Comment fonctionne la vulnérabilité JPEG of Death?

94

J'ai lu sur un ancien exploit contre GDI + sur Windows XP et Windows Server 2003 appelé le JPEG de la mort pour un projet sur lequel je travaille.

L'exploit est bien expliqué dans le lien suivant: http://www.infosecwriters.com/text_resources/pdf/JPEG.pdf

Fondamentalement, un fichier JPEG contient une section appelée COM contenant un champ de commentaire (éventuellement vide) et une valeur de deux octets contenant la taille de COM. S'il n'y a aucun commentaire, la taille est 2. Le lecteur (GDI +) lit la taille, soustrait deux et alloue un tampon de la taille appropriée pour copier les commentaires dans le tas. L'attaque consiste à placer une valeur de 0sur le terrain. GDI + soustrait 2, conduisant à une valeur -2 (0xFFFe)dont la valeur est convertie en entier non signé 0XFFFFFFFEpar memcpy.

Exemple de code:

unsigned int size;
size = len - 2;
char *comment = (char *)malloc(size + 1);
memcpy(comment, src, size);

Observez que malloc(0)sur la troisième ligne doit renvoyer un pointeur vers la mémoire non allouée sur le tas. Comment écrire0XFFFFFFFE octets (4GB !!!!) peut-elle ne pas planter le programme? Cela écrit-il au-delà de la zone de tas et dans l'espace d'autres programmes et du système d'exploitation? Que se passe-t-il alors?

Si je comprends bien memcpy, il copie simplement les ncaractères de la destination vers la source. Dans ce cas, la source doit être sur la pile, la destination sur le tas et l' nest 4GB.

Rafa
la source
malloc allouera de la mémoire à partir du tas. je pense que l'exploit a été fait avant memcpy et après l'allocation de la mémoire
iedoc
juste comme remarque: ce n'est pas memcpy ce qui promeut la valeur en un entier non signé (4 octets), mais plutôt la soustraction.
rév
1
Mise à jour de ma réponse précédente avec un exemple en direct. La malloctaille ed n'est que de 2 octets au lieu de 0xFFFFFFFE. Cette taille énorme n'est utilisée que pour la taille de la copie, pas pour la taille d'allocation.
Neitsa

Réponses:

96

Cette vulnérabilité était certainement un débordement de tas .

Comment l'écriture d'octets 0XFFFFFFFE (4 Go !!!!) peut-elle ne pas planter le programme?

Ce sera probablement le cas, mais à certaines occasions, vous avez le temps d'exploiter avant que le crash ne se produise (parfois, vous pouvez ramener le programme à son exécution normale et éviter le crash).

Au démarrage de memcpy (), la copie écrasera soit certains autres blocs du tas, soit certaines parties de la structure de gestion du tas (par exemple, liste libre, liste occupée, etc.).

À un moment donné, la copie rencontrera une page non allouée et déclenchera une AV (violation d'accès) en écriture. GDI + essaiera alors d'allouer un nouveau bloc dans le tas (voir ntdll! RtlAllocateHeap ) ... mais les structures du tas sont maintenant toutes en désordre.

À ce stade, en élaborant soigneusement votre image JPEG, vous pouvez écraser les structures de gestion du tas avec des données contrôlées. Lorsque le système essaie d'allouer le nouveau bloc, il dissociera probablement un bloc (gratuit) de la liste libre.

Les blocs sont gérés avec (notamment) des pointeurs flink (lien vers l'avant; le bloc suivant dans la liste) et clignotant (lien vers l'arrière; le bloc précédent dans la liste). Si vous contrôlez à la fois le clignotement et le clignotement, vous pourriez avoir une possible WRITE4 (condition d'écriture Quoi / Où) où vous contrôlez ce que vous pouvez écrire et où vous pouvez écrire.

À ce stade, vous pouvez écraser un pointeur de fonction (les pointeurs SEH [Structured Exception Handlers] étaient une cible de choix à l'époque en 2004) et obtenir l'exécution de code.

Voir l'article de blog Heap Corruption: A Case Study .

Remarque: bien que j'aie écrit sur l'exploitation en utilisant la liste libre, un attaquant pourrait choisir un autre chemin en utilisant d'autres métadonnées du tas (les «métadonnées du tas» sont des structures utilisées par le système pour gérer le tas; flink et blink font partie des métadonnées du tas), mais l'exploitation de la dissociation est probablement la plus "facile". Une recherche google pour «exploitation de tas» renverra de nombreuses études à ce sujet.

Cela écrit-il au-delà de la zone de tas et dans l'espace d'autres programmes et du système d'exploitation?

Jamais. Les systèmes d'exploitation modernes sont basés sur le concept d'espace d'adressage virtuel, de sorte que chaque processus possède son propre espace d'adressage virtuel qui permet d'adresser jusqu'à 4 gigaoctets de mémoire sur un système 32 bits (en pratique, vous n'en avez que la moitié dans l'espace utilisateur, le reste est pour le noyau).

En bref, un processus ne peut pas accéder à la mémoire d'un autre processus (sauf s'il le demande au noyau via un service / une API, mais le noyau vérifiera si l'appelant a le droit de le faire).


J'ai décidé de tester cette vulnérabilité ce week-end, afin que nous puissions avoir une bonne idée de ce qui se passait plutôt que de la pure spéculation. La vulnérabilité a maintenant 10 ans, donc j'ai pensé que c'était correct d'écrire à ce sujet, même si je n'ai pas expliqué la partie exploitation dans cette réponse.

Planification

La tâche la plus difficile était de trouver un Windows XP avec uniquement SP1, comme c'était le cas en 2004 :)

Ensuite, j'ai téléchargé une image JPEG composée uniquement d'un seul pixel, comme indiqué ci-dessous (par souci de brièveté):

File 1x1_pixel.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF E0|00 10 4A 46|49 46 00 01|01 01 00 60| ÿØÿà JFIF  `
00000010  00 60 00 00|FF E1 00 16|45 78 69 66|00 00 49 49|  `  ÿá Exif  II
00000020  2A 00 08 00|00 00 00 00|00 00 00 00|FF DB 00 43| *          ÿÛ C
[...]

Une image JPEG est composée de marqueurs binaires (qui intrduisent des segments). Dans l'image ci-dessus, FF D8est le marqueur SOI (Start Of Image), tandis queFF E0 , par exemple, est un marqueur d'application.

Le premier paramètre dans un segment de marqueur (à l'exception de certains marqueurs comme SOI) est un paramètre de longueur de deux octets qui code le nombre d'octets dans le segment de marqueur, y compris le paramètre de longueur et à l'exclusion du marqueur de deux octets.

J'ai simplement ajouté un marqueur COM (0x FFFE) juste après le SOI, car les marqueurs n'ont pas d'ordre strict.

File 1x1_pixel_comment_mod1.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF FE|00 00 30 30|30 30 30 30|30 31 30 30| ÿØÿþ  0000000100
00000010  30 32 30 30|30 33 30 30|30 34 30 30|30 35 30 30| 0200030004000500
00000020  30 36 30 30|30 37 30 30|30 38 30 30|30 39 30 30| 0600070008000900
00000030  30 61 30 30|30 62 30 30|30 63 30 30|30 64 30 30| 0a000b000c000d00
[...]

La longueur du segment COM est définie sur 00 00 pour déclencher la vulnérabilité. J'ai également injecté des octets 0xFFFC juste après le marqueur COM avec un motif récurrent, un nombre de 4 octets en hexadécimal, ce qui deviendra pratique lors de "l'exploitation" de la vulnérabilité.

Débogage

Un double-clic sur l'image déclenchera immédiatement le bogue dans le shell Windows (aka "explorer.exe"), quelque part dans gdiplus.dll, dans une fonction nomméeGpJpegDecoder::read_jpeg_marker() .

Cette fonction est appelée pour chaque marqueur de l'image, elle: lit simplement la taille du segment marqueur, alloue un tampon dont la longueur est la taille du segment et copie le contenu du segment dans ce tampon nouvellement alloué.

Voici le début de la fonction:

.text:70E199D5  mov     ebx, [ebp+arg_0] ; ebx = *this (GpJpegDecoder instance)
.text:70E199D8  push    esi
.text:70E199D9  mov     esi, [ebx+18h]
.text:70E199DC  mov     eax, [esi]      ; eax = pointer to segment size
.text:70E199DE  push    edi
.text:70E199DF  mov     edi, [esi+4]    ; edi = bytes left to process in the image

eax register pointe vers la taille du segment et edi correspond au nombre d'octets restants dans l'image.

Le code procède ensuite à la lecture de la taille du segment, en commençant par l'octet le plus significatif (la longueur est une valeur de 16 bits):

.text:70E199F7  xor     ecx, ecx        ; segment_size = 0
.text:70E199F9  mov     ch, [eax]       ; get most significant byte from size --> CH == 00
.text:70E199FB  dec     edi             ; bytes_to_process --
.text:70E199FC  inc     eax             ; pointer++
.text:70E199FD  test    edi, edi
.text:70E199FF  mov     [ebp+arg_0], ecx ; save segment_size

Et l'octet le moins significatif:

.text:70E19A15  movzx   cx, byte ptr [eax] ; get least significant byte from size --> CX == 0
.text:70E19A19  add     [ebp+arg_0], ecx   ; save segment_size
.text:70E19A1C  mov     ecx, [ebp+lpMem]
.text:70E19A1F  inc     eax             ; pointer ++
.text:70E19A20  mov     [esi], eax
.text:70E19A22  mov     eax, [ebp+arg_0] ; eax = segment_size

Une fois cela fait, la taille du segment est utilisée pour allouer un tampon, en suivant ce calcul:

alloc_size = taille_segment + 2

Cela se fait par le code ci-dessous:

.text:70E19A29  movzx   esi, word ptr [ebp+arg_0] ; esi = segment size (cast from 16-bit to 32-bit)
.text:70E19A2D  add     eax, 2 
.text:70E19A30  mov     [ecx], ax 
.text:70E19A33  lea     eax, [esi+2] ; alloc_size = segment_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

Dans notre cas, comme la taille du segment est 0, la taille allouée pour le tampon est de 2 octets .

La vulnérabilité est juste après l'attribution:

.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)
.text:70E19A3C  test    eax, eax
.text:70E19A3E  mov     [ebp+lpMem], eax ; save pointer to allocation
.text:70E19A41  jz      loc_70E19AF1
.text:70E19A47  mov     cx, [ebp+arg_4]   ; low marker byte (0xFE)
.text:70E19A4B  mov     [eax], cx         ; save in alloc (offset 0)
;[...]
.text:70E19A52  lea     edx, [esi-2]      ; edx = segment_size - 2 = 0 - 2 = 0xFFFFFFFE!!!
;[...]
.text:70E19A61  mov     [ebp+arg_0], edx

Le code soustrait simplement la taille du segment_size (la longueur du segment est une valeur de 2 octets) de la taille du segment entier (0 dans notre cas) et se termine par un sous- dépassement d' entier: 0 - 2 = 0xFFFFFFFE

Le code vérifie ensuite s'il reste des octets à analyser dans l'image (ce qui est vrai), puis passe à la copie:

.text:70E19A69  mov     ecx, [eax+4]  ; ecx = bytes left to parse (0x133)
.text:70E19A6C  cmp     ecx, edx      ; edx = 0xFFFFFFFE
.text:70E19A6E  jg      short loc_70E19AB4 ; take jump to copy
;[...]
.text:70E19AB4  mov     eax, [ebx+18h]
.text:70E19AB7  mov     esi, [eax]      ; esi = source = points to segment content ("0000000100020003...")
.text:70E19AB9  mov     edi, dword ptr [ebp+arg_4] ; edi = destination buffer
.text:70E19ABC  mov     ecx, edx        ; ecx = copy size = segment content size = 0xFFFFFFFE
.text:70E19ABE  mov     eax, ecx
.text:70E19AC0  shr     ecx, 2          ; size / 4
.text:70E19AC3  rep movsd               ; copy segment content by 32-bit chunks

L'extrait de code ci-dessus montre que la taille de la copie est 0xFFFFFFFE morceaux de 32 bits. Le tampon source est contrôlé (contenu de l'image) et la destination est un tampon sur le tas.

Condition d'écriture

La copie déclenchera une exception de violation d'accès (AV) lorsqu'elle atteint la fin de la page mémoire (cela peut provenir du pointeur source ou du pointeur de destination). Lorsque l'AV est déclenché, le tas est déjà dans un état vulnérable car la copie a déjà écrasé tous les blocs de tas suivants jusqu'à ce qu'une page non mappée soit rencontrée.

Ce qui rend ce bogue exploitable, c'est que 3 SEH (Structured Exception Handler; c'est try / except at low level) attrapent des exceptions sur cette partie du code. Plus précisément, le 1er SEH déroulera la pile afin qu'il revienne pour analyser un autre marqueur JPEG, sautant ainsi complètement le marqueur qui a déclenché l'exception.

Sans SEH, le code aurait juste fait planter tout le programme. Ainsi, le code ignore le segment COM et analyse un autre segment. Nous revenons donc GpJpegDecoder::read_jpeg_marker()à un nouveau segment et au moment où le code alloue un nouveau tampon:

.text:70E19A33  lea     eax, [esi+2] ; alloc_size = semgent_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

Le système dissociera un bloc de la liste libre. Il arrive que les structures de métadonnées aient été écrasées par le contenu de l'image; nous contrôlons donc la dissociation avec des métadonnées contrôlées. Le code ci-dessous quelque part dans le système (ntdll) dans le gestionnaire de tas:

CPU Disasm
Address   Command                                  Comments
77F52CBF  MOV ECX,DWORD PTR DS:[EAX]               ; eax points to '0003' ; ecx = 0x33303030
77F52CC1  MOV DWORD PTR SS:[EBP-0B0],ECX           ; save ecx
77F52CC7  MOV EAX,DWORD PTR DS:[EAX+4]             ; [eax+4] points to '0004' ; eax = 0x34303030
77F52CCA  MOV DWORD PTR SS:[EBP-0B4],EAX
77F52CD0  MOV DWORD PTR DS:[EAX],ECX               ; write 0x33303030 to 0x34303030!!!

Maintenant, nous pouvons écrire ce que nous voulons, où nous voulons ...

Neitsa
la source
3

Puisque je ne connais pas le code de GDI, ce qui est ci-dessous n'est que spéculation.

Eh bien, une chose qui me vient à l'esprit est un comportement que j'ai remarqué sur certains systèmes d'exploitation (je ne sais pas si Windows XP en avait) lors de l'allocation avec de nouveaux / malloc , vous pouvez en fait allouer plus que votre RAM, tant que vous n'écrivez pas dans cette mémoire.

C'est en fait un comportement du noyau Linux.

Depuis www.kernel.org:

Les pages de l'espace d'adressage linéaire de processus ne résident pas nécessairement en mémoire. Par exemple, les allocations effectuées pour le compte d'un processus ne sont pas satisfaites immédiatement car l'espace est simplement réservé dans la vm_area_struct.

Pour entrer dans la mémoire résidente, une erreur de page doit être déclenchée.

Fondamentalement, vous devez rendre la mémoire sale avant qu'elle ne soit réellement allouée sur le système:

  unsigned int size=-1;
  char* comment = new char[size];

Parfois, il ne fera pas réellement une allocation réelle en RAM (votre programme n'utilisera toujours pas 4 Go). Je sais que j'ai vu ce comportement sur un Linux, mais je ne peux cependant pas le répliquer maintenant sur mon installation Windows 7.

À partir de ce comportement, le scénario suivant est possible.

Afin de rendre cette mémoire existante dans la RAM, vous devez la rendre sale (essentiellement memset ou une autre écriture dessus):

  memset(comment, 0, size);

Cependant, la vulnérabilité exploite un débordement de tampon, pas un échec d'allocation.

En d'autres termes, si je devais avoir ceci:

 unsinged int size =- 1;
 char* p = new char[size]; // Will not crash here
 memcpy(p, some_buffer, size);

Cela conduira à une écriture après tampon, car il n'existe pas de segment de 4 Go de mémoire continue.

Vous n'avez rien mis dans p pour salir les 4 Go de mémoire, et je ne sais pas si la memcpymémoire est sale en même temps, ou juste page par page (je pense que c'est page par page).

Finalement, il finira par écraser le cadre de la pile (Stack Buffer Overflow).

Une autre vulnérabilité plus possible était si l'image était conservée en mémoire sous forme de tableau d'octets (lecture du fichier entier dans la mémoire tampon), et la taille des commentaires était utilisée juste pour sauter des informations non vitales.

Par exemple

     unsigned int commentsSize = -1;
     char* wholePictureBytes; // Has size of file
     ...
     // Time to start processing the output color
     char* p = wholePictureButes;
     offset = (short) p[COM_OFFSET];
     char* dataP = p + offset;
     dataP[0] = EvilHackerValue; // Vulnerability here

Comme vous l'avez mentionné, si le GDI n'alloue pas cette taille, le programme ne plantera jamais.

MichaelCMS
la source
4
Cela pourrait être avec un système 64 bits, où 4 Go n'est pas un gros problème (en parlant d'espace addess). Mais dans un système 32 bits (ils semblent également vulnérables), vous ne pouvez pas réserver 4 Go d'espace d'adressage, car ce serait tout ce qu'il y a! Donc, un malloc(-1U)échouera sûrement, reviendra NULLet memcpy()plantera.
rodrigo
9
Je ne pense pas que cette ligne soit vraie: "Finalement, il finira par écrire dans une autre adresse de processus." Normalement, un processus ne peut pas accéder à la mémoire d'un autre. Voir les avantages de MMU .
chue x
Avantages @MMU oui, vous avez raison. J'étais censé dire que cela va dépasser les limites normales du tas et commencer à écraser le cadre de la pile. Je vais éditer ma réponse, merci de l'avoir signalé.
MichaelCMS