Chapitre 5. Introduction aux attaques logiciel

Table des matières

Théorie: comment ca marche ?
Exploiter un logiciel dites vous ?
L'organisation de la memoire
Qu'est-ce qu'un shellcode ?
Vulnérabilités: comment cela s'exploite ?
Ecrivons un shellcode
Exercice: Un `catcode`
Exploitons un buffer overflow
Exo: exploit classique
Quelques exemples de la vie reelle
Défense: comment se protéger ?
Protections Userland contre les débordements de tampons

Théorie: comment ca marche ?

Exploiter un logiciel dites vous ?

Maintenant nous allons rentrer dans le vif du sujet. Exploiter cela veut dire modifier à son avantage le fonctionnement d'un programme. En effet, nous allons voir qu'il est possible, dans certaines conditions, de tirer profit d'un programme, afin de lui faire faire des actions non prévues (comme binder un shell sur une socket). Pour ceux qui ne le savent pas, binder un shell revient à faire tourner un interpreteur de commande (shell), sur un port réseau de la machine, afin de pouvoir éxécuter des commandes à distance (genre make, ou useradd :) )

Avant de commencer à faire segfaulter des programmes, voici ce que vous devez savoir sur le contexte de l'exploitation d'un bout de code.

Environnement de l'exploitation

Chaque cas est relativement précis, et les techniques sont rarement interchangeables, mais on peut dégager deux grandes constantes:

  • soit l'attaque est réalisee en local, à l'aide d'un shell sur la machine

  • soit à distance (remote), à l'aide d'une connexion réseau

En local, les programmes ciblés seront les setuid, qui permettent d'obtenir des privilèges plus élevés (root le plus souvent)

En remote, ce seront les services qui seront attaqués, afin de permettre à un attaquant d'obtenir un shell sur votre machine (pour commencer des attaques locales, si il n'a pas déjà un compte uid0).

De manière générale, tous les bouts de codes qui manipulent des données étrangères, tel qu'un browser http, un client mail, un lecteur multimédia, etc, etc, sont des cibles potentielles pour une attaque remote ou local.

Note: nous n'avons pas parlé du kernel. Je vous laisse deviner le contexte d'une faille dans un appel système, ou dans la pile tcp... L'exploitation d'un tel bug est tout à fait réalisable, et aboutit soit à un reboot de la machine, soit à un compte root :)

Les principaux bugs exploitables

Voici la liste des erreurs qui peuvent aboutir à une corruption de la mémoire. En effet, quand on parle d'exploiter un logiciel ca revient à lui faire faire quelque chose d'anormal, et le plus souvent cela aboutit à une corruption de la mémoire. Nous verrons juste aprés comment, en écrivant un byte ou deux de trop au mauvais endroit, on faire éxécuter du code arbitraire.

  • Mauvaise gestion d'un buffer :

    for (i = 0; i < sizeof(dest_buf); i++)
    {
       ...
        i++;
       ...
    }
    dest_buf[i] = '\0';
            

  • Mauvaise utilisation de fonctions standard :

    strncpy(buf, input_buffer, sizeof(buf)); /* Cela ne copie pas le null byte de fin de chaîne si strlen(input_buffer) > sizeof(buf) */
    
    strncat(buf, input_buffer, sizeof(buf) - strlen(buf)); /* ERREUR: strncat copie le null byte de fin de chaîne */
            

  • Mauvaise condition avec des valeurs signées :

    void   foo(int size)
    {
      char buf[4096];
    
      if (size > sizeof(buf))   /* ERREUR: si size > 0x80000000 alors le test ne marche pas */ 
        return ;
        ...
    }
            

  • Erreur de conception qui peuvent aboutir à de mauvaises manipulations.

Ok, si une de ces erreurs de programmation arrive, et bien il y a de fortes chances que la mémoire soit corrompue. Regardons à présent comment tirer profit de ce genre de situations.

Exploitation d'une corruption de la mémoire

Dans la pile et dans le tas, des données de gestion sont entrallacées avec des données utilisateur. Il ne faut pas que les informations liées au déroulement du programme soit modifiées. Voici la liste des éléments dangereux en mémoire :

  • Les adresses de retour des fonctions sur la stack sont les valeurs de prédilection pour un pirate ! En effet, si il arrive a modifier cette adresse, alors il peut rediriger l'execution du programme vers du code arbitraire

  • La sauvegarde du stack frame pointeur est elle aussi dangereuse, elle permet de repositioner la stack à la fin d'une fonction. Si elle est modifiée, alors le retour de fonction de la fonction appellante ne sera pas bon (cf bullet ci-dessus)

  • Les structures de gestion du mémory allocator sont elles aussi dangereuses. Il est tout à fait possible de manipuler free() ou malloc() afin de lui faire écrire une ou deux valeurs là ou il faut !

  • Les entrée de la Global Offset Table (GOT). C'est un tableau qui est utilisé pour résoudre les addresse dynamique, tel que les adresses des fonctions partagée (libc par exemple). Si l'entrée d'une fonction qui va être appelé est remplacé par l'adresse d'un shellcode, alors il sera éxécuté lors de l'appel de cette fonction. objdump -R pour avoir l'adresse du tableau

  • Les destructeur (DTOR) de la libc. C'est aussi un tableau de pointeur de fonctions qui sera éxécuté à la fin du programme.

Note: cette liste n'est pas exhaustive, ce sont juste les élèments les plus courrants; de manière générale, une corruption de la mémoire n'est pas souhaitable en sécurité ! Regardons à présent les vecteurs d'attaques

Vecteurs d'attaques

Voici les élèments via lesquels une attaque peut avoir lieu :

  • Via les parametres d'un programme

  • Via un file descripteur (entré standard, socket réseau)

  • Via les données manipulées par un programme (fichiers)

L'organisation de la memoire

Chargement d'un programme

Lorsqu'un programme au format ELF est lance, le noyau organise la memoire virtuelle allouee au processus : des plages memoire sont reservees pour les besoins du programme (pile, tas, donnees, code, etc). Ainsi chaque programme userland (ring 3) croit être le seul à tourner : c'est ce qu'on appelle le mode protégé

Chaque processus a un espace memoire virtuelle compris entre [0x0; 0xFFFFFFFF]. Voici comment il est divise :

  • une partie pour le noyau : kernelland [0xBFFFFFFF; 0xFFFFFFFF]
  • une pour le processus : userLand [0x0; 0xBFFFFFFF]

Celle qui nous intéresse est la zone userland. Voici ce qu'on y trouve :

  • La pile (stack) : zone d'accès rapide pour les appels de fonctions et les variables locales
  • Le tas (heap) : espace allouable par les processus, (malloc, calloc) (tout ce qui fait appel à brk() ou sbrk())
  • Les bibliotheques (chargees au demarage ou par mmap)
  • L'exécutable : le code du programme.
  • Les donnees globales

Les variables locales sont regroupees dans une zone memoire reservee a l'execution du programme. Les fonctions pouvant s'invoquer de maniere recurrente, le nombre d'instances d'une variable locale n'est pas connu a l'avance. Elles seront donc placees, au moment de leur definition dans la pile du processus (stack).

La stack commence a partir de 0xBFFFFFFF [1] (linux) et 0xBFBFFFFF (*bsd) et grandit vers le bas.

pour savoir comment est segmenté la mémoire d'un programme : # cat /proc/<PID>/maps. Chaque ligne décrit une zone mappée, vous aurez :

  • La plage d'adresse virtuelle dans la mémoire du programme
  • Les permissions les droits en lecture - écriture - exécution - shared/private
  • L'offset du fichier
  • Dev
  • L'inode du fichier et son path

[1]: a partir du noyau linux 2.6.12 les adresses memoire sont randomisées... 'echo 0 > /proc/sys/kernel/randomize_va_space' pour désactiver cette protection.

Schéma de la mémoire virtuelle

  • Userland / Kernelland
    -----------------  0xffffffff
    |               |
    |               |
    |  KernelLand   |
    |               |
    |               |
    ----------------|  0xbfffffff
    |               |
    |               |
    |  UserLand     |
    |               |
    |               |
    ----------------   0x00000000
    	
  • Userland (exécution stopee au debut du main)
    |----------------| 0xbfffffff
    ||--------------||
    || Path         ||
    || (/home/...)  ||
    ||--------------||
    || Env star     ||
    ||(PATH=...)    ||
    ||--------------||
    || Argc star    ||
    ||(/home/...)   ||
    ||--------------||
    || Envp[]       ||
    ||              ||
    ||--------------||
    || Argv[]       |--> La stack 
    ||              ||
    ||--------------||
    || Vars locals  --->  Des fonctions avant celle du main
    ||              ||
    ||--------------||
    || *Envp[]      ||
    ||--------------||
    || *Argv[]      ||
    ||--------------||
    || Argc         ||
    ||--------------||
    || Return addr  --->  Adresse de l'instruction suivant le call de main (cf C calling convention)
    ||--------------||
    ||    Ebp       --->  Base Pointer address de la fonction appelante
    ||______________||
    || Variables    ||
    || locales      ||
    ||   de main    ||
    ||______________||
    |                |
    |                |
    |               --->  Grande zone non-allouee
    |                |
    |                |
    |                |
    ||--------------||
    ||mmap()        |->  Librairies chargées dynamiquement par le programme
    ||______________||
    |                |
    ||--------------||
    ||lib.so        |->  Librairies partagées
    ||--------------||
    |                |
    |                |
    |                |
    ||--------------||
    ||              ||
    ||              |->  Le tas qui contient les zones allouées par malloc en autre.
    ||  Heap        ||
    ||______________||
    |                |
    |                |
    ||--------------||
    ||  Exécutable  ||
    ||______________||
    |________________| 0x0
    	

Qu'est-ce qu'un shellcode ?

Un shellcode est du code executable independant de sa position, destine a etre execute dans le contexte d'une application. Il est generalement copie en memoire grace a des fonctions de manipultation de chaines de caracteres et donc ne peut contenir de caracteres nuls. Il peut contenir plusieurs types de code en fonction de son utilisation, par exemple :

  • execution d'un simple shell suid root, pour les failles locales
  • bind shell : lier un shell a un serveur, pour un acces distant
  • reverse shell : le code se connecte a un serveur distant
  • ecrire dans un fichier, lancer un programme...

Voici le hello world les shellcodes :

char shellcode[] =
                                // setuid(0);
"\x31\xdb"                      // xorl         %ebx,%ebx
"\x8d\x43\x17"                  // leal         0x17(%ebx),%eax
"\xcd\x80"                      // int          $0x80
                                // exec('/bin/sh');
"\x31\xd2"                      // xorl         %edx,%edx
"\x52"                          // pushl        %edx
"\x68\x6e\x2f\x73\x68"          // pushl        $0x68732f6e
"\x68\x2f\x2f\x62\x69"          // pushl        $0x69622f2f
"\x89\xe3"                      // movl         %esp,%ebx
"\x52"                          // pushl        %edx
"\x53"                          // pushl        %ebx
"\x89\xe1"                      // movl         %esp,%ecx
"\xb0\x0b"                      // movb         $0xb,%al
"\xcd\x80";                     // int          $0x80 
  

Il existe des variantes de shellcode qui sont resistantes a des 'tolower' ou qui ne sont composees que de caracteres ascii, ou qui sont polymorphes, afin de dejouer la plupart des IDS.