La sécurité applicative sous Linux

MISC n° 062 | juillet 2012 | Sébastien Dudek
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 !
Les failles applicatives ont ouvert une porte aux vers, virus, rootkits et autres menaces. Mais des mécanismes permettant de complexifier une exploitation (ASLR, PIE, NX, Canari, RELRO, FORTIFY_SOURCE) ont permis au système Linux de se défendre contre ce type de menaces.Cet article fait état des différentes protections par défaut, que nous découvrirons avec les moyens possibles de les contourner.

1. Notre héritage

Depuis les années 1970, la communauté académique s'est intéressée à étudier les erreurs, vulnérabilités et défauts présents sur les systèmes informatiques. La documentation sur la faille de débordement de pile (« buffer overflow ») avait été rendue publique, du moins partiellement. En novembre 1988, un ver du nom de Morris avait infecté 10% des systèmes reliés à Internet. Ce ver s’était propagé en exploitant entre autres un « buffer overflow » sur le service « fingerd » sous Unix.

Une mailing-list indépendante sous le nom de « bugtrack » s’était ensuite développée en 1993, afin de prévenir des failles sur des logiciels commerciaux. Avec les nombreuses documentations publiées depuis, « Smashing the stack for fun and profit » par Elias Levy (Aleph One [1]), dans le magazine Phrack #49, est l'article qui a marqué toute une génération. Cette introduction pas-à-pas avait permis à de nombreux curieux de comprendre le fonctionnement de la pile d'un programme, comment découvrir une vulnérabilité, jusqu'à sa mise en œuvre avec l'écriture d'un « shellcode ».

Comme nous pouvons le constater, la faille par débordement de pile est aussi vieille que le C, créé dans les années 1970 par Denis Ritchie à qui nous rendons hommage au passage. Ce langage aura rendu aussi possible des vulnérabilités comme celle du « Format String Bug », ou encore le débordement de tampon dans l'implémentation de « malloc » par Doug Lea [2].

La publication de ces failles de sécurité a contribué à de nouveaux mécanismes de protection. De ce fait, il n'est plus possible de compromettre aussi facilement un système comme avant, en rejouant l'attaque d'Elias Levy et d'autres sur le débordement de pile, par exemple.

2. Contexte

Dès à présent, nous allons nous intéresser à l'exploitation d'un programme vulnérable sur un système Linux courant. Pour cela, nous travaillerons sur un système Ubuntu 11.10 (2.6.38-14-generic) en 64 bits, qui est une des distributions orientées bureau la plus utilisée et donc très représentative pour nos démonstrations.

Prenons une vulnérabilité simple à analyser comme suit :

void vuln(char *string)

{

        char buffer[512];

        strcpy(buffer, string);

        printf("%s\n", buffer);

}

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

{

        if (argc == 2)

vuln(argv[1]);

}

Dans cet exemple en C, nous avons un tableau nommé « buffer » de 512 éléments. Pour ceux qui ont des notions sur l'exploitation de pile, il est évident que la fonction vulnérable est strcpy et qu'aucun contrôle n'est effectué sur la chaîne à copier.

Après compilation, nous allons analyser ce programme en nous mettant à la place d'un attaquant.

2.1 Analyse du programme

Sachant qu'aucun mécanisme n'a été prévu pour vérifier la chaîne qui sera passée dans buffer, nous allons alors copier plus de données que l'espace alloué à cette variable ne le permet (sub $0x220,%rsp) :

fluxius@handgrep:~/misc$ ./toto $(python -c "print 'A'*700")

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[....]

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

Erreur de segmentation

Notre programme s'est interrompu comme convenu avec une erreur de segmentation. Mais une autre erreur est aussi apparue : stack smashing detected ***: ./toto terminated, nous la détaillerons plus tard dans cet article.

2.2 C'était mieux avant...

Arrivés au point où nous découvrons une vulnérabilité, nous nous empressons de l'exploiter. Ayant lu et relu les tutoriels d'exploitation de « buffer overflow », notre stratégie est toute tracée :

  1. identification de l'adresse de la pile ;
  2. injection de notre « shellcode » au tout début de la pile ;
  3. ajout de padding ;
  4. et enfin réécriture de l'adresse de retour pour exécuter notre « shellcode ».

Si tous ces points sont effectués correctement, le shellcode est exécuté. De plus, si le SET User ID est appliqué sur l'exécutable, nous aurons la chance d'exécuter notre shellcode avec les droits du propriétaire du fichier. Mais à votre grande déception, cette stratégie de base ne fonctionne pas telle quelle.

En effet, revenons maintenant en 2012 et faisons un petit tour sur la page wiki décrivant les caractéristiques de sécurité d'Ubuntu [3]. ASRL, NX, Stack et Heap Protector, RELRO, architecture 64-bits… Voilà la nouvelle réalité !

3. Exploitation

Comme nous l'avons vu précédemment, l'exploitation d'une vulnérabilité telle qu'un buffer overflow nécessite de bonnes bases dans le domaine, mais aussi une connaissance des protections courantes.

Dans cette partie, nous allons donc parler des différents mécanismes de sécurité, ainsi que les contournements utilisés pour nous donner une chance d'exécuter du code arbitraire.

3.1 Pile non exécutable

Pour prévenir l'exécution de code arbitraire dans la pile, des protections ont été implémentées. En effet, les développeurs ont réalisé que la pile et le tas d'un programme ne devraient pas être exécutables.

Pour ce faire, AMD et Intel ont introduit le bit NX [4] (No eXecute) et XD (eXecute Disable) respectivement. Ce bit se réfère au bit 63 (le plus significatif) comme suit :

Si ce bit est initialisé à « 1 », la page n'est pas exécutable ou l'inverse si ce même bit vaut « 0 ». Cependant, l'utilisation de ce bit requiert la présence de PAE (Physical Address Extension) ou du mode 64-bit. Sans cela, la page est considérée comme exécutable.

Pour vérifier que le bit NX et le PAE sont bien présents, affichons /proc/cpuinfo :

fluxius@handgrep:~$ cat /proc/cpuinfo

flags  : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm 3dnowext 3dnow constant_tsc rep_good nopl [...]

Avant que cette fonctionnalité ne soit introduite, les systèmes d'exploitation essayaient d'émuler cette fonctionnalité à travers différents patches kernel comme Exec-shield, W^X et PaX (Page-eXec) qui est très complet avec PAGEEXEC, SEGMEXEC, et dont nous parlerons plus en détail dans le second article.

Reprenons notre programme vulnérable et analysons le mode utilisé pour la pile à l'aide de readelf.

On voit que la pile est en mode lecture et écriture (RW), mais pas exécution (E) comme les segments de types PHDR et LOAD. Ce qui signifie que normalement, il ne serait pas possible d'exécuter du code, et nous allons vérifier cela.

Prenons un shellcode assez classique en état de fonctionner, comme suit [7] :

int main(void)

{

    char shellcode[] =

    "\x48\x31\xd2"                                  // xor    %rdx, %rdx

    "\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68"      // mov      $0x68732f6e69622f2f, %rbx

    "\x48\xc1\xeb\x08" // shr $0x8, %rbx

    "\x53"                                          // push   %rbx

    "\x48\x89\xe7"                                  // mov    %rsp, %rdi

    "\x50"                                          // push   %rax

    "\x57"                                          // push   %rdi

    "\x48\x89\xe6"                                  // mov    %rsp, %rsi

    "\xb0\x3b"                                      // mov    $0x3b, %al

    "\x0f\x05";                                     // syscall

    (*(void (*)()) shellcode)();

     

return 0;

}

Après compilation et exécution, nous obtenons le résultat attendu :

fluxius@handgrep:~/misc$ ./shellcode64

Erreur de segmentation

Par contre, si nous autorisons l'exécution à l'aide de execstack [25], notre shellcode s'exécute comme prévu :

fluxius@handgrep:~/misc$ execstack -s shellcode64

fluxiux@handgrep:~/misc$ ./shellcode64

# whoami

root

Ce qui signifie que nous devrons trouver une autre méthode pour notre programme vulnérable. Mais une première technique pour défier cette protection appelée le « return-to-libc » a été élaborée.

3.1.1 Return-to-libc

Cette technique consiste à utiliser les fonctions partagées de la bibliothèque libc.

Si nous voulons exécuter la fonction system("/bin/sh") avec la technique return-to-libc, nous devons remplir le buffer avec des données inutiles jusqu'à atteindre la sauvegarde de RIP (Re-Extended Instruction Pointer) (« AAAAAAAAAAAAAA... » par exemple). Ensuite, nous réécrirons RIP avec celle de system. Pour finir le programme proprement, nous ajouterons l'adresse de la fonction exit ainsi que l'adresse de la chaîne /bin/sh (récupérée avec la fonction memcmp ou une variable d'environnement).

À l'exception des octets NUL, cette technique marche très bien pour les systèmes 32 bits, mais pour les 64 bits les choses se compliquent largement. En effet, les spécifications System V Application Binary Interface[8] prévoient quelques changements en ce qui concerne, entre autres, la manière avec laquelle les arguments sont passés dans les fonctions. Nous verrons plus en détail cette spécification à la fin de cet article, parlons maintenant d'une autre technique que nous pourrions utiliser en x86-64.

3.1.2 La technique « The borrowed code chunks »

Inspirée du « return-to-libc », cette technique avait été introduite par Sebastian Krahmer dans son papier sur l'exploitation de système Linux x86-64 avec le bit NX [9]. Le return-to-libc fonctionnait bien sur des anciennes versions de Linux et permettait d'outrepasser la protection introduite par PaX dans certaines conditions.

Mais avec ELF64 ABI System V, comme vous avez pu vous rendre compte au début de l'article, les arguments sont attendus dans les registres. Et dans le cas où nous souhaitons passer un argument à la fonction system, il est obligatoire de le faire par le registre %rdi (chose que nous verrons en dernière partie). Ce qui rend le return-to-libc obsolète pour ces nouveaux systèmes.

Comme la plupart des arguments sont passés dans le registre, il nous faut donc trouver une méthode capable de contrôler le registre %rdi pour y passer l'argument et sauter ensuite à la fonction system.

Tout ce que nous contrôlons, c'est la valeur qui est dans la pile lorsque le dépassement intervient. Nous devons donc chercher un moyen de placer une valeur contenue dans la pile dans le registre %rdi. Cependant, aucune instruction pop %rdi ne ressort par chance... nous devons alors chercher d'autres moyens de passer notre valeur.

La technique du « borrowed code chuncks » a inspiré des attaques telles que le ROP (Return Oriented Programing) [10], JOP (Jump-Oriented Programming) [11] et bien d'autres...

3.2 Address Space Layout Randomization

Pour freiner les attaquants, un autre mécanisme connu sous le nom de ASLR (Address Space Layout Randomization) a été conçu. Cette protection a pour fonction de placer aléatoirement des zones comme la pile, tas, la section .text, vdso, des bibliothèques partagées et l'adresse de base de l'exécutable lorsqu'il est compilé avec le support PIE (Position Independent Executable).

Imaginons maintenant que notre exécutable permette l'exécution de code dans la pile et que nous puissions réécrire le %rip, quelle adresse de retour préciser ? On pourrait essayer de deviner l'adresse, mais à moins d'avoir beaucoup de chance, notre exécution s'achèvera sur une erreur de segmentation.

3.2.1 Linux gate's instructions

Sur de plus vieux noyaux Linux, les attaquants avaient trouvé une méthode se rapprochant des attaques que nous avons vues précédemment avec le NX. En effet, des instructions « linux gate » pouvaient être récupérées de manière statique. Il suffisait simplement de repérer les opcodes '\xff\xe4' (« jump esp » sur x86), puis de remplir le buffer jusqu'à réécrire l'adresse de retour, avec l'adresse où se trouve l'opcode. Et placer notre shellcode à la suite.

3.2.2 Brute force

La recherche exhaustive est une méthode parfois très utilisée et souvent en dernier recours, car elle prend beaucoup de temps. Ce que l'attaquant cherche avant tout, c'est un moyen de trouver une faille au niveau de l'entropie.

Observons en premier l'aléa que nous offre l'ASLR avec le code suivant :

main()
{
 char buffer[100];
 printf("Buffer address: %p\n", &buffer);

Résultat après 4 exécutions :

fluxius@handgrep:~/misc$ ./buffer_addr

Buffer address: 0x7fffcc0b7a80

fluxius@handgrep:~/misc$ ./buffer _addr

Buffer address: 0x7fffcc7c6c30

fluxius@handgrep:~/misc$ ./buffer_addr

Buffer address: 0x7fffcf7f1f40

fluxius@handgrep:~/misc$ ./buffer_addr

Buffer address: 0x7fff3d1f0b40

4 octets sont pseudo-aléatoires et il nous faudra beaucoup de temps et de la chance pour trouver l'adresse où notre shellcode se trouve.

Pour ce faire, une attaque bien connue consiste à utiliser la fonction execl, afin de copier l'image du processus courant en un nouveau processus.

Dans notre cas, l'utilisation de execl montre qu'on diminue bien l'entropie. Mais avec d'autres essais, nous pourrions constater que l'entropie peut être quelquefois plus importante.

Avec cette méthode, nous pouvons réécrire l'adresse de retour, ajouter un NOP sled assez large, réécrire l'adresse de retour, le shellcode et tenter de deviner le décalage correspondant pour se pointer dessus. Cependant, le degré d'aléa n'est pas le même et demandera beaucoup de temps. De plus, l'attaque est largement plus efficace sur des systèmes 32 bits et avec une version de noyau plus ancienne [12].

3.2.3 Fuite d'informations

En creusant un peu plus loin, nous pouvons découvrir des méthodes supplémentaires afin de réduire le domaine des recherches à l'aide des fichiers /proc/<self>/maps :

7f7c29b75000-7f7c29b76000 ---p 00000000 00:00 0

7f7c29b76000-7f7c2a376000 rw-p 00000000 00:00 0

7f7c2a376000-7f7c2a377000 ---p 00000000 00:00 0

7f7c2a377000-7f7c2ab77000 rw-p 00000000 00:00 0

7f7c2ab77000-7f7c2ab93000 r-xp 00000000 08:05 397038                     /lib/x86_64-linux-gnu/libselinux.so.1

7f7c2ab93000-7f7c2ad92000 ---p 0001c000 08:05 397038                     /lib/x86_64-linux-gnu/libselinux.so.1

7f7c2ad92000-7f7c2ad93000 r--p 0001b000 08:05 397038                     /lib/x86_64-linux-gnu/libselinux.so.1

7f7c2ad93000-7f7c2ad94000 rw-p 0001c000 08:05 397038                     /lib/x86_64-linux-gnu/libselinux.so.1

7f7c2ad94000-7f7c2ad95000 rw-p 00000000 00:00 0

7f7c2ad95000-7f7c2adac000 r-xp 00000000 08:05 397050                     /lib/x86_64-linux-gnu/libz.so.1.2.3.4

[...]

7f7c2d47a000-7f7c2d4dc000 rw-p 00000000 00:00 0                          [heap]

7fffa69fb000-7fffa6a1c000 rw-p 00000000 00:00 0                          [stack]

7fffa6bb4000-7fffa6bb5000 r-xp 00000000 00:00 0                          [vdso]

ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

Malheureusement, cette faille a été corrigée depuis la version 2.6.22 du noyau [13] et ces fichiers sont protégés si le ptrace d'un pid n'est pas autorisé. Mais d'autres fichiers peuvent tout de même être utilisés, comme /proc/self/stat et /proc/self/wchan, qui révèlent déjà pas mal d'informations, comme le pointeur de pile (visible avec ps -eo pid,eip,esp,wchan) :

fluxius@handgrep:~$ ps -eo pid,eip,esp,wchan

  PID      EIP      ESP WCHAN

[...]

1794 cb95aae3 14d91670 poll_schedule_timeout

1801 14f37ae3 d8434ab0 poll_schedule_timeout

1806 0b78bf20 33ad9f58 unix_stream_data_wait

1837 00000000 00000000 exit

1845 c16fbae3 fa7b2c50 poll_schedule_timeout

1858 da9b5ae3 ec890a00 poll_schedule_timeout

[...]

2102 0a70cae3 3cbc4220 poll_schedule_timeout

2146 ca80faa8 ff5276e0 poll_schedule_timeout

2196 00000000 00000000 poll_schedule_timeout

2241 9f8c5ae3 f0b1e380 poll_schedule_timeout

2260 1bcb8ae3 e59440e0 poll_schedule_timeout

2557 f7999ae3 8f39aed0 poll_schedule_timeout

2561 00000000 00000000 unix_stream_data_wait

2562 85356d3e 008b1460 wait

2618 955def20 18c26808 -

Une autre technique de fuzzing basée sur le traitement des « kstkeip » permet aussi de reconstruire l'agencement de l'espace d'adressage avec « fuzzyaslr » de Tavis Ormandy [14].

3.2.4 Return-to-registers

Afin d'éviter le brute force, nous pourrions tenter de voir si une instruction nous permettrait de sauter à l'adresse de notre shellcode, tout comme la méthode avec les instructions « Linux gate ».

En étudiant le binaire compilé, nous constatons que le registre %rax pointe sur le début de notre buffer.

Tout comme nous l'avons fait au tout début, nous allons chercher une instruction appelant le registre %rax. Plus précisément, une instruction de type jmp/callq rax :

fluxiux@handgrep:~/misc$ objdump -d ./toto | grep "callq"

  [...]

  40044c: ff d0                 callq *%rax

  [...]

400614: ff d0                 callq *%rax

  [...]

Nous avons le choix ici entre deux adresses : 0x40044c et 0x400614.

Pour exécuter notre code arbitraire, il suffira de faire du NOP sled au début du buffer, d'insérer notre shellcode à la suite et de remplir avec des données inutiles, jusqu'à la réécriture de l'adresse contenue dans %rip avec l'adresse 0x40044c, par exemple.

3.3 Position Independent Executable

Le PIE (Position Independent Executable) est une technique qui permet de compiler et lier des exécutables pour être « position independent ». Un exécutable compilé avec cette caractéristique est considéré comme une bibliothèque partagée et se comporte comme telle. Ce qui permet à l'adresse de base d'être repositionnée.

Chaque invocation du programme compilé avec PIE sera chargée dans un emplacement mémoire différent. Vous remarquez aussi que notre exécutable n'est pas complètement aléatoire et que les zones data, text, bss conservent leurs adresses à chaque exécution. Mais pour éviter les attaques ROP, PaX propose des mécanismes tels que RANDMAP et RANDEXEC.

Note

Le PIE n'a aucun effet sans ASLR. Différents modes existent pour l'ASLR (fichier proc/sys/kernel/randomize_va_space) :

0. désactivé ;

1. distribution aléatoire de l'espace d'adressage de la bibliothèque partagée et des exécutables PIE ;

2. même fonction que le mode 1 + l'espace « brk » aléatoire.

3.4 Canari

Rappelons-nous au départ que nous avions désactivé le canari Stack-Smashing Protector, qui est compilé par défaut avec les nouvelles versions de GCC. Lors d'une compilation par défaut, chaque fonction considérée comme dangereuse est changée au niveau de son prologue et de son épilogue.

Avec notre code compilé par défaut, si l'argument en entrée est beaucoup trop grand par rapport au buffer alloué, une erreur stack smashing detected ***: ./toto terminated apparaît et arrête le programme avant d'atteindre l'instruction de retour.

Reprenons notre code toto.c et compilons-le par défaut. Puis désassemblons son code dans GDB comme suit :

(gdb) disas vuln

Dump of assembler code for function vuln:

   [...]

   0x0000000000400599 <+53>: callq 0x400470 <strcpy@plt>

   0x000000000040059e <+58>: mov    -0x8(%rbp),%rax

   0x00000000004005a2 <+62>: xor    %fs:0x28,%rax

   0x00000000004005ab <+71>: je     0x4005b2 <vuln+78>

   0x00000000004005ad <+73>: callq 0x400460 <__stack_chk_fail@plt>

0x00000000004005b2 <+78>: leaveq

   0x00000000004005b3 <+79>: retq

On voit que l'instruction à l'adresse 0x00000000004005a2 va effectuer une opération xor de la valeur stockée dans %fs:0x28 et %rax. Si le résultat est « 0 », le drapeau conditionnel Z du processeur prendra la valeur « VRAI », le programme saute à l'adresse 0x4005b2 définie par callq 0x400460 et la fonction se termine proprement. Dans le cas contraire, le programme continue sans effectuer de saut, la fonction __stack_chk_fail est appelée et le programme s'arrête en détectant une tentative d'exploitation.

Observons ce qu'il se passe lorsque nous réécrivons un octet du cookie et en plaçant un breakpoint à l'appel de la fonction __stack_chk_fail :

(gdb) x/20x $rsp+500

0x7fffffffdf54: 0x41414141 0x41414141 0x41414141 0x41414141

0x7fffffffdf64: 0x41414141 0x41414141 0x41414141 0x41414141

0x7fffffffdf74: 0x41414141 0x78aa0041 0xc225354b 0xffffdfa0

0x7fffffffdf84: 0x00007fff 0x004005dc 0x00000000 0xffffe088

0x7fffffffdf94: 0x00007fff 0x00000000 0x00000002 0x00000000

Le cookie attendu était 0x78aa00<x> (où <x> est l'octet réécrit).

À l'adresse 0x7fffffffdf84, nous apercevons aussi l'adresse de retour de main (0x004005dc) :

0x7fffffffdf84: 0x00007fff 0x004005dc 0x00000000 0xffffe088

0x7fffffffdf94: 0x00007fff 0x00000000 0x00000002 0x00000000

(gdb) disas main

Dump of assembler code for function main:

[...]

   0x00000000004005dc <+40>: leaveq

   0x00000000004005dd <+41>: retq   

Ce qui nous donne une vision plus claire de la structure du canari :

Il y a 3 types de canaris :

- Null (0×0) ;

- terminator (0x00, 0x0d, 0x0a, 0xff) ;

- aléatoire.

Les deux premiers sont faciles à outrepasser [15], car cela ne demande qu'à réécrire les octets que l'on connaît déjà à la bonne place lors de la saisie du buffer. De l'autre côté, il y a les canaris aléatoires, qui sont un peu plus compliqués.

La fonction __gard__setup remplit une variable globale avec des données récupérées dans /dev/uradom, si possible. Ensuite, 4 ou 8 octets (en fonction de l'architecture) sont sélectionnés comme valeur pour le cookie. Cependant, si nous ne pouvons pas utiliser l'entropie de /dev/urandom, par défaut, nous obtenons un canari terminator ou nul.

Une vulnérabilité de type format string ou heap overflow nous permettrait de lire la valeur du cookie, mais aussi d'écrire le cookie souhaité. Mais lorsque nous n'avons pas trop de choix, il faut utiliser d'autres méthodes.

Tout comme pour l'ASLR, le brute force du canari est une discipline à part entière. En effet, en effectuant du fork, il est possible de réduire l'entropie appliquée à l'aléa du cookie, ce qui nous avance dans le brute force. Mais rappelons-nous que dans notre cas, nous avons un cookie de 64 bits et que nous utilisons la fonction strcpy, qui de plus rend difficile l'exploitation si nous avons un canari nul ou terminator(mauvais caractères 0x00 et 0x0a).

3.5 RELRO

Récemment sur Linux, un mécanisme de protection a été introduit, afin de renforcer les sections « data » des binaires et processus. Cette protection est nommée RELRO (RELocation Read Only) et a été proposée principalement pour réduire l'impact des attaques de type format string ou heap overflow.

Cette protection est visible simplement avec l'utilisation d'un outil comme readelf :

fluxiux@handgrep:~/misc$ readelf -l ./toto

Program Headers:

  Type           Offset             VirtAddr           PhysAddr

                 FileSiz            MemSiz              Flags Align

[...]

  GNU_RELRO      0x0000000000000e28 0x0000000000600e28 0x0000000000600e28

                 0x00000000000001d8 0x00000000000001d8 R      1

Et dans notre cas, avec une compilation par défaut, nous pouvons remarquer que des sections sont cartographiées en lecture seule :

Section to Segment mapping:

  Segment Sections...

   [...]

   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss

[...]

   08     .ctors .dtors .jcr .dynamic .got

Si nous trouvions une vulnérabilité de type string overflow, les cibles parfaites à réécrire sont les adresses GOT (Global Offset Table), servant au repositionnement dynamique des fonctions comme strcpy :

000000601018 000500000007 R_X86_64_JUMP_SLO 0000000000000000 strcpy + 0

Il y a deux modes de RELRO [16] :

- Partial RELRO : compilé avec gcc -Wl,-z,relro, les non-PLT sont en lecture seule, mais le GOT est toujours en écriture.

- Full RELRO : compilé avec gcc -Wl,-z,relro,-z,now, support des caractéristiques du RELRO partiel et le GOT entier est en lecture seule.

Il est clair qu'un RELRO partiel ne change pas énormément de choses si nous nous dirigions vers la réécriture du PLT d'une fonction. Mais il faut savoir que dans les distributions comme Ubuntu que nous utilisons ici, les packages sont généralement compilés en RELRO complet (à vérifier rapidement avec le script checksec.sh [17]).

Pour vérifier si nos exécutables sont bien compilés en Full RELRO, re-compilons notre code toto.c avec cette option :

fluxius@handgrep:~/misc$ gcc -Wl,-z,relro,-z,now toto.c -o toto

fluxius@handgrep:~/misc$ readelf -d ./toto

Dynamic section at offset 0xe10 contains 22 entries:

  Tag        Type                         Name/Value

[...]

0x0000000000000018 (BIND_NOW)           

[...]

Cependant, malgré l'effort fourni autour du RELRO, les tables de destructions DTORS (appelées juste avant exit) restent en écriture. Ce qui permet de réaliser une attaque de type « detour with .dtors » [18].

3.6 x86_64

Dans la partie NX, nous avons pu remarquer que les arguments étaient passés en registre [19], ce qui a compliqué l'attaque return-to-libc, que nous avons orientée vers du « borrowed chunk code » ou ROP.

En effet, la convention d'appel System V x64 ABI [20] utilise les 6 premiers registres (RDI, RSI, RDX, RCX, R8, R9) pour les entiers et 8 registres de type flottant/double/vecteur (XMM0-XMM7) pour faire passer les arguments dans les fonctions appelées.

En voici un exemple tiré des spécifications System V ABI x64 :

typedef struct {

structparm s;

int e, f, g, h, i, j, k;

extern void func (int e, int f, structparm s, int g, int h, long double ld, double m, __m256 y, double n, int i, int j, int k);

func (e, f, s, g, h, ld, m, y, n, i, j, k);

Résultat avec la valeur des registres :

Source : System V ABI x86_64

La pile est alignée sur 16 octets et GCC utilise toujours le registre RBP comme pointeur de base. On peut remarquer qu'il est aussi possible que les paramètres soient passés dans la pile.

L'extension de l'espace d'adressage virtuel amené à 64 bits, rend aussi l'ASLR plus redoutable pour les attaques return-oriented programming. De plus, si nous exploitons des fonctions s'arrêtant au premier caractère « \0 » (en plus des autres mauvais caractères 0x0a, 0x0d, 0x40), il est impossible de faire passer des adresses 64 bits pour exécuter nos gadgets.

3.7 FORTIFY_SOURCE

Afin de compliquer les failles format string, la GLIBC s'est vue attribuer un patch de plus. Cette protection est compilée par défaut depuis la Ubuntu 8.10 (conformément à la man page), mais doit être activée avec le drapeau d'optimisation -O2 ou supérieur.

Reprenons notre code vulnérable en modifiant la ligne du printf, comme suit :

[...]

        strcpy(buffer, string);

        printf(buffer);

[...]

Et compilons-le avec le drapeau « -O2 » :

fluxiux@handgrep:~/fail$ gcc toto.c -o toto -O2

toto.c: In function ‘vuln’:

toto.c:8:9: warning: format not a string literal and no format arguments

Après cela, on suppose que le format string est protégé et nous essayons de lire le contenu de la pile avec le spécificateur %x :

fluxiux@handgrep:~/fail$ ./toto "%x"

200fluxiux@handgrep:~/fail$ ./toto "%x %x %x %x"

1ff 0 fefefeff ff000000

Cela a l'air de fonctionner. Nous allons continuer avec un paramètre d'accès direct :

fluxiux@handgrep:~/fail$ ./toto "%4\$x"

*** invalid %N$ use detected ***

Abandon

Et maintenant, essayons avec le spécificateur %n, argument normalement traité comme un pointeur vers un entier :

fluxiux@handgrep:~/fail$ ./toto "%n"

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

Abandon

Le programme s'arrête sans rien afficher, si ce n'est que notre tentative de lecture dans la pile a échoué et a été détectée.

Nous avons mentionné que cette protection est un mécanisme GCC, donc analysons ce qu'il se passe dans la fonction vulnérable :

   0x00000000004005ff <+31>:callq 0x4004d0 <__strcpy_chk@plt>

    [...]

   0x000000000040060e <+46>: callq 0x4004b0 <__printf_chk@plt>

Nos deux fonctions strcpy et printf ont été remplacées. Regardons très rapidement en C ce que ces deux fonctions peuvent être. Regardons en premier __strcpy_chk :

// gcc-4.6.2/libssp/strcpy-chk.c

char *

__strcpy_chk (char *__restrict__ dest, const char *__restrict__ src,

              size_t slen)

{

  size_t len = strlen (src);

  if (len >= slen)

    __chk_fail ();

  return memcpy (dest, src, len + 1);

}

En bref, la fonction ne fait rien de plus que de détecter si la taille de la chaîne copiée n'est pas plus grande que celle qui est allouée. Nous n'allons pas nous attarder dessus.

Au niveau de __printf_chk, la première protection [21] consiste à détecter si un argument %n se trouve dans une zone de la mémoire avec les droits d'écriture (pile, bss, data, etc.) et arrête directement le programme en rendant le format string inoffensif dans le cas où le spécificateur est présent dans la charge active :

//libc/stdio-common/vfprintf.c

     LABEL (form_number):                                                     

       if (s->_flags2 & _IO_FLAGS2_FORTIFY)                                   

        {                                                                     

          if (! readonly_format)                                              

            {                                                                 

              extern int __readonly_area (const void *, size_t)               

                attribute_hidden;                                             

              readonly_format                                                 

                = __readonly_area (format, ((STR_LEN (format) + 1)            

                                            * sizeof (CHAR_T)));              

            }                                                                 

          if (readonly_format < 0)                                            

            __libc_fatal ("*** %n in writable segment detected ***\n");       

        }

La deuxième protection est la suivante :

//libc/stdio-common/vfprintf.c

    /* Determine the number of arguments the format string consumes. */

    nargs = MAX (nargs, max_ref_arg);

    /* Allocate memory for the argument descriptions. */

    args_type = alloca (nargs * sizeof (int));

    memset (args_type, s->_flags2 & _IO_FLAGS2_FORTIFY ? '\xff' : '\0',

            nargs * sizeof (int));

    args_value = alloca (nargs * sizeof (union printf_arg));

    args_size = alloca (nargs * sizeof (int));

..

    for (cnt = 0; cnt < nargs; ++cnt)

..

      switch (args_type[cnt])

..

        case -1:

          /* Error case. Not all parameters appear in N$ format

             strings. We have no way to determine their type. */

          assert (s->_flags2 & _IO_FLAGS2_FORTIFY);

          __libc_fatal ("*** invalid %N$ use detected ***\n");

}

Ici, si le numéro du paramètre dont nous souhaiterions avoir accès est inférieur au nombre d'arguments tapés, le programme s'arrête avec l'erreur *** invalid %N$ use detected ***. Ce qui explique pourquoi ce qui suit fonctionne :

fluxiux@handgrep:~/fail$ ./toto "%3x %1x %2x %4x"

1fb 0 fefefeff ff000000

En accord avec Captain Planet, cela n'est pas une réelle protection, mais plutôt un « mécanisme de découragement ».

Sur le dernier exploit « sudo » [22] avec une format string, une technique a été démontrée pour désactiver le drapeau _IO_FLAGS2_FORTIFY, afin d'utiliser le spécificateur %n. Comme nous pouvons spécifier une valeur à nargs, il serait possible de choisir « nargs = 0×40000000 », afin de tronquer (nargs*4) qui serait égale à « 0 » en utilisant le format string %1$*269096872$x.

Cependant, en 64-bits, aucune valeur n'a permis de satisfaire (nargs*4)=0. On cherchera alors d'autres méthodes pour outrepasser cette protection.

Conclusion

Aujourd'hui, la majeure partie des distributions embarquent des mécanismes de sécurité par défaut, encore peu appliqués il y a encore quelques années. Nous avons vu dans cet article les différentes protections par défaut et les moyens classiques de les contourner.

Le NX empêchant l'exécution de code dans la pile a permis d'introduire des techniques comme le return-to-libc, qui a donné suite à une multitude de techniques, dont le ROP. Cependant, le 64-bit rend plus difficile l'exploitation d'un dépassement de pile, sans parler du stack cookie qui demande un peu plus de travail avec notre programme vulnérable tel quel (brute force). L'ASLR constitue aussi une bonne protection, surtout avec le support PIE.

Il est clair qu'en combinant toutes ces protections ensemble, on remarque très rapidement qu'une vulnérabilité comme un buffer overflow est aujourd'hui très difficile à exploiter, si ce n'est même impossible.

L'exploitation en userland est donc devenue un art. Un art où il est indispensable de connaître les bases de l'exploitation, les différentes protections, les techniques pour les contourner et aussi pousser plus loin jusqu'à en découvrir de nouvelles.

Références

[1] Aleph One, Smashing the stack for fun and profit, http://www.phrack.com/issues.html?issue=49&id=14

[2] D. Lea, malloc, http://g.oswego.edu/dl/html/malloc.html

[3] Ubuntu Security Features, https://wiki.ubuntu.com/Security/Features

[4] Wikipedia, NX bit, http://en.wikipedia.org/wiki/NX_bit#Linux

[5] Wikipedia, Physical Address Extension, http://en.wikipedia.org/wiki/Physical_Address_Extension

[6] Julien Tinnès, Protection de l’espace d’adressage : état de l’art sous Linux et OpenBSD, http://www.unixgarden.com/index.php/misc/protection-de-l-espace-d-adressage-etat-de-l-art-sous-linux-et-openbsd

[7] Shell-Storm, Linux x86_64 execve('/bin/sh') 30 octets, http://www.shell-storm.org/shellcode/files/shellcode-603.php

[8] System V Application Binary Interface, AMD64 Architecture Processor Supplement, http://www.x86-64.org/documentation/abi.pdf

[9] Sebastian Krahmer, x86-64 buffer overflow exploits and the borrowed code chunks, http://www.suse.de/~krahmer/no-nx.pd

[10] VnSecurity, ROPEME, http://www.vnsecurity.net/2010/08/ropeme-rop-exploit-made-easy/

[11] Marco Ramilli's Blog, From ROP to JOP, http://marcoramilli.blogspot.fr/2011/12/from-rop-to-jop.html

[12] Jon Erickson , Hacking – The art Of Exploitation

[13] Julien Tinnes and Tavis Ormandy, Local bypass of Linux ASLR through /procinformation leaks, http://blog.cr0.org/2009/04/local-bypass-of-linux-aslr-through-proc.html

[14] Tavis Ormandy, FuzzyASLR, http://code.google.com/p/fuzzyaslr/

[15] Paul Rascagneres, Stack Smashing Protector (FreeBSD), HES 2010, http://www.hackitoergosum.org/2010/HES2010-prascagneres-Stack-Smashing-Protector-in-FreeBSD.pdf

[16] Trapkit, RELRO - À (not so well known) Memory Corruption Mitigation Technique, http://tk-blog.blogspot.fr/2009/02/relro-not-so-well-known-memory.html

[17] Trapkit, Checksec, http://www.trapkit.de/tools/checksec.html

[18]Sebastian Krahmer, RELRO, http://www.suse.de/~krahmer/relro.txt

[19] Benjamin Morin et Arnaud Michelizza, La sécurité au sein des processeurs x86, MISC n°58

[20] Jon Larimer, Intro to x64 reversing, SummerCon 2011, http://lolcathost.org/b/introx86.pdf

[21] Captain Planet, A Eulogy for Format Strings, http://www.phrack.org/issues.html?issue=67&id=9&mode=txt

[22] VnSecurity, Exploiting Sudo format string vunerability, http://www.vnsecurity.net/2012/02/exploiting-sudo-format-string-vunerability/

[23] Chris Anley, John Heasman, Felix « FX » Linder, Gerardo Richarte, The Shellcoder's Handbook

[24] Shellstorm, ROPgadget Tool, http://www.shell-storm.org/project/ROPgadget/

[25] Ubuntu, ExecStack, http://manpages.ubuntu.com/manpages/hardy/man8/execstack.8.html