Cet article est le premier d'une série qui traitera des protections contre l'exploitation des débordements de buffer. Chaque article présentera différentes méthodes de protections, et ce contre quoi elle protège précisément. En effet, il ne faut pas installer ces protections et croire qu'elles constituent un rempart infranchissable.
Avant de rentrer dans le vif du sujet dès le prochain article, nous rappelons ici quelques notions indispensables à la compréhension de la suite, comme le format ELF des binaires Linux, l'organisation de la mémoire des processus et la PLT/GOT (Procedure Linkage Table/Global Offset Table).
Le format ELF (Executable and Linking Format -- format d'exécution et d'édition de liens) est le format actuel des binaires sous Linux. Il a remplacé le format a.out pour différentes raisons :
La principale chose à connaître sur ce format est son
organisation. En fait, un binaire au format ELF est découpé en
plusieurs sections. Chacune possède sa propre finalité. Par
exemple, la section .text
contient les instructions
machines du programme, c'est-à-dire son code exécutable. Ainsi, une
fois chargée en mémoire, comme un processus ne peut modifier son
propre code, toutes les autres instances de ce programme utiliseront
cette même portion de mémoire. La section .text
est
chargée une seule et unique fois pour tous les processus issus de ce
binaire.
La commande file
fournit les renseignements relatifs
au format d'un fichier :
$ file /usr/bin/vim /usr/bin/vim: ELF 32-bit LSB executable, Intel 80386, version 1, dynamically linked (uses shared libs), stripped
Le format ELF possède également une table des symboles,
c'est-à-dire une liste de tous les symboles (labels, noms de
fonctions, adresses de variables, etc.) qui sont définis ou
référencés dans le fichier, ainsi que des informations sur ces
symboles. Examinons les informations fournies par
hello.c
:
/* hello.c */ #include <stdio.h> char world[6] = "world"; char * empty; main(int argc, char ** argv ) { printf( "Hello %s\n", argv[1] ); }
Avec gcc
, nous transformons ces instructions en
fichier objet, puis la commande nm
en affiche le
contenu :
$ gcc -c hello.c -o hello.o $ ls hello.c hello.o $ nm hello.o 00000004 C empty 00000000 t gcc2_compiled. 00000000 T main U printf 00000000 D world
La commande nm
affiche tous les symboles contenus
dans un fichier objet. Pour chaque symbole, nm
donne :
.bss
;.data
;empty
est défini mais pas encore
initialisé. S'il ne l'est nulle part, son type changera
alors en B
;.text
(code
) ;
Il existe de nombreux autres types décrits dans la page
info nm
;
Dans le fichier objet, la fonction printf()
n'est pas
encore définie. Dans le fichier exécutable, il faudra connaître
l'emplacement de cette fonction (i.e. la bibliothèque et son
adresse dans celle-ci). Comme cette fonction est externe, un mécanisme
de réadressage est prévu. Tout d'abord, il contient un décalage
(offset) dans la table des symboles qui référence le symbole
lui-même. Ensuite, il recèle un décalage dans la section
.text
qui réfère l'adresse du code de la fonction. Enfin,
un tag indique le type de réadressage utilisé.
Lors de l'édition de liens, le linker recherche l'adresse
réelle de la fonction printf()
. Une fois découverte, elle
est recopiée en mémoire afin que les appels à la fonction soient
effectués sans repasser par cette étape de résolution.
Ce mécanisme décrit de manière très générale le comportement de la PLT (Procedure Linkage Table) et de la GOT (Global Offset Table). De plus amples détails sont donnés ci-après.
Nous ne détaillons pas ici le fonctionnement de la mémoire d'un processus, mais simplement l'organisation de ses régions mémoire.
Au cours de l'exécution d'un programme, il est tout à fait
possible de retrouver les caractéristiques des régions (plage
d'adresses, droits d'accès ...) grâce au fichier maps
du
processus, dans le système de fichiers /proc
(/proc/<pid>/maps
). Même si ces informations ne
sont pas toujours exactes, elles décrivent néanmoins l'organisation du
processus dans la mémoire :
$ /bin/cat /proc/11384/maps 08048000-080ca000 r-xp 00000000 03:01 419059 /usr/bin/vim [1] 080ca000-080d1000 rw-p 00081000 03:01 419059 /usr/bin/vim [2] 080d1000-080f8000 rwxp 00000000 00:00 0 [3] 40000000-40012000 r-xp 00000000 03:01 225598 /lib/ld-2.1.3.so 40012000-40014000 rw-p 00011000 03:01 225598 /lib/ld-2.1.3.so 40016000-40048000 r-xp 00000000 03:01 225579 /lib/libncurses.so.5.0 40048000-40050000 rw-p 00031000 03:01 225579 /lib/libncurses.so.5.0 40050000-40055000 rw-p 00000000 00:00 0 40055000-40059000 r-xp 00000000 03:01 563425 /usr/lib/libgpm.so.1.17.3 40059000-4005b000 rw-p 00003000 03:01 563425 /usr/lib/libgpm.so.1.17.3 4005b000-40130000 r-xp 00000000 03:01 225600 /lib/libc-2.1.3.so 40130000-40134000 rw-p 000d4000 03:01 225600 /lib/libc-2.1.3.so 40134000-40138000 rw-p 00000000 00:00 0 40138000-40142000 r-xp 00000000 03:01 225613 /lib/libnss_compat-2.1.3.so 40142000-40143000 rw-p 00009000 03:01 225613 /lib/libnss_compat-2.1.3.so 40143000-40155000 r-xp 00000000 03:01 225606 /lib/libnsl-2.1.3.so 40155000-40157000 rw-p 00011000 03:01 225606 /lib/libnsl-2.1.3.so 40157000-40159000 rw-p 00000000 00:00 0 bfffb000-c0000000 rwxp ffffc000 00:00 0 [4]La ligne [1] représente la région mémoire
.text
où le
code exécutable du programme est chargé. La commande objdump
-d
affiche les instructions Assembleur présentes dans cette
section. La ligne [2] indique la région des données globales
initialisées (.data
), et la [3] la région des données
globales non initialisées (.bss
).
La commande objdump
est une espèce de couteau suisse
pour lire ces informations :
$ /usr/bin/objdump -h /usr/bin/vim /usr/bin/vim: file format elf32-i386 Sections: Idx Name Size VMA LMA File off Algn [...] 12 .text 00073eec 08049c90 08049c90 00001c90 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE [...] 15 .data 000058d0 080ca940 080ca940 00081940 2**5 CONTENTS, ALLOC, LOAD, DATA [...] 21 .bss 00002ecc 080d04a0 080d04a0 000874a0 2**5 ALLOC
Signalons que la commande readelf
est capable de
performances identiques.
Lorsqu'un programme au format ELF est lancé, le noyau organise la
mémoire virtuelle allouée au processus : des plages mémoires sont
réservées pour les besoins du programme (pile, tas, données, code,
etc). S'il utilise des bibliothèques dynamiques, le binaire contient
le nom de l'éditeur de liens à utiliser
(/lib/ld-linux.so.2
en général) dans la section
.interp
:
$ /usr/bin/objdump -s -j .interp /usr/bin/vim /usr/bin/vim: file format elf32-i386 Contents of section .interp: 80480f4 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so 8048104 2e3200 .2.
Le noyau passe d'abord le contrôle des opérations à l'éditeur de liens afin qu'il charge les symboles (c'est-à-dire les références aux fonctions et variables des bibliothèques dynamiques ou d'autres fichiers objet, que nous avons vues précédemment) qui ne sont pas encore résolus, puis au programme qui commence alors le cours normal de son exécution.
Comme il existe différents types de variables, il existe également
différentes zones de mémoires dans lesquelles celles-ci sont stockées.
Nous savons déjà qu'il existe les sections .data
et
.bss
(cf. le paragraphe précédent). Ces zones sont
réservées dès la compilation car leur taille est définie et connue de
par la nature même des objets qu'elles contiennent.
Se pose maintenant le problème des variables locales et des variables dynamiques. Elles sont regroupées dans une zone mémoire réservée à l'exécution du programme (user stack frame). Les fonctions pouvant s'invoquer de manière récurrente, le nombre d'instances d'une variable locale n'est pas connu à l'avance. Elles seront donc placées, au moment de leur définition dans la pile du processus (stack). Cette pile se situe dans les adresses hautes de l'espace d'adressage de l'utilisateur, et fonctionne sur un modèle LIFO (Last In, First Out), dernier entré, premier sorti.
Le bas de la zone user frame sert à l'allocation des variables dynamiques. Cette région s'appelle le tas (heap) : elle contient les zones mémoires adressées par les pointeurs, les variables dynamiques. Lors de sa déclaration un pointeur occupe 32 bits soit dans BSS, soit dans la pile et ne pointe nulle part en particulier. Lors de son allocation, il reçoit une adresse qui correspond à celle du premier octet réservé pour lui dans le tas.
L'exemple suivant illustre la disposition des variables en mémoire :
/* mem.c */ int indice = 1; //dans data char * str; //dans bss int rien; //dans bss void f( char c ) { int i; //dans la pile /* Réservation de 5 caractères dans le tas */ str = ( char * ) malloc ( 5 * sizeof (char) ); strncpy( str, "abcde", 5 ); } int main( void ) { f( 0 ); }
Des débordements de buffer peuvent se produire indistinctement dans
ces régions. Nous illustrons ceci simplement avec quatre petits
programmes qui simulent un débordement. Ils vont nous permettre de
constater l'imprécision de certaines informations contenues dans le
système de fichier /proc
:
.data
$ cat sh_data.c /* sh_data.c */ 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"; int main() { int * ret; *( (int *) & ret + 2 ) = ( int ) shellcode; sleep( 5 ); return( 0 ); } $ ./sh_data sh-2.04$
gdb
nous permet (comme toujours ;) de mieux voir les
choses :
(gdb) info symbol shellcode shellcode in section .data (gdb) p &shellcode $2 = (char (*)[46]) 0x8049520
/proc
pour obtenir des informations sur la mémoire
utilisée par le processus, nous obtenons les informations
suivantes :
$ ./sh_data ^Z [3]+ Stopped ./sh_data $ cat /proc/`ps | grep sh_ | awk '{print $1}'`/maps 00110000-00126000 r-xp 00000000 08:01 26579 /lib/ld-2.2.2.so 00126000-00127000 rw-p 00015000 08:01 26579 /lib/ld-2.2.2.so 00127000-00128000 rw-p 00000000 00:00 0 00133000-0025c000 r-xp 00000000 08:01 26588 /lib/libc-2.2.2.so 0025c000-00261000 rw-p 00128000 08:01 26588 /lib/libc-2.2.2.so 00261000-00265000 rw-p 00000000 00:00 0 08048000-08049000 r-xp 00000000 08:03 884812 /tmp/sh_data 08049000-0804a000 rw-p 00000000 08:03 884812 /tmp/sh_data bfffe000-c0000000 rwxp fffff000 00:00 0Comme vous pouvez le constater, notre shellcode se situe à l'adresse
0x8049520
. Or, cette zone n'est pas
marquée comme exécutable dans
/proc/<pid>/maps
! Et pourtant, il
tourne ;)
.bss
/* sh_bss.c */ char shellcode[64]; int main() { int * ret; memset( shellcode, 0, 64 ); sprintf( 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" ); * ( (int *) & ret + 2 ) = (int)shellcode; return( 0 ); }La variable globale
shellcode
est définie, mais
n'est initialisée que dans la fonction main()
. Elle
se situe dans dans la zone .bss
:
(gdb) info symbol shellcode shellcode in section .bss (gdb) p &shellcode $1 = (char (*)[64]) 0x80496c0
Bien que l'adresse du shellcode le situe dans une zone marquée
rw-
, nous parvenons tout de même à l'exécuter :
$ ./sh_bss sh-2.04$
$ cat sh_heap.c /* sh_heap.c */ int main() { int * ret; char * shellcode = ( char * ) malloc( 64 ); sprintf( 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" ); *( (int *) & ret + 2 ) = ( int ) shellcode; return( 0 ); }La variable
shellcode
se trouve dans la pile
(stack), mais la mémoire qui lui est allouée lors du
malloc()
est réservée dans le tas
(heap) :
(gdb) p &shellcode $1 = (char **) 0xbffff6d0 //dans la pile (gdb) info symbol 0xbffff6d0 No symbol matches 0xbffff6d0. (gdb) p shellcode $2 = 0x80496b0 "ë\037^\211v\b1À\210F\a\211F\f°\013\211ó\215N\b\215V\fÍ\2001Û\211Ø(at)Í\200èÜÿÿÿ/bin/sh"Lorsque nous l'exécutons, tout se déroule sans surprise, bien que la mémoire allouée pour
shellcode
dans le tas (en
0x80496b0
) soit toujours indiquée comme non
exécutable :
$ ./sh_heap sh-2.04$
$ cat sh_stack.c /* sh_stack.c */ int main() { int * ret; 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"; *( (int *) & ret + 4 ) = ( int ) shellcode; return( 0 ); }Ici, le décalage vers l'adresse de retour est différent car des registres sont placés sur la pile à l'entrée de la fonction (un
disass main
sous gdb
montre ceci).
(gdb) p &shellcode $2 = (char (*)[46]) 0xbffff6d0 //dans la pile ... $ ./sh_stack sh-2.04$Cette fois, tout se passe comme prévu puisque cette zone est bien indiquée comme exécutable dans le système de fichiers
/proc
;)
Maintenant que nous avons vu la disposition de la mémoire et des variables, revenons à l'édition de liens.
Une section qui nous intéresse particulièrement est la Procedure Linkage Table (ou PLT). Elle joue en quelque sorte le rôle d'éditeur de liens (ou linker) pour les fonctions. Par défaut, toutes ses entrées sont initialisées non pas pour pointer vers la bonne fonction, mais sur l'éditeur de liens lui-même (celui dont nous avons parlé auparavant). Au premier appel d'une fonction donnée, le linker recherche la fonction dans la bibliothèque appropriée et met à jour son adresse. Le prochain appel de la fonction pointe ainsi directement où il faut.
$ /bin/cat elf.c #include <stdio.h> main() { printf( "Bonjour monde\n" ); } $ make elf cc elf.c -o elf $ gdb ./elf [...] (gdb) disass main Dump of assembler code for function main: 0x80483e0 <main>: push %ebp 0x80483e1 <main+1>: mov %esp,%ebp 0x80483e3 <main+3>: sub $0x8,%esp 0x80483e6 <main+6>: add $0xfffffff4,%esp 0x80483e9 <main+9>: push $0x8048460 0x80483ee <main+14>: call 0x804830c <printf> 0x80483f3 <main+19>: add $0x10,%esp 0x80483f6 <main+22>: jmp 0x8048400 <main+32> 0x80483f8 <main+24>: jmp 0x8048402 <main+34> 0x80483fa <main+26>: lea 0x0(%esi),%esi 0x8048400 <main+32>: jmp 0x80483f6 <main+22> 0x8048402 <main+34>: jmp 0x8048404 <main+36> 0x8048404 <main+36>: leave 0x8048405 <main+37>: ret [...] End of assembler dump. (gdb) disass printf Dump of assembler code for function printf: 0x804830c <printf>: jmp *0x80494a8 0x8048312 <printf+6>: push $0x18 0x8048317 <printf+11>: jmp 0x80482cc <_init+52> End of assembler dump. (gdb) x 0x80494a8 0x80494a8 <_GLOBAL_OFFSET_TABLE_+24>: 0x08048312
La fonction main()
contient un appel à la fonction
printf()
. En examinant le contenu de la mémoire à
l'adresse indiquée (0x804830c
, i.e. l'adresse de
printf()
), nous constatons que la première instruction
exécutée est en fait un saut à une adresse contenue dans la section
.got
(Global Offset Table ou GOT). En simplifiant,
cette GOT joue le rôle d'index de la PLT : elle signale qu'il
faut revenir dans la PLT en 0x08048312
, soit juste après
le saut. Ensuite, un autre saut rend l'exécution du programme au
linker pour qu'il recherche l'adresse de la fonction dans la
bibliothèque adéquate.
Précisons qu'il est tout à fait possible d'obtenir les mêmes résultats avec la commande objdump :
$ /usr/bin/objdump -T ./elf | grep printf 0804830c DF *UND* 0000002f GLIBC_2.0 printf $ /usr/bin/objdump -R ./elf | grep printf 080494a8 R_386_JUMP_SLOT printf
La première donne l'adresse de la PLT de la fonction
printf()
, et la seconde son entrée dans le GOT.
Il faut bien comprendre ici le rôle distinct de la PLT et de la
GOT. La première effectue une action : construire le lien entre
une fonction requise dans le code du programme et le code machine
associé dans une bibliothèque. En quelque sorte, la PLT est un
mini-éditeur de liens. D'ailleurs, tout comme la section
.text
qui contient les instructions du programme, la PLT
est en lecture seule. De son côté, la GOT, qui est en
lecture/écriture, est un annuaire qui référence juste l'adresse d'une
fonction (en toute rigueur, elle indexe également les variables
globales définies dans les bibliothèques et utilisées dans le
programme)
Cette approche s'appelle lazy symbol binding (résolution tardive des symboles). L'idée est que si un programme utilise beaucoup de bibliothèques dynamiques, l'édition de liens est très (trop) longue. Ainsi, celle-ci ne se fait que lorsqu'il y en a réellement besoin.
Il est possible de forcer la résolution des symboles par l'éditeur
de liens avec la variable d'environnement LD_BIND_NOW
dès
l'appel du programme, et non plus lorsqu'un symbole est requis
:
$ export LD_BIND_NOW=1 $ gdb ./elf [...] (gdb) b main Breakpoint 1 at 0x80483e6 (gdb) r Starting program: /home/zorgon/dev/articles/intro/./elf Breakpoint 1, 0x80483e6 in main () (gdb) disass printf Dump of assembler code for function printf: 0x804830c: jmp *0x80494a8 0x8048312 : push $0x18 0x8048317 : jmp 0x80482cc <_init+52> End of assembler dump. (gdb) x 0x80494a8 0x80494a8 <_GLOBAL_OFFSET_TABLE_+24>: 0x40059d44 (gdb) info symbol 0x40059d44 printf in section .text (gdb)
Cette fois, la résolution est faite avant même l'exécution de la
fonction printf()
. Nous remarquons que l'adresse contenue
dans la GOT pointe maintenant dans la section .text
où se
trouvent les instructions de la fonction.
Pour illustrer ce mécanisme, nous montrons maintenant comment transformer l'appel d'une fonction en une autre à l'aide d'un petit programme très simple :
$ /bin/cat foobar.c #include <stdio.h> #include <stdlib.h> int main( int argc, char * argv[] ) { unsigned int got_addr = strtoul( argv[1], 0, 16 ); unsigned int value = strtoul( argv[2], 0, 16 ); * (int *) got_addr = value; printf( argv[3] ); return; } $ gcc foobar.c -o foobar
Nous voulons que le programme foobar
transforme
l'appel de la fonction printf()
en un appel à
system()
en allant modifier la GOT. Pour y parvenir, nous
devons nous procurer deux informations :
printf()
dans la GOT :
$ /usr/bin/objdump -R ./foobar | grep printf 08049518 R_386_JUMP_SLOT printf
system()
dans la libc :
$ gdb ./foobar [...] (gdb) b main Breakpoint 1 at 0x8048426 (gdb) r Starting program: /home/zorgon/dev/articles/intro/./foobar Breakpoint 1, 0x8048426 in main () (gdb) p system $1 = {<text variable, no debug info>} 0x4004e2f0 <system>
Ainsi, la PLT va chercher l'adresse de la fonction
printf()
en 0x08049518
. Il nous suffit alors
de remplacer le contenu de cette adresse par 0x4004e2f0
qui correspond à l'adresse de la fonction system()
en
mémoire, ce qui est réalisé par l'instruction * (int *) got_addr
= value;
:
$ ./foobar 0x08049518 0x4004e2f0 /bin/sh sh-2.03$
Nous avons présenté ici différentes notions relatives à l'exécution d'un programme. Chacune nous servira dans le prochain article où nous étudierons de multiples solutions offertes sous Linux pour se prémunir de l'exécution de shellcode résultant d'un débordement de buffer : Openwall, Stackguard, PaX, LibSafe. Nous détaillerons les mécanismes mis en oeuvre par ces approches et les défenses qu'elles fournissent, mais nous en verrons également les limites.