Les options de sécurité de gcc

GNU/Linux Magazine HS n° 076 | janvier 2015 | Julien Perrot
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Cet article vise à décrire les principales options de gcc permettant de renforcer la sécurité des programmes ainsi que les attaques couvertes par ces options.

gcc (GNU Compiler Collection) est le compilateur bien connu maintenu par le projet GNU et disponible sur la plupart des systèmes de type Unix et sous Windows grâce aux projets Cygwin et MinGW. Initialement conçu comme compilateur pour le langage C, gcc a bénéficié, depuis sa première version sortie en 1987, de nombreuses améliorations, que ce soit au niveau des langages acceptés (C++, Objective-C, Fortran, Java, Ada, etc.), mais également en ce qui concerne les architectures matérielles supportées. Parmi ces améliorations, on notera également l'ajout de nouvelles fonctionnalités visant à renforcer la sécurité des programmes générés par gcc. Ces fonctionnalités sont activables au moment de la compilation en spécifiant les options correspondantes en ligne de commandes et peuvent être classées en deux catégories : celles qui influent sur le code machine produit par le compilateur et celles qui tentent d'identifier des problèmes de sécurité en analysant le code source du programme.

1. Options de renforcement du code

Les options présentées dans cette section agissent directement sur le code machine produit par le compilateur. Pour mesurer l'impact de chacune de celles-ci, la démarche présentée dans cet article est de partir d'un programme C sample.c, de le compiler en désactivant toutes les options de sécurité puis de les activer une par une afin de comprendre les modifications apportées par chacune.

Ce programme, dont le code source est présenté ci-dessous, se contente d'appeler une fonction pour copier le premier argument du programme vers un tampon situé sur la pile de la fonction puis affiche cet argument sur la sortie standard :

/* sample.c */

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

 

void copy_and_print_string(const char *str) {

        char buf[32];

 

        strcpy(buf, str);

        printf(buf);

        printf("\n");

}

 

int main(int argc, char *argv[]) {

       copy_and_print_string(argv[1]);

 

       exit(EXIT_SUCCESS);

}

La fonction copy_and_print_string présente deux vulnérabilités classiques, à savoir un dépassement de tampon sur pile et un passage d'une chaîne de format à la fonction printf.

Ce programme est alors compilé avec la ligne de commandes suivante pour désactiver les options de sécurité activées par défaut :

$ gcc -O0 -g -m32 -fno-stack-protector -U_FORTIFY_SOURCE -o sample sample.c

Pour simplifier la compréhension du code machine généré, le paramètre -m32 est passé à gcc pour forcer la production de code 32-bits. Le binaire obtenu peut alors être désassemblé avec la commande objdump :

$ objdump --no-show-raw-insn -M intel --disassemble --source sample

Le code assembleur de la fonction copy_and_print_string est présenté ci-après :

void copy_and_print_string(const char *str) {

80484ad: push ebp                                ; prologue de fonction

80484ae: mov ebp,esp                             ;

80484b0: sub esp,0x38                            ; réservation d'espace sur la pile

       char buf[32];

       strcpy(buf, s);

80484b3: mov eax,DWORD PTR [ebp+0x8]             ; adresse de str dans le registre eax

80484b6: mov DWORD PTR [esp+0x4],eax             ; adresse de str sur la pile

80484ba: lea eax,[ebp-0x28]                      ; eax contient l'adresse de buf

80484bd: mov DWORD PTR [esp],eax                 ; adresse de buf sur la pile

80484c0: call 8048360 <strcpy@plt>                ; appel à strcpy(buf, str)

       printf(buf);

80484c5: lea eax,[ebp-0x28]                      ; adresse de buf dans eax

80484c8: mov DWORD PTR [esp],eax                 ; adresse de buf sur la pile

80484cb: call 8048350 <printf@plt>                ; appel à printf(buf)

       printf("\n");

80484d0: mov DWORD PTR [esp],0xa                 ; 0xa sur la pile

80484d7: call 80483a0 <putchar@plt>              ; appel à putchar(0xa)

}

80484dc: leave

80484dd: ret

On retrouve bien dans le code assembleur généré le premier appel à la fonction strcpy pour copier le premier argument du programme dans le tampon buf puis l'appel à printf pour afficher le contenu du tampon.

Au passage, il est possible de remarquer que gcc a optimisé l'appel à printf("\n") en putchar(0xa).

1.1. Protection de la pile (-fstack-protector)

gcc propose l'option -fstack-protector pour détecter et bloquer, au moment de l'exécution, l'exploitation de vulnérabilités de type débordements de tampon sur la pile.

Définition

Un débordement de tampon sur la pile (« stack based buffer overflow » en anglais) est une vulnérabilité classique des programmes informatiques. Elle consiste à réécrire en mémoire les informations correspondant à la pile d'appels du programme, en copiant une quantité importante de données vers un tampon de taille fixe alloué sur la pile. En effet, au moment de l'appel d'une fonction, un programme sauvegarde sur la pile l'adresse de retour, c'est-à-dire l'adresse de la prochaine instruction qui sera exécutée par la fonction appelante. Si un attaquant est capable de réécrire la valeur de cette adresse de retour en provoquant un débordement de données du tampon alloué sur la pile, alors il pourra rediriger le flot d'exécution du programme vers une adresse de son choix (par exemple l'adresse d'un shellcode qu'il aura précédemment copié en mémoire).

L'article « Smashing the stack for fun and profit » d'Aleph One [1] constitue le document de référence sur l'exploitation de ce type de vulnérabilités.

Le programme sample.c vu précédemment présente une vulnérabilité de ce type trivialement exploitable : pour cela, il suffit de spécifier un argument de plus de 31 caractères. L'appel à strcpy de la fonction copy_and_print_string va alors écraser des données sauvegardées sur la pile en débordant du tampon buf. Pour déclencher la vulnérabilité, le programme est exécuté en lui passant une chaîne de 64 caractères comme argument.

$ ./sample `python -c 'print "A"*64'`

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

[2] 8437 segmentation fault (core dumped) ./sample `python -c 'print "A"*64'`

Pour connaître l'état des registres lors du plantage, le programme peut alors être exécuté via gdb :

$ gdb -q sample

Reading symbols from sample...done.

(gdb) run `python -c 'print "A"*64'`

Starting program: sample `python -c 'print "A"*64'`

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

 

Program received signal SIGSEGV, Segmentation fault.

0x41414141 in ?? ()

(gdb) i r

eax 0xa 0xa

ecx 0xf7faa898 0xf7faa898

edx 0xa 0xa

ebx 0xf7fa9000 0xf7fa9000

esp 0xffffce20 0xffffce20

ebp 0x41414141 0x41414141

esi 0x0 0x0

edi 0x0 0x0

eip 0x41414141 0x41414141

[...]

Lors de la sortie de la fonction, les registres ebp et eip ont été restaurés respectivement par les instructions leave et ret. En particulier, le flot d'exécution du programme est maintenant redirigé vers l'adresse 0x41414141 qui correspond à quatre caractères 'A' en ASCII. On peut remarquer que le registre ebp vaut également 0x41414141. La figure 1 illustre l'état de la pile à l'entrée de la fonction et montre bien que les données du tampon buf sont contiguës avec les valeurs sauvegardées des registres ebp et eip.

Fig. 1 : État de la pile à l'entrée de la fonction.

Le programme sample.c est alors recompilé en activant cette fois l'option -fstack-protector :

$ gcc -O0 -g -m32 -fstack-protector -U_FORTIFY_SOURCE -o sample sample.c

Une tentative d'exploitation de la vulnérabilité déclenche alors l'arrêt brutal du programme, comme présenté ci-après :

$ ./sample `python -c 'print "A"*64'`

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

*** stack smashing detected ***: ./sample terminated

[2] 8645 abort (core dumped) ./sample `python -c 'print "A"*64'`

Pour comprendre les modifications apportées par cette option, le code assembleur de la fonction copy_and_print_string peut alors être analysé :

void copy_and_print_string(const char *s) {

80484fd: push ebp

80484fe: mov ebp,esp

8048500: sub esp,0x48

8048503: mov eax,DWORD PTR [ebp+0x8] ; adresse de str dans eax

8048506: mov DWORD PTR [ebp-0x3c],eax ; sauvegarde de l'adresse str sur la pile
                                    ; *après* les données de buf

8048509: mov eax,gs:0x14              ; récupération de la valeur du canari dans eax

804850f: mov DWORD PTR [ebp-0xc],eax   ; sauvegarde de la valeur du canari *avant*
                                  ; les données de buf

8048512: xor eax,eax

       char buf[32];

 

       strcpy(buf, s);

8048514: mov eax,DWORD PTR [ebp-0x3c]

8048517: mov DWORD PTR [esp+0x4],eax

804851b: lea eax,[ebp-0x2c]

804851e: mov DWORD PTR [esp],eax

8048521: call 80483b0 <strcpy@plt>

       printf(buf);

8048526: lea eax,[ebp-0x2c]

8048529: mov DWORD PTR [esp],eax

804852c: call 8048390 <printf@plt>

      printf("\n");

8048531: mov DWORD PTR [esp],0xa

8048538: call 80483f0 <putchar@plt>

}

804853d: mov eax,DWORD PTR [ebp-0xc]         ; récupération dans eax de la valeur du
 ; canari sauvegardé précédemment

8048540: xor eax,DWORD PTR gs:0x14           ; xor avec la valeur initiale

8048547: je 804854e <copy_and_print_string+0x51>  ; sort de la fonction si le résultat du
                                             ; xor vaut 0

8048549: call 80483a0 <__stack_chk_fail@plt>      ; affiche une erreur et termine le
                                             ; programme sinon

804854e: leave

804854f: ret

Les principales modifications effectuées par le compilateur sont :

- l'insertion sur la pile d'un« canari » avant les données du tampon buf et la vérification de la valeur de ce canari à la sortie de la fonction ;

- la copie de l'adresse de l'argument str après les données du tampon buf.

Ces modifications sont représentées sur la figure 2.

Fig. 2 : État de la pile à l'entrée de la fonction avec fstack-protector.

La valeur du canari est lue depuis l'adresse mémoire gs:0x14 puis insérée sur la pile avant les données de buf. Afin que cette valeur ne puisse pas être prédictible, elle est initialisée par le noyau lors du lancement d'un programme et restera constante tout au long de l'exécution de celui-ci.

La recopie des arguments de la fonction après les données de buf permet de protéger ceux-ci en cas de dépassement de tampon. En effet, la fonction fait maintenant référence aux arguments en utilisant les adresses recopiées et non pas les adresses déposées sur la pile par la fonction appelante. De la même façon, si des variables locales avaient été déclarées au début de la fonction, elles auraient également été recopiées après les données de buf pour se prémunir des risques de corruption en cas de dépassement de tampon.

Par défaut, si l'option -fstack-protector est active, les fonctions protégées sont celles qui appellent la fonction alloca pour allouer une zone mémoire de taille fixe sur la pile ou celles qui comportent un tampon de plus de 8 octets. Cette valeur peut être redéfinie à l'aide de l'option --param=ssp-buffer-size de gcc (la plupart des distributions Linux redéfinissent cette valeur à 4).

Pour protéger toutes les fonctions, il est possible de spécifier l'option --fstack-protector-all, maiscela peut avoir un impact significatif sur les performances du programme, la taille du code généré et l'utilisation de la mémoire au niveau de la pile. Depuis la version 4.9, gcc intègre une nouvelle option --fstack-protector-strong proposée par Google [2] qui offre le meilleur compromis entre le niveau de protection des fonctions et l'impact sur les performances.

Définition

Pour détecter rapidement les poches de gaz dans les mines de charbon (le grisou), on utilisait autrefois un canari : celui-ci était plus rapidement affecté par ce gaz inodore que les mineurs et permettait donc de le détecter.

En informatique le principe consiste à stocker une valeur avant l'adresse de retour et à vérifier à la fin de la fonction que celle-ci n'a pas été corrompue (dans ce cas l'exécution du code est interrompue).

1.2. Exécutable à position indépendante

En règle générale, un programme est toujours chargé à la même adresse mémoire avant son exécution. En effet, certaines instructions du programme font référence à des adresses absolues, par exemple en cas de branchement ou pour accéder à une variable définie globalement. Dans ce cas, la section .text qui contient le code exécutable du programme ne peut pas bénéficier des protections de type ASLR (Address Space Layout Randomization). Un attaquant pourra donc concevoir un code d'exploitation en utilisant des adresses fixes présentes dans la section .text. Cela peut lui permettre de mettre en place une attaque de type « return-to-text » ou de construire une chaîne ROP (Return-Oriented Programming) en référençant des gadgets présents dans la section .text.

Un exécutable à position indépendante (« position-independent executable » ou PIE en anglais) est un programme qui s'exécutera correctement, quelle que soit son adresse de chargement en mémoire. Ainsi, si le noyau supporte les protections de type ASLR, un exécutable PIE sera chargé à une adresse mémoire différente à chaque nouvelle exécution.

Pour illustrer ce comportement, le programme sample2.c présenté ci-après va afficher l'adresse de chargement de la section .text lors de son exécution :

/* sample2.c */

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <libgen.h>

 

char buf[1024];

 

void show_text_mapping(const char *prog) {

FILE *f = fopen("/proc/self/maps", "r");

while (fgets(buf, 1024, f)) {

if (strstr(buf, "r-x") && strstr(buf, prog)) {

printf("%s", buf);

}

}

fclose(f);

}

 

int main(int argc, char *argv[]) {

show_text_mapping(basename(argv[0]));

exit(EXIT_SUCCESS);

}

Sa compilation et son exécution retournent le résultat suivant :

$ gcc -m32 -o sample2 sample2.c

$ ./sample2 

08048000-08049000 r-xp 00000000 fc:01 4206147 sample2

$ ./sample2

08048000-08049000 r-xp 00000000 fc:01 4206147 sample2

Le code exécutable du programme est toujours chargé à la même adresse, à savoir l'adresse 0x804800.

La recompilation du programme avec les options -pie -fPIE permet d'obtenir un exécutable PIE :

$ gcc -m32 -pie -fPIE -o sample2-pie sample2.c
$ ./sample2-pie
f775a000-f775b000 r-xp 00000000 fc:01 4206147      sample2-pie
$ ./sample2-pie
f777d000-f777e000 r-xp 00000000 fc:01 4206147      sample2-pie

Cette fois-ci, la section .text est bien chargée à une adresse aléatoire à chaque nouvelle exécution.

Pour produire un exécutable PIE, le compilateur doit remplacer les instructions référençant une adresse absolue par plusieurs instructions équivalentes, mais se basant cette fois-ci sur un adressage relatif au pointeur d'instruction courant (EIP).

Note

Le code généré pour les bibliothèques partagées utilise des techniques similaires pour garantir une exécution correcte indépendamment de l'adresse de base. Ainsi, une bibliothèque partagée ne sera chargée en mémoire qu'une seule fois, quels que soient les espaces d'adressage virtuels des processus utilisant cette bibliothèque.

La comparaison du code assembleur des binaires sample2 et sample2-pie permet de se rendre compte des modifications apportées par le compilateur.

Par exemple, l'appel à fopen du binaire sample2 se traduit par les instructions suivantes :

void show_text_mapping(const char *prog) {

804855d: push ebp

804855e: mov ebp,esp

8048560: sub esp,0x28

FILE *f = fopen("/proc/self/maps", "r");

8048563: mov DWORD PTR [esp+0x4],0x80486b0

804856b: mov DWORD PTR [esp],0x80486b2

8048572: call 8048440 <fopen@plt>

Les deux chaînes de caractères qui constituent les arguments de la fonction fopen sont bien référencées par des adresses absolues.

La version PIE de la même fonction correspond aux instructions ci-dessous :

void show_text_mapping(const char *prog) {

75b: push ebp

75c: mov ebp,esp

75e: push ebx

75f: sub esp,0x24

762: call 630 <__86.get_pc_thunk.bx>

767: add ebx,0x1899

FILE *f = fopen("/proc/self/maps", "r");

76d: lea eax,[ebx-0x1730]

773: mov DWORD PTR [esp+0x4],eax

777: lea eax,[ebx-0x172e]

77d: mov DWORD PTR [esp],eax

780: call 5d0 <fopen@plt>

Le code généré par le compilateur est cette fois plus complexe pour permettre un adressage relatif au pointeur d'instruction courant. On peut voir apparaître l'appel à une nouvelle fonction, __86.get_pc_thunk.bx. Celle-ci est constituée des deux instructions suivantes :

00000630 <__x86.get_pc_thunk.bx>:

630: mov ebx,DWORD PTR [esp]

633: ret

L'exécution de ces deux instructions permet de stocker dans le registre ebx l'adresse de retour sauvegardée sur la pile lors de l'appel __86.get_pc_thunk.bx. Un décalage fixe de 0x1899 est ensuite ajouté à ebx pour obtenir l'adresse de la GOT (Global Offset Table qui agit comme une table d'indirection contenant les adresses absolues des symboliques dynamiques auxquels le programme accède). Les deux arguments de l'appel à fopen, qui sont stockés dans la section .rodata, sont ensuite obtenus en utilisant un décalage négatif (0x1730 et 0x172e) par rapport à la valeur stockée dans ebx. En effet, le décalage entre les sections .rodata et la GOT est fixe et connu par le compilateur lors de la génération du code machine. Il est donc possible de faire référence à des adresses de la section .rodata (ou .data) à partir de l'adresse de la GOT.

Sur une architecture matérielle possédant peu de registres, telle que IA32, la production d'un programme PIE entraîne une augmentation de la taille du code et une baisse significative des performances (de l'ordre de 5 à 10%). Dans ce cas, seuls les programmes les plus sensibles sont compilés pour être PIE. L'impact est moindre sur AMD64 en raison du nombre plus important de registres.

1.3. FORTIFY_SOURCE

L'utilisation de l'option FORTIFY_SOURCE de gcc remplit plusieurs objectifs :

- de façon statique (lors de la compilation), l'identification d'erreurs de programmation habituelles sur des fonctions réputées dangereuses (strcpy, memcpy, etc.) ;

- de façon dynamique (lors de l'exécution), la vérification des arguments de certaines de ces fonctions.

Pour illustrer les modifications apportées au code généré, le programme sample.c présenté au début de l'article a été recompilé avec l'option FORTIFY_SOURCE :

$ gcc -O1 -g -m32 -fno-stack-protector-D_FORTIFY_SOURCE=2 -o sample sample.c

Pour que cette option soit effective, le programme doit être compilé avec un niveau d'optimisation supérieur ou égal à 1. La directive de compilation FORTIFY_SOURCE peut prendre trois valeurs : 0 pour désactiver la protection, 1 pour un niveau de protection basique et 2 pour le niveau de protection le plus complet.

Le désassemblage du binaire obtenu avec objdump retourne les instructions ci-dessous :

080484cd <copy_and_print_string>:

80484cd: push ebp

80484ce: mov ebp,esp

80484d0: push ebx

80484d1: sub esp,0x34

80484d4: mov DWORD PTR [esp+0x8],0x20

80484dc: mov eax,DWORD PTR [ebp+0x8]

80484df: mov DWORD PTR [esp+0x4],eax

80484e3: lea ebx,[ebp-0x28]

80484e6: mov DWORD PTR [esp],ebx

80484e9: call 80483b0 <__strcpy_chk@plt>

80484ee: mov DWORD PTR [esp+0x4],ebx

80484f2: mov DWORD PTR [esp],0x1

80484f9: call 80483c0 <__printf_chk@plt>

80484fe: mov DWORD PTR [esp],0xa

8048505: call 80483a0 <putchar@plt>

804850a: add esp,0x34

804850d: pop ebx

804850e: pop ebp

804850f: ret

Les principales différences observées avec le code assembleur du binaire initial (sans FORTIFY_SOURCE) sont :

- l'appel à strcpy est remplacé par un appel à __strcpy_chk qui prend un argument supplémentaire (ici 0x20) ;

- l'appel à printf est remplacé par un appel à __printf_chk qui prend également un argument supplémentaire (ici 1).

Par rapport à la fonction strcpy initiale, la fonction __strcpy_chk accepte comme argument supplémentaire la taille du tampon de destination qui est connue par gcc lors de la compilation. Ainsi, le programme est capable de déterminer au moment de l'exécution si un dépassement de tampon va avoir lieu, en comparant la taille du tampon de destination avec la longueur de la chaîne à copier (obtenue avec strlen).

On peut s'intéresser au comportement du programme lorsque celui-ci est exécuté avec un argument de plus de 31 caractères :

$ ./sample `python -c 'print "A"*31'`

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

$ ./sample `python -c 'print "A"*32'`

*** buffer overflow detected ***: ./sample terminated

======= Backtrace: =========

/lib/i386-linux-gnu/libc.so.6(+0x696de)[0xf76226de]

/lib/i386-linux-gnu/libc.so.6(__fortify_fail+0x6b)[0xf76b513b]

/lib/i386-linux-gnu/libc.so.6(+0xfafca)[0xf76b3fca]

/lib/i386-linux-gnu/libc.so.6(__strcpy_chk+0x37)[0xf76b34a7]

./sample[0x80484ee]

./sample[0x8048527]

/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xf3)[0xf75d2a83]

./sample[0x80483f1]

======= Memory map: ========

08048000-08049000 r-xp 00000000 08:03 2618                             sample

08049000-0804a000 r--p 00000000 08:03 2618                             sample

[…]

Dans le dernier cas, la fonction __strcpy_chk détecte bien un dépassement de tampon et déclenche l'arrêt brutal du programme.

La fonction __printf_chk effectue des vérifications sur la chaîne de caractères passée en argument déterminé par la valeur du nouveau paramètre. Par exemple, cette fonction va s'assurer qu'une vulnérabilité de type « format string » ne peut pas être exploitée.

Définition

Une vulnérabilité de type « format string » est présente dans un programme lorsque des données externes sont directement utilisées comme chaîne de format par une fonction de formatage telle que printf. Dans ce cas, un attaquant peut obtenir des informations sur les données présentes dans la pile avec la directive de formatage %p ou bien même écrire une valeur en mémoire avec la directive %n. Le document « Exploiting Format String Vulnerabilities » [3] décrit en détail les techniques d'exploitation de cette classe de vulnérabilités.

Bien évidemment, notre programme sample.c comporte une telle vulnérabilité, trivialement exploitable. En effet, la chaîne de caractères passée en argument du paramètre est utilisée telle quelle par la fonction printf. Il est donc possible d'inclure des directives de formatage, comme présenté ci-dessous :

$ ./sample hello.%p.%p

hello.0x20.0xf75791e3

$ ./sample hello.%n

*** %n in writable segment detected ***

hello.[2] 30613 abort (core dumped) ./sample hello.%n

L'insertion d'une directive %n dans la chaîne de caractères passée à printf déclenche l'arrêt du programme.

1.4. Protection de la GOT / PLT

1.4.1. Fonctionnement du lazy binding

En cas d'accès à un objet (variable ou fonction) exporté par une bibliothèque partagée, le chargeur dynamique (ld.so) se charge de résoudre l'adresse de l'objet, en tenant compte de l'adresse de base de chargement de la bibliothèque dans l'espace d'adressage virtuel du processus. Cette résolution peut être effectuée lors du chargement du programme ou pendant son exécution. En particulier, pour un appel de fonction d'une bibliothèque, la résolution de l'adresse de celle-ci s'effectue lors du premier appel selon un mécanisme appelé lazy binding. L'objectif de ce mécanisme est de réduire le temps de chargement d'un programme avec une résolution « à la demande » se limitant aux fonctions effectivement appelées.

Le résultat de ces résolutions par le chargeur dynamique est stocké dans une zone mémoire nommée « Global Offset Table » ou plus simplement GOT, que nous avons vu précédemment et qui, pour rappel, agit comme une table d'indirection contenant les adresses absolues des symboliques dynamiques auxquels le programme accède.

Le fonctionnement du mécanisme lazy binding évoqué précédemment nécessite la mise en place de deux zones mémoires :

- une section de données .got.plt, accessible en écriture, qui contient les adresses des fonctions une fois résolues ;

- une section de code exécutable .plt contenant un stub pour chacune des fonctions appelées.

Dans le cas d'un premier appel d'une fonction, ce stub va rendre la main au chargeur dynamique pour résoudre l'adresse de la fonction, mettre à jour l'entrée de la GOT correspondante puis déclencher son appel. Pour les appels ultérieurs, le stub n'a plus qu'à récupérer l'adresse de la fonction depuis la GOT pour pouvoir l'appeler.

1.4.2. Exploitation d'une vulnérabilité par réécriture dans la GOT

Si une vulnérabilité permet à un attaquant d'avoir accès à une primitive d'écriture arbitraire en mémoire, il lui est alors relativement aisé de la transformer en une exécution arbitraire de code : pour cela, il lui suffit d'injecter un shellcode en mémoire et de réécrire une entrée de la GOT avec l'adresse de celui-ci. Lorsque le programme effectuera l'appel de la fonction correspondante, son exécution sera redirigée vers le shellcode.

1.4.3. Passage de la GOT en lecture seule

L'éditeur de liens (ld) accepte deux options qui permettent de changer le comportement du chargeur dynamique afin de bloquer la technique d'exploitation décrite précédemment :

1. l'option relro pour créer un segment de programme « PT_GNU_RELRO » qui contiendra, en lecture seule, le résultat des résolutions dynamiques effectuées au démarrage du programme ;

2. l'option now pour forcer le chargeur dynamique à résoudre les adresses de toutes les fonctions importées lors du démarrage du programme, au détriment du temps de lancement du programme.

L'activation de la seule option relro fournit un niveau de protection assez faible. En effet, certains symboles peuvent être résolus par le chargeur dynamique au moment du lancement de l'exécutable, mais ce n'est pas le cas pour les fonctions exportées par les bibliothèques partagées à cause du lazy binding. La protection en lecture seule de l'intégralité de la GOT n'est effective qu'avec l'activation de ces deux options.

Ces options peuvent être passées à l'éditeur de liens via gcc avec la ligne de commandes suivante :

$ gcc -m32 -Wl,-z,relro,-z,now -o sample sample.c

Avec gdb, il est alors possible de s'assurer que l'adresse de la fonction strcpy est bien résolue avant que celle-ci ne soit appelée :

$ gdb -q sample

Reading symbols from sample...done.

(gdb) break copy_and_print_string

Breakpoint 1 at 0x80484b3: file sample.c, line 8.

(gdb) run foobar

Starting program: sample foobar

 

Breakpoint 1, copy_and_print_string (s=0xffffd2c9 "foobar") at sample.c:8

8 strcpy(buf, s);

(gdb) disassemble copy_and_print_string

Dump of assembler code for function copy_and_print_string:

[...]

=> 0x080484b3 <+6>: mov eax,DWORD PTR [ebp+0x8]

0x080484b6 <+9>: mov DWORD PTR [esp+0x4],eax

0x080484ba <+13>: lea eax,[ebp-0x28]

0x080484bd <+16>: mov DWORD PTR [esp],eax

0x080484c0 <+19>: call 0x8048360 <strcpy@plt>

[...]

End of assembler dump.

(gdb) x/3i 0x8048360

0x8048360 <strcpy@plt>: jmp DWORD PTR ds:0x8049fe8

0x8048366 <strcpy@plt+6>: push 0x8

0x804836b <strcpy@plt+11>: jmp 0x8048340

(gdb) x/x 0x8049fe8

0x8049fe8 <strcpy@got.plt>: 0xf7e89160

(gdb) x/i 0xf7e89160

0xf7e89160 <__strcpy_sse2>: mov edx,DWORD PTR [esp+0x4]

L'entrée de la GOT pour la fonction strcpy en 0x8049fe8 pointe bien directement dans le code de la GLIBC à l'adresse 0xf7e89160.

Pour finir, on peut s'assurer que la GOT n'est pas accessible en écriture avec la commande ci-dessous :

$ cat /proc/`pidof sample`/maps

08048000-08049000 r-xp 00000000 08:03 2618 sample

08049000-0804a000 r--p 00000000 08:03 2618 sample

0804a000-0804b000 rw-p 00001000 08:03 2618 sample

L'adresse 0x8049fe8 appartient à la zone 08049000-0804a000 qui n'est accessible qu'en lecture seule.

2. Avertissements à la compilation

gcc permet également d'identifier certains problèmes de sécurité lors de la compilation, en fonction des paramètres qui lui sont passés.

Le programme sample-warning.c ci-dessous contient deux vulnérabilités : un débordement de tampon et une vulnérabilité de type chaîne de format.

/* sample-warning.c */

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

 

int main(int argc, char **argv) {

char buf[5];

strcpy(buf, "hello world!");

printf(buf);

exit(EXIT_SUCCESS);

}

Comme évoqué précédemment, FORTIFY_SOURCE peut détecter au moment de la compilation certaines vulnérabilités de type dépassement de tampon, comme présenté ci-dessous :

$ gcc -m32 -O -D_FORTIFY_SOURCE=2 -o sample-warning sample-warning.c

In file included from /usr/include/string.h:640:0,

from sample-warning.c:3:

In function ‘strcpy’,

inlined from ‘main’ at sample-warning.c:7:8:

/usr/include/bits/string3.h:104:3: warning:call to __builtin___strcpy_chk will always overflowdestinationbuffer[enabled by default]

return __builtin___strcpy_chk (__dest, __src, __bos (__dest));

^

gcc émet un avertissement à propos du dépassement de tampon, en précisant que ce dernier sera systématiquement déclenché : en effet, la taille du tampon destination est inférieure à la longueur de la chaîne de caractères passée en argument.

gcc propose également deux options, -Wformat et -Wformat-security, pour avertir de l'usage incorrect des fonctions de formatage. L'utilisation de ces options sur le programme sample-warning.c permet d'identifier la vulnérabilité de type chaîne de format :

$ gcc -m32 -Wformat -Wformat-security -o sample-warning sample-warning.c

sample-warning.c: In function ‘main’:

sample-warning.c:8:2: warning: format not a string literal and no format arguments[-Wformat-security]

printf(buf);

^

L'option -Wformat peut prendre une valeur indiquant le niveau des vérifications à effectuer. Il est recommandé d'utiliser le niveau -Wformat=2 qui active automatiquement -Wformat-security.

En complément de ces options, gcc peut également émettre des avertissements sur des problèmes liés à la qualité du code et qui peuvent avoir des conséquences sur le niveau de sécurité d'un programme. L'option -Wall active des vérifications intéressantes, parmi lesquelles :

- -Warray-bounds : émet un avertissement en cas d'accès à un tableau en dehors de ses limites ;

- -Wchar-subscripts : émet un avertissement si un index d'un tableau est de type char ;

- -Wuninitialized : émet un avertissement si une variable non initialisée est utilisée.

Pour aller plus loin, les options -Wextra et -Wpedantic peuvent être utilisées pour activer des vérifications supplémentaires, comme la comparaison entre une valeur signée et une autre non signée ou bien la conformité stricte à un standard de programmation (C ISO par défaut, mais peut être redéfini avec l'option -std) .

Enfin, une dernière option intéressante est -Wunreachable-code qui permet de détecter des parties du code qui ne seront jamais exécutées. L'activation de cette option aurait pu permettre d'identifier la vulnérabilité dite « goto fail » [4], à condition de s'intéresser aux avertissements émis par le compilateur et de les interpréter correctement.

3. Prise en compte dans les distributions Linux

Aujourd'hui, la plupart des distributions Linux restreignent l'utilisation des options présentées dans cet article pour les programmes sensibles ou particulièrement exposés (services réseau, interpréteurs, etc.) en raison de l'impact potentiel sur les performances. Sur les architectures 64-bits, pour lesquelles l'impact est bien moindre, les distributions ont pour objectif de recompiler tous les paquets avec ces options de renforcement, sauf en cas d'incompatibilité majeure.

3.1. Debian

Avant la sortie de sa dernière version stable Wheezy, Debian avait pour projet [5] de recompiler tous les programmes sensibles avec les options de renforcement proposées par gcc. En particulier, les programmes concernés sont :

- tous les programmes ayant fait l'objet d'un bulletin de sécurité Debian (DSA) depuis 5 ans ;

- les programmes ayant une priorité supérieure ou égale à « important » ;

- les services et bibliothèques accessibles depuis le réseau ;

- tous les interpréteurs développés en C.

3.2. Ubuntu

Le Wiki d'Ubuntu [6] détaille les options de gcc activées par défaut lors de la compilation des paquets de la distribution. Parmi celles-ci, on retrouve -fstack-protector, -D_FORTIFY_SOURCE et -Wl,-z,relro. En complément, certains programmes particulièrement sensibles [7] sont compilés pour être PIE.

3.3. Red Hat et Fedora

De façon analogue à Ubuntu, le projet Fedora maintient une page de son Wiki [8] présentant l'intégration des fonctionnalités de sécurité en fonction de la version de la distribution. Seuls certains programmes jugés critiques [9] sont compilés pour être PIE. Parmi ces programmes figurent OpenSSH, Postfix, Sendmail et d'autres services réseau exposés.

Conclusion

En résumé, les options de gcc recommandées pour renforcer le niveau de sécurité d'un programme sont :

$ gcc -O2 -Wunreachable-code -pedantic -Wextra -Wall -Wformat=2 -D_FORTIFY_SOURCE=2 \
 -fstack-protector --param ssp-buffer-size=4 -fPIE -pie -Wl,-z,relro,-z,now -o sample sample.c

Sur les versions récentes de gcc (à partir de 4.9), les paramètres -fstack-protector --param ssp-buffer-size=4 peuvent avantageusement être remplacés par -fstack-protector-strong. L'outil hardening-check présent sur les distributions Ubuntu et Debian permet de s'assurer de la bonne prise en compte de ces options :

$ hardening-check sample

sample:

Position Independent Executable: yes

Stack protected: yes

Fortify Source functions: yes

Read-only relocations: yes

Immediate binding: yes

Aujourd'hui, sur une architecture matérielle récente, l'activation de ces options n'impacte que de façon marginale les performances d'un programme. Il serait donc dommage de s'en priver, tout en gardant à l'esprit que ces renforcements ne visent qu'à bloquer des techniques génériques d'exploitation de vulnérabilités. Dans des circonstances particulières, certaines peuvent être contournées par un attaquant suffisamment motivé. Il convient donc d'intégrer ces mesures dans une logique de défense en profondeur pour limiter les risques en cas d'exploitation réussie d'une vulnérabilité.

Références

[1] Aleph One, « Smashing the stack for fun and profit », Phrack 49 : http://insecure.org/stf/smashstack.html

[2] Kess Cook, « -fstack-protector-strong » : http://www.outflux.net/blog/archives/2014/01/27/fstack-protector-strong/

[3] scut / team teso, « Exploiting Format String Vulnerabilities » : https://crypto.stanford.edu/cs155/papers/formatstring-1.2.pdf

[4] A. Langley, « Apple SSL/YLS bug » : https://www.imperialviolet.org/2014/02/22/applebug.html

[5] Debian, « Hardening » : https://wiki.debian.org/Hardening

[6] Ubuntu, « CompilerFlags » : https://wiki.ubuntu.com/ToolChain/CompilerFlags

[7] Ubuntu, « BuiltPIE » : https://wiki.ubuntu.com/SecurityTeam/KnowledgeBase/BuiltPIE

[8] Fedora, « Security Features Matrix » : https://fedoraproject.org/wiki/Security_Features_Matrix

[9] Fedora, « Hardened Packages » : https://fedoraproject.org/wiki/Hardened_Packages

Tags : C, gcc, Sécurité