Protections contre l'exploitation des débordements de buffer - Introduction

Introduction

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).

L'organisation de la mémoire

Rappels sur le format ELF

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 :

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.

Les régions mémoire d'un processus

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.

Variables et mémoire

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 :

Shellcode dans le .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
      

Maintenant, si nous regardons dans le système de fichiers /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 0
      
Comme 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 ;)

Shellcode dans le .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$ 
      

Shellcode dans le tas (heap)

      $ 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$ 
      

Shellcode dans la pile (stack)

      $ 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.

La Procedure Linkage Table ou PLT

Son fonctionnement

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.

Alchimie avec les fonctions

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 :

  1. l'adresse de printf() dans la GOT :
          $ /usr/bin/objdump -R ./foobar | grep printf
          08049518 R_386_JUMP_SLOT   printf
          
  2. l'adresse de 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$ 

Conclusion

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.


Frédéric Raynal - pappy(at)linuxmag-france.org
Samuel Dralet - zorgon(at)mastersecurity.fr