← Retour à Linux

Le Buffer Overflow dans la pile

5 septembre 2026•15 min•Moyen
Reverse EngineeringLinux

Débordement de tampon basé sur la pile ou Stack-based Buffer Overflow

Dans l'article précédent, on a vu ce qu'est un buffer overflow et pourquoi scanf("%s", ...) sans limite est dangereux. Cette fois, on va se concentrer sur la pile, comprendre ce qu'un débordement y écrase vraiment et voir comment on passe d'un simple crash à une corruption du flot d'exécution.

À quoi sert la pile

On part du principe que tu sais déjà ce qu'est une pile en informatique. Si ce n'est pas le cas, le lien renvoie vers une explication complète.

Ce qui nous intéresse ici, c'est comment elle est utilisée par un programme en cours d'exécution. Chaque fois qu'une fonction est appelée, une nouvelle zone lui est réservée sur la pile pour stocker ses variables locales, l'adresse à laquelle revenir une fois la fonction terminée et parfois la sauvegarde de certains registres.

Sur x86 et x86-64, la pile grandit vers les adresses basses, ce qui veut dire qu'un push fait diminuer le pointeur de pile et qu'un pop le fait augmenter. Ce détail explique en grande partie pourquoi les buffer overflows sur la pile sont exploitables, un buffer local est écrit du bas vers le haut, en direction des données de contrôle du programme et non l'inverse.

Organisation d'une stack frame

Quand une fonction est appelée, elle reçoit sa propre zone sur la pile, la stack frame. Sur une architecture x86-64 classique, une frame typique ressemble à ceci.

adresses hautes
+-----------------------------+
|      adresse de retour      |
+-----------------------------+
|      RBP sauvegardé         |
+-----------------------------+
|      variables locales      |
+-----------------------------+
adresses basses

Au début d'une fonction, le prologue standard fait ceci.

push   rbp
mov    rbp, rsp
sub    rsp, 0x20

push rbp sauvegarde l'ancien frame pointer de l'appelant. mov rbp, rsp fixe le nouveau frame pointer sur la frame actuelle, il sert de point de référence pour accéder aux variables locales et aux arguments tout au long de la fonction, même si RSP bouge en cours de route. sub rsp, 0x20 réserve simplement la place nécessaire pour les variables locales.

L'adresse de retour, elle, est empilée automatiquement par l'instruction call, avant même que le prologue ne s'exécute. C'est l'adresse de l'instruction juste après le call, celle où le processeur doit reprendre une fois la fonction terminée avec ret.

Cette organisation est justement ce qui rend le buffer overflow dangereux, un buffer local est placé en dessous, dans les adresses basses et un débordement écrit vers le haut, en direction du RBP sauvegardé puis de l'adresse de retour.

Le fonctionnement d'un Stack Buffer Overflow

Comment le dépassement sort du buffer

Reprenons un exemple simple, volontairement vulnérable.

#include <stdio.h>

void fonction_compromise() {
    printf("Compromission réussie.\n");
}

void traiter_nom() {
    char buffer[32];
    printf("Nom : ");
    gets(buffer);
    printf("Bonjour %s\n", buffer);
}

int main() {
    traiter_nom();
    return 0;
}

buffer[32] réserve 32 cases mémoire sur la pile, chacune pouvant stocker un caractère. Le problème vient de cette ligne :

gets(buffer);

gets ne connaît absolument pas la taille du buffer qu'on lui donne. Elle continue d'écrire tant qu'elle reçoit des caractères, jusqu'au retour à la ligne, sans jamais vérifier si ça dépasse les 32 cases prévues. Au-delà de cette limite, l'écriture continue dans la mémoire adjacente, celle du RBP sauvegardé, puis de l'adresse de retour.

Quelles données peuvent être écrasées

Dans l'ordre, un débordement suffisamment long va typiquement écraser d'autres variables locales situées après le buffer dans la frame, puis le stack canary s'il est présent, puis le RBP sauvegardé de l'appelant et enfin l'adresse de retour.

Chacun de ces éléments a un intérêt différent. Écraser une variable locale peut suffire à contourner une vérification, par exemple un flag d'authentification stocké juste après un buffer. Écraser le RBP peut perturber l'accès aux variables locales de l'appelant après le retour. Mais c'est l'adresse de retour qui offre le contrôle le plus direct sur la suite de l'exécution.

Pourquoi l'adresse de retour est importante

Quand traiter_nom() se termine, l'instruction leave restaure RSP puis dépile RBP, et l'instruction ret qui suit dépile ensuite la valeur au sommet de la pile et saute dessus. Si cette valeur a été remplacée par une entrée trop longue, le processeur va sauter exactement là où le contenu écrasé le lui indique.

C'est le principe fondamental du buffer overflow sur la pile. On ne casse pas le programme au sens propre, on détourne une instruction de saut qui existait déjà et qui fait une confiance aveugle à ce qu'elle trouve en mémoire.

Contrôler le crash

Avant d'aller plus loin, la première étape consiste à provoquer un crash de façon fiable et à comprendre ce qui a été corrompu.

Provoquer le crash volontairement

On compile sans les protections modernes, pour se concentrer sur le mécanisme.

gcc -fno-stack-protector -no-pie -z execstack -o vuln vuln.c

Puis on lance le programme sous gdb avec une entrée clairement identifiable.

gdb ./vuln
(gdb) run
Nom : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Si l'entrée est assez longue, le programme plante.

Program received signal SIGSEGV, Segmentation fault.
0x0000000000401196 in traiter_nom ()

Et si on regarde le registre RIP au moment du crash.

(gdb) info registers rip
rip            0x4141414141414141   0x4141414141414141

0x41 est le code ASCII de A. Le processeur a bien tenté de sauter à l'adresse AAAAAAAA, ce qui prouve que l'adresse de retour a été écrasée. C'est le signal qu'on cherche, le crash n'est pas juste un accident, c'est la preuve qu'une donnée critique est corrompue.

Observer ce qui a été corrompu

gdb permet d'inspecter la pile au moment du crash avec x/40xg $rsp ou d'examiner la frame avec info frame. On peut ainsi repérer précisément où se trouvaient le RBP sauvegardé et l'adresse de retour avant l'écrasement et confirmer que le reste de la mémoire environnante n'a pas été touché de façon inattendue.

Comprendre le lien entre entrée utilisateur et registres

L'idée à retenir ici, c'est qu'il existe une relation directe et prévisible entre les octets qu'on envoie en entrée et le contenu final des registres et de la pile. Chaque octet tapé au clavier finit quelque part en mémoire, de façon déterministe.

Trouver l'offset

Déterminer précisément l'offset

Envoyer 44 A au hasard et constater que RIP vaut 0x4141414141414141 prouve que l'adresse de retour est écrasée, mais pas à partir de quel octet exactement. Il faut connaître l'offset, c'est-à-dire le nombre d'octets à envoyer avant que les octets suivants ne tombent pile sur l'adresse de retour.

Introduction aux motifs (patterns)

Plutôt que d'essayer des entrées de plus en plus longues à chaque tentative, on utilise un motif unique où chaque groupe de 4 ou 8 octets ne se répète jamais. C'est le rôle d'outils comme pattern_create de Metasploit ou cyclic de pwntools.

python3 -c "from pwn import cyclic; print(cyclic(200).decode())"

On envoie ce motif au programme, il plante, et on relève la valeur de RIP.

(gdb) info registers rip
rip            0x6161616c61616b61

Il suffit ensuite de demander à l'outil à quelle position ce motif apparaît dans la chaîne d'origine.

python3 -c "from pwn import cyclic_find; print(cyclic_find(0x6161616c61616b61))"

Le nombre retourné est l'offset exact, le nombre d'octets de remplissage nécessaires avant que les 8 octets suivants n'atterrissent précisément dans l'adresse de retour.

Observer la corruption du flot d'exécution

Une fois l'offset connu, on peut construire une entrée qui place une adresse choisie exactement à l'emplacement de l'adresse de retour, et observer où le processeur part.

from pwn import *

offset = 40
adresse_cible = 0x0000000000401156

payload = b"A" * offset
payload += p64(adresse_cible)

p = process("./vuln")
p.sendline(payload)
p.interactive()

p64() encode l'adresse sur 8 octets en little-endian, l'ordre dans lequel x86-64 stocke les adresses en mémoire. Le ret de traiter_nom() ne retourne plus vers main, mais saute à l'adresse indiquée. C'est exactement ce qu'on veut vérifier, la valeur de RIP n'est plus dictée par la logique du programme, mais par les octets qu'on a fournis.

Ce que ça montre

RIP (ou EIP en 32 bits) est le registre qui contient l'adresse de la prochaine instruction à exécuter. Le voir changer de valeur en fonction de l'entrée prouve qu'on a la main sur la suite du flot d'exécution. Dans le cadre de cet article, on s'arrête à cette constatation, l'exploitation à proprement parler sort du sujet.

Méthodologie d'analyse

Pour résumer, face à un binaire suspecté vulnérable à un stack buffer overflow, la démarche suit généralement ces étapes.

  1. Identifier la vulnérabilité, repérer une fonction dangereuse comme gets, strcpy ou scanf("%s", ...) sans limite.
  2. Reproduire le crash, envoyer une entrée volontairement trop longue et confirmer un SIGSEGV, idéalement avec un contrôle visible sur RIP.
  3. Trouver l'offset, utiliser un motif cyclique pour déterminer précisément le nombre d'octets avant l'adresse de retour.
  4. Vérifier la corruption, construire une entrée qui place une adresse choisie à l'emplacement de l'adresse de retour et observer le changement de RIP.

Ce fil conducteur, stack, overflow, crash, offset, RIP, résume bien la logique derrière la plupart des buffer overflows sur la pile.

Comment l'éviter

Toutes les techniques qu'on vient de voir reposent sur une seule et même faiblesse, une fonction qui écrit en mémoire sans jamais vérifier la taille de ce qu'elle reçoit. Corriger ça à la source rend tout le reste inopérant.

La première règle, c'est de bannir les fonctions qui ne prennent pas de limite de taille. gets() en est l'exemple le plus connu, elle a fini par être retirée du standard C. Pas de version sécurisée, pas de paramètre à ajouter, juste à ne plus l'utiliser.

char buffer[32];
fgets(buffer, sizeof(buffer), stdin);

fgets fait le même travail que gets, mais s'arrête proprement à la taille du buffer.

Même logique du côté de strcpy et strcat, qui copient sans regarder la taille de la destination. Leurs équivalents strncpy et strncat prennent un troisième argument pour fixer cette limite. Attention cela dit, strncpy ne garantit pas toujours une chaîne terminée par \0 si la source est plus longue que la limite donnée.

strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';

Sur le plan des outils, activer les protections du compilateur ne coûte rien et bloque une bonne partie des cas simples. Le stack canary (-fstack-protector-strong), le NX (activé par défaut sur la plupart des systèmes récents) et le PIE avec l'ASLR au niveau du système compliquent sérieusement la tâche même quand une faille existe encore dans le code. Ces protections ne remplacent jamais une vérification de taille correcte, elles viennent en renfort.

Enfin, des outils d'analyse statique comme cppcheck ou les warnings du compilateur (-Wall -Wextra, voire -Wstack-usage) permettent souvent de repérer ce genre de fonction dangereuse avant même de lancer le programme. Ça ne remplace pas la relecture, mais ça évite pas mal d'oublis bêtes.

Conclusion

On est parti d'un buffer un peu trop petit et on a fini par voir comment la moindre écriture non contrôlée en mémoire peut remonter jusqu'à des registres critiques du processeur. La façon dont la pile est organisée explique pourquoi un débordement touche d'abord des variables voisines, puis le frame pointer, puis l'adresse de retour, et c'est cette dernière qui donne la main sur le flot d'exécution du programme.

Voir aussi d'autres catégories...

Ces thèmes pourraient aussi t'intéresser.