Comprendre les bases du dépassement de pile sur Linux x86-64

Introduction

Cet article explore les fondamentaux des attaques par dépassement de pile sur des programmes Linux 64 bits (x86-64) sans protections modernes. Nous utiliserons l'outil radare2 pour analyser la mémoire cible. L'environnement de pratique est accessible via SSH avec les identifiants utilisateur1 et motdepasse1.

Organisation de la mémoire processus

Chaque programme s'exécute en tant que processus. La mémoire du processus est organisée séquentiellement :

  • Pile utilisateur (User stack) : Contient les données d'exécution comme le compteur d'instructions. La pile croît vers les adresses basses.
  • Région des bibliothèques partagées : Code des librairies liées dynamiquement.
  • Tas (Heap) : Mémoire allouée dynamiquement, croît vers les adresses hautes.
  • Code et données du programme : Instructions exécutables et variables initialisées.

Fonctionnement de la pile sur x86-64

La pile est une structure LIFO (Last-In, First-Out) cruciale pour la gestion des fonctions. Elle s'étend vers les adresses basses. Les opérations de base sont :

  • PUSH : Ajoute une valeur sur la pile. Le pointeur de pile (RSP) est décrémenté de 8 octets, puis la valeur est écrite.
  • POP : Retire une valeur de la pile. La valeur est lue à l'adresse pointée par RSP, puis RSP est incrémenté de 8.

Cadre de pile (Stack Frame)

Chaque fonction possède son propre cadre de pile pour stocker ses variables locales et gérer les appels. Prenons un exemple différent :


int multiplier(int a, int b) {
    int produit = a * b;
    return produit;
}

int effectuer_calcul(int x, int y) {
    int resultat = multiplier(x, y);
    return resultat;
}
effectuer_calcul(6, 7);

Lors d'un appel de fonction, l'adresse de retour (prochiane instruction de la fonction appelante) est poussée sur la pile. Le cadre de la fonction appelée est ensuite créé. Le registre RAX sert à retourner une valeur. Les six premiers arguments sont passés via les registres RDI, RSI, RDX, RCX, R8, R9. D'autres arguments utilisent la pile. Des conventions régissent quels registres doivent être préservés par l'appelant ou l'appelé.

Ordre des octets (Endianness)

L'architecture x86-64 utilise un ordre d'octets petit-boutiste (little-endian). La valeur 0xDEADBEEF est stockée en mémoire comme EF BE AD DE (l'octet de poids faible en premier).

Dépassement simple pour modifier une variable

Considérons un programme vulnérable contenant :


volatile int cible = 0;
char tampon[14];
gets(tampon);

Le compilateur aligne souvent les variables sur des limites spécifiques. Si tampon est placé avant cible sur la pile, une saisie de plus de 14 octets via gets() (non sécurisé) écrasera cible. La copie des données se fait des adresses basses vers les hautes, permettant de contrôler la variable adjacente.

Détournement d'un pointeur de fonction

Un programme peut contenir un pointeur de fonction que nous pouvons écraser. Supposons la fonction normal() pointée et une fonction speciale() à appeler. La procédure dans GDB serait :

  1. Déterminer la taille du tampon et l'offset jusqu'à l'adresse de retour.
  2. Exécuter disassemble speciale pour obtenir son adresse, par exemple 0x400570.
  3. Construire une charge utile avec des octets de remplissage (ex: 'A') puis l'adresse en petit-boutiste (\x70\x05\x40\x00\x00\x00).

Exécution de code arbitraire (Shellcode)

Pour exécuter un shell, nous injectons du code machine (shellcode) et redirigeons l'exécution vers lui. Un shellcode simple pour execve("/bin/sh") peut être généré. La charge utile typique se compose de :

  1. Traîneau NOP (NOP Sled) : Séquence d'instructions \x90 (No Operation) pour créer une zone de réception.
  2. Shellcode : Le code machine à exécuter.
  3. Padding : Données de remplissage pour atteindre l'adresse de retour.
  4. Adresse cible : Pointant au milieu du traîneau NOP ou au début du shellcode, en petit-boutiste.

Python est utile pour formater cette charge utile : python3 -c "print('\x90'*N + shellcode + 'A'*M + adresse)".

Cas pratique : Gagner les permissions d'un autre utilisateur

Si le binaire vulnérable possède le bit setuid, il s'exécute avec les privilèges du propriétaire. Nous pouvons modifier notre shellcode pour appeler setreuid(uid, uid) avant de lancer le shell. L'outil pwntools peut générer ce shellcode assembleur spécifique.


# Exemple pour cibler l'UID 1001
from pwn import *
context.update(arch='amd64', os='linux')
sc_setreuid = shellcraft.setreuid(1001, 1001)
sc_sh = shellcraft.sh()
shellcode = sc_setreuid + sc_sh
print(shellcode)

Ensuite, nous intégrons ce shellcode dans notre charge utile d'exploitation, en ajustant les longueurs pour conserver l'offset exact vers l'adresse de retour.

Protections et limitations

Ces attaques de base supposent l'absence de protections comme :

  • ASLR (Address Space Layout Randomization) : Randomise les adresses de base des segments mémoire.
  • Stack Canaries : Valeur placée avant l'adresse de retour pour détecter les écrasements.
  • NX (Non-Executable Stack) : Empêche l'exécution de code sur la pile.
  • PIE (Position Independent Executable) : Randomise l'adresse de base du binaire lui-même.

Comprendre ces mécanismes de base est essentiel pour étudier des techniques d'exploitation plus avancées qui cherchent à contourner ces défenses.

Étiquettes: x86-64 buffer-overflow Linux shellcode stack

Publié le 5 juillet à 21h21