Buffer overflow x86 (Introduction aux buffers overflow)

Tout au long de cet article nous allons voir comment détecter et exploiter un buffer overflow dans le but d'obtenir un shell root. Pour cela nous allons exploiter le binaire suivante:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
   
void get_me_a_root_shell(){
    printf("GG WP opening the root shell !\n");
    setreuid(geteuid(), geteuid());
    system("/bin/bash");
}
   
void hello_there(){
    char buffer[64];
    fgets(buffer, 200, stdin);
    printf("Hello %s", buffer);
}
   
int main(int argc, char *argv[]){
    hello_there();
}

Le binaire en soit ne fait pas grand chose, la fonction main appelle la fonction hello_there() qui crée un buffer pouvant contenir 64 octets de données. Ensuite la fonction fgets récupère 200 octets d'entrées utilisateur et les stocke dans le buffer précédemment déclaré. La petite particularité de ce binaire cependant c'est qu'il contient une fonction, get_me_a_root_shell, qui n'est jamais appelée.

I/ Préparation du binaire

Avant toute chose nous allons devoir compiler notre binaire et modifier ses droits de manière à ce qu'une exploitation réussie nous permette d'obtenir un shell root. Voici les commandes à exécuter:

# Désactive les options de sécurité et compile en 32 bits
gcc -o basic basic_x86_linux.c -fno-stack-protector -no-pie -Wl,-z,relro,-z,now,-z,noexecstack -m32
# Change l'owner du binaire
sudo chown root: basic
# Ajoute le SUID à root et autorise l'exécution aux autres utilisateurs
sudo chmod u+s basic && sudo chmod a+x basic

II/ Détection du buffer overflow

Quel est le problème avec ce binaire ? Eh bien lorsque l'on regarde les lignes suivantes:

char buffer[64];
fgets(buffer, 200, stdin);

On peut voir que nous déclarons un tableau de char de 64 octets dans lequel nous allons stocker 200 octets de données. Le problème c'est que le buffer dans lequel nous voulons stocker ces 200 octets de données ne peut en stocker que 64. Du coup lorsque notre binaire tentera d'écrire les 200 octets de données et bien il va déborder du buffer. On dit qu'il va y avoir un overflow. Pour prouver qu'un buffer overflow est effectivement présent il nous suffira d'entrer une chaîne de caractères de taille supérieur à 64 et de voir comment réagit le binaire.

Avec la ligne suivante je vais générer une chaîne de 200 "A":

python -c 'print "A" *200'

Et lorsque l'on soumet cette chaîne de caractères au binaire on obtient ceci:

Une erreur de segmentation (abrégée segfault). Cette erreur apparaît lorsqu'un binaire tente d'accéder à une zone mémoire qui n'est pas allouée. Reste à savoir pourquoi notre binaire a ce comportement. Pour cela on pourra utiliser un débuggueur (tel que gdb) mais avant tout nous allons utiliser strace.

Strace est un binaire utilisé afin de débuguer une application. Il va, entre autre, nous permettre d'analyser les appels systèmes effectués par un binaire:

Ici l'opération qui nous intéresse est l'appel système à la fonction read qui va lire l'entrée utilisateur depuis STDIN (symbolisé par le paramètre 0). Comme on peut le voir sur le screen ci-dessous c'est bien cet appel système qui est responsable du segfault:

En revanche ce qui est plus intéressant à noter, c'est le contenu de la variable si_addr:

Et pourquoi cette valeur est intéressante ? Tout simplement parce que 0x41414141 est la représentation hexadécimale de la chaîne de caractères "AAAA".  Or la variable si_addr contient l'adresse mémoire responsable du sefsault. Cela veut donc dire que si notre binaire a crash c'est parce qu'il a tenté d'accéder à l'adresse mémoire 0x41414141. Reste à savoir pourquoi notre chaîne de 200 "A" se retrouve ici :P !

Pour cela nous allons dégainer notre meilleur ami: gdb (enfin gdb-peda parce que c'est quand même vachement plus cool à utiliser)

gdb basic

La première chose que nous allons faire, c'est poser un breakpoint sur l'entry point du binaire c'est à dire la fonction main:

b *main
Puis nous allons lancer l'exécution du binaire via la commande:
run

Grâce au code source nous savons que la fonction fgets est appelée au sein de la fonction hello_there:

void hello_there(){
    char buffer[64];
    fgets(buffer, 200, stdin);
    printf("Hello %s", buffer);
}

Nous allons donc ajouter un deuxième breakpoint sur cette fonction:

b *hello_there

Puis continuer l'exécution du binaire via la commande:

c

Nous voilà dans le fonction hello_there, désassemblons la pour voir ce qu'elle fait via la commande:

pdisass

Sans trop rentrer dans les détails nous pouvoir voir que les fonctions fgets puis printf sont appelées. Comme nous l'avons vu plus haut nous savons que la fonction fgets est critique. En effet nous avons vu que l'adresse mémoire 0x41414141 est directement liée à notre chaîne de 200 caractères "A". Pour bien comprendre ce qu'il s'est passé il est donc nécessaire d'analyser les opérations effectuées par notre binaire juste après que ce dernier ait récupéré notre chaîne de caractères. A partir de maintenant nous allons donc exécuter notre binaire instructions par instruction via la commande:

n

Continuons l'exécution du programme jusqu'à arriver à l'appel de la fonction fgets:

En appuyant une nouvelle fois sur "n", le binaire va nous demander une chaîne de caractères. Nous allons donc lui fournir notre chaîne de 200 "A":

Puis continuer l'exécution de notre programme jusqu'à arriver à l'opération ret:

Exécutons encore une instruction puis observons le contenu des registres:

On retrouve une partie de notre chaîne de caractères dans le registre EIP! Or le registre EIP est le registre dans lequel est stockée l'adresse mémoire de la prochaine instruction à exécuter. Du coup si on exécute encore une fois la commande n, notre binaire va tenter d'exécuter l'instruction contenue à l'adresse 0x41414141 et comme cette adresse n'est pas référencée et bien le binaire crashera:

Nous venons donc de trouver la raison pour laquelle notre binaire segfault: notre chaîne de caractère, d'une manière ou d'une autre, réécrit plusieurs registres dont le registre EIP! Il ne nous reste plus qu'à comprendre pourquoi!

III/ Stack frame et buffer overflow

Avant d'exploiter cette vulnérabilité il est important de bien comprendre le fonctionnement de la pile. Il y a un an environ j'avais déjà écrit un article sur ce sujet mais nous allons quand même revoir tout ça en profondeur. Lors de l'exécution d'un binaire, le kernel va réserver une plage mémoire physique en RAM et mapper les différentes sections de ce dernier en respectant le pattern suivant:

De haut en bas nous avons:

  • Les arguments passés au binaire ainsi qu'une copie des variables d'environnement
  • La stack qui est utilisé pour manipuler des données
  • La heap qui est utilisée afin de stocker des données allouées dynamiquement (utilisée par les fonctions calloc, malloc, realloc et free)
  • La section .bss qui contient l'ensemble des variables initialiasées à 0 (i.e: int nombre;)
  • La section .data qui contient l'ensemble des variables initialisées (i.e: int nombre = 5;)
  • La section .text qui contient les instructions à exécuter

Dans le cadre de l'exploitation de notre binaire, la section qui va le plus nous intéresser c'est la stack. La stack, c'est une structure de type LIFO (Last In First Out). Plus on empile d'informations sur la stack plus les adresses mémoires seront basses (puisque la stack grandit des adresses hautes vers les adresses basses). Pour la heap c'est l'inverse. 

La pile n’a techniquement pas de taille maximale en revanche elle varie en fonction du contenu du programme. Pour toujours savoir où elle commence et où elle se termine nous disposons de deux registres : EBP et ESP.

EBP (Base Pointer) est le registre qui contient l’adresse mémoire du début de la pile (l’adresse la plus haute de la pile) tandis que ESP (Stack Pointer) contient l’adresse mémoire de la fin de la pile (l’adresse la plus basse). La première conséquence de cette architecture est que à chaque fois qu’on va placer un élément sur la pile (via l’instruction push), l’ESP sera modifié :

Deuxième conséquence de cette architecture, toutes les valeurs placées sur la pile seront contenues entre l’adresse stockée dans le registre EBP et l’adresse stockée dans ESP.

Lorsque l'on parle de stack on parle aussi de stack frame. Une stack frame c'est en fait un espace mémoire dédié sur la stack a une fonction. Sur cet espace, une fonction d'un programme pourra stocker ses variables locales et les manipuler. Une stack frame sera créée au moment de l'appel d'une fonction. Ainsi lorsque la fonction main de notre binaire appellera la fonction hello_there, alors la stack ressemblera schématiquement à ça:

Mais du coup comment passe-t-on d'une stack frame à l'autre ? Comment cet espace mémoire est créé ? Eh bien tout cela nous le devons à deux groupes d'instructions, toujours les mêmes, qui se trouvent au début et à la fin d'une fonction et se nomment respectivement le prologue:

push ebp
mov ebp, esp

Et l'épilogue

mov esp, ebp
pop ebp
ret

En breakant sur la fonction hello_there du binaire on retrouve bien ces ensembles d'instructions:

Leave est une instruction haut niveau qui exécute les mêmes opérations que les instructions mov esp,ebp et pop ebp.

Mais du coup que font ces instructions ? Pour comprendre tout ça je vais poser un breakpoint juste avant l'appel de la fonction hello_there dans le main:

b *main+27

Voici le contenu de la stack ainsi que des registres:

L'instruction call est une instruction haut niveau qui exécute en fait deux instructions d'un coup:

push eip
jmp hello_there

Cette instruction nous permet à la fois de sauvegarder l'adresse la prochaine exécution à exécuter une fois la fonction terminée et de jumper sur le code de cette fonction. A ce moment là, on peut représenter la stack de cette manière:

Nous entrons donc dans la fonction hello_there et exécutons la première instruction du prologue, c'est à dire:

push ebp

Pourquoi fait-on cela ? L’idée derrière tout ça c’est de sauvegarder la valeur d'origine d’EBP pour justement ne pas écraser les données déjà présentes sur la pile. Si on regarde la valeur d'EBP juste avant l'appel de la fonction hello_there on trouve cette adresse:

Et si on regarde le contenu de la stack juste après l'exécution de cette instruction on retrouve bien la même adresse hexadécimale:

La stack ressemble donc à ça:

Ensuite l'instruction suivante est exécutée:

mov ebp, esp

Cette instruction va nous permettre de faire pointer EBP et ESP sur la même adresse mémoire. Et effectivement quand on regarde le contenu de ces registres on retrouve la même valeur:

A ce moment là, la stack peut être schématisée de cette manière (on notera que EBP et ESP pointe vers la même adresse mémoire):

 

A partir de maintenant la stack frame de la fonction hello_there est prête à être utilisée. Si l'on reprend le code source du binaire on peut voir qu'un buffer de 64 octets va être initialisé:

void hello_there(){
    char buffer[64];
    fgets(buffer, 200, stdin);
    printf("Hello %s", buffer);
}

Donc au sein de la stack frame de la fonction hello_there nous aurons quelque part un espace de 64 octets qui sera alloué et qui sera utilisé afin de stocker la chaîne de caractères soumise par l'utilisateur:

Le problème ici c'est que la fonction fgets n'est pas sécurisée. En effet à aucun moment elle ne vérifie la taille de l'input de l'utilisateur. Si on lui fournit une chaîne de caractères de 30 octets, tout se passera bien, fgets écrira le chaîne dans le buffer en partant de l'adresse la plus basse jusqu'à l'adresse la plus haute:

Par contre si on lui fournit une chaîne de caractères de 200 octets et bien fgets écrira les 64 premiers dans le buffer alloué puis continuera d'écrire dans la stack.

Ce qui aura pour conséquence d'écraser de nombreuses valeurs dont à minima la sauvegarde d'EBP ainsi que la sauvegarde d'EIP. La fonction va continuer de s'exécuter puis arriver à l'épilogue qui est constituée des trois instructions suivantes:

mov esp, ebp
pop ebp
ret

L'instruction:

mov esp, ebp

Va nous permettre d'écraser la stack frame de la fonction hello_there en faisant pointer de nouveau EBP et ESP sur la même adresse mémoire:

L'instruction:

pop ebp

Va nous permettre de récupérer la sauvegarde d'EBP de manière à refaire pointer EBP sur le début de la stack frame de la fonction main:

Pour finir l'instruction:

ret

Nous  permettra de récupérer la sauvegarde faite d'EIP et ainsi continuer le flux d'exécution de notre programme.

L'instruction ret est une instruction haut niveau qui revient à faire un pop eip

Et c'est exactement à ce moment là que nous allons pouvoir exploiter notre buffer overflow! En effet comme nous l'avons vu plus haut en écrivant une chaîne de caractères plus grande que la taille du buffer alloué nous allons pouvoir réécrire le contenu de la sauvegarde d'EIP et donc, lorsque la fonction hello_there se terminera, nous contrôlerons totalement le flux d'exécution du binaire. Maintenant que nous avons les connaissances théoriques nous allons pouvoir passer à la partie la plus intéressante: l'exploitation!

IV/ Exploitation du buffer overflow

L'exploitation d'un buffer overflow est assez triviale une fois que la méthodologie comprise. Premièrement nous allons devoir déterminer l'emplacement d'EIP. Dans notre cas nous savons qu'un buffer de 64 octets est initialisé sur la stack donc la sauvegarde d'EIP se trouve forcément plus loin.

Pour déterminer l'emplacement mémoire d'EIP nous allons nous servir de peda-gdb qui va nous permettre de créer un pattern:

pattern create 200

Ce pattern est une chaîne de 200 caractères qui ne contient pas deux fois le même pattern. En entrant cette chaîne de caractères lorsque le binaire nous le demandera et en analysant le contenu du registre EIP nous pourrons ainsi déterminer l'emplacement mémoire de la sauvegarde d'EIP:

Sur le screen ci-dessus nous pouvons voir que le registre EIP contient la chaîne de caractères:

AA4A

Avec cette information nous allons pouvoir utiliser la commande suivante:

pattern offset AA4A

Qui nous indiquera que la sauvegarde d'EIP se trouve à l'offset 76. Pour vérifier cela nous pourrons créer une nouvelle chaîne de caractères composée de 76 "A" ainsi que de 4 "B":

python -c 'print "A" * 76 + "BBBB"'

Puis nous pourrons relancer le binaire, entrer notre nouvelle chaîne de caractères et nous apercevoir que EIP contient la valeur 0x42424242 (soit BBBB):

Génial! Nous contrô​lons donc bien EIP. Que nous reste-il à faire ? Eh bien c'est simple nous ce que nous voulons c'est sauter sur la fonction get_me_a_root_shell. Etant donné que nous controlons EIP nous allons pouvoir sauter directement sur cette fonction... A condition de connaître son adresse héxadécimale. Pour cela, encore une fois, on utilisera peda et notamment la commande:

info func get_me_a_root_shell

Cette commande nous indique que le début de la fonction get_ma_a_root_shell se trouve à l'adresse 0x08048546. Il ne nous reste plus qu'à créer une chaîne de 76 caractères suivies de l'adresse hexadécimale de la fonction get_me_a_root_shell. Pour cela j'utilise le module python struct qui va nous permettre d'interpréeter des octets sour la forme de data packée. Au final notre payload ressemblera à ça:

(python -c 'import struct; print "A" * 76 + struct.pack("<L", 0x08048546)') | ./basic

L'argument <L nous permet de spécifier à struct qu'il doit packer l'adresse 0x08048546 en little endian. Il est important de bien connaître la différence en little et big endian puisque cela influera sur la structure finale de votre payload.

Avec ce payload nous obtiendrons bien un shell root:

En revanche nous ne pouvons pas intéragir directement avec lui puisque le flux stdin est coupé. Pour empêcher cela il nous suffira d'ajouter la commande cat à notre payload:

(python -c 'import struct; print "A" * 76 + struct.pack("<L", 0x08048546)'; cat) | ./basic

Nous obtiendrons ainsi un shell root complètement interactif via l'exploitation d'un buffer overflow!