← Retour à Reverse Engineering

Le Buffer Overflow dans le tas

9 septembre 2026•18 min•Très difficile
Reverse EngineeringLinux

Débordement de tampon dans le tas ou Heap-based Buffer Overflow

Dans l'article précédent, on a vu comment un débordement sur la pile finit par écraser l'adresse de retour, ce qui donne la main sur le flot d'exécution. Sur le tas, ça ne se passe pas comme ça. Il n'y a pas de ret à détourner, pas de frame bien rangée et ce qui se fait écraser dépend entièrement de la façon dont les allocations ont été faites avant. C'est plus diffus, moins linéaire et souvent plus intéressant à analyser.

Le tas, c'est quoi au juste

Le tas est la zone mémoire utilisée pour les allocations dynamiques, celles qu'on ne connaît pas à la compilation. Si tu veux creuser la notion, tu peux te renseigner via le lien juste au-dessus.

Tout ce qui passe par malloc, calloc, realloc finit là et c'est free qui rend la mémoire. Sur la pile, la taille des variables locales est fixée à l'avance et la frame disparaît automatiquement quand la fonction retourne. Sur le tas, c'est le programme qui demande explicitement de la place et qui doit la rendre explicitement aussi.

C'est justement cette souplesse qui rend le tas intéressant côté sécurité. Puisqu'on demande de la mémoire à la volée, l'emplacement des buffers dépend de l'ordre des allocations et de ce que l'allocateur a décidé de réutiliser. Deux buffers alloués côte à côte peuvent très bien ne pas rester voisins après une libération et une nouvelle allocation.

Où se situe le tas dans le processus

Pour situer le tas dans l'ensemble, un processus Linux classique est organisé en plusieurs zones.

adresses hautes
+-----------------------------+
|           Stack             |
+-----------------------------+
|             ↓               |
|                             |
|             ↑               |
+-----------------------------+
|            Heap             |
+-----------------------------+
|         BSS / Data          |
+-----------------------------+
|            Code             |
+-----------------------------+
adresses basses

Le code contient les instructions du programme. La section data regroupe les variables globales et statiques initialisées, la BSS celles qui ne le sont pas. Le tas grandit vers les adresses hautes, à l'inverse de la pile et c'est là que vivent les allocations dynamiques.

Un buffer dans le tas, à quoi ça ressemble

Comparons rapidement deux façons de réserver 32 octets.

char buffer[32];              // pile
char *buffer = malloc(32);    // tas

Dans le premier cas, buffer est une variable locale, elle vit dans la frame de la fonction, elle disparaît au retour. Dans le second, buffer est un pointeur stocké quelque part (souvent sur la pile), les 32 octets qu'il pointe vivent dans le tas. Le pointeur et la donnée ne sont pas au même endroit et c'est une distinction qui compte dès qu'on parle de débordement.

Comment survient un Heap Buffer Overflow

Le principe est le même que sur la pile, une écriture qui dépasse la taille prévue. La différence, c'est ce qui se trouve juste après le buffer dans le tas.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char *a = malloc(32);
    char *b = malloc(32);

    strcpy(b, "Donnée sensible");
    printf("b avant : %s\n", b);

    memset(a, 'A', 64);

    printf("b après : %s\n", b);

    free(a);
    free(b);
    return 0;
}

a et b font 32 octets chacun. On écrit 64 octets dans a, soit deux fois sa taille. Comme les deux allocations sont faites l'une après l'autre, il y a de bonnes chances que b se retrouve juste après a en mémoire. Résultat : la seconde moitié de a écrase le contenu de b.

À l'exécution, b avant affiche donnée sensible et b après n'affiche plus grand-chose d'utile. Le buffer b n'a jamais été touché directement, c'est l'écriture dans a qui l'a corrompu.

Ce qui peut se faire écraser

Ce qu'on trouve après un buffer dans le tas dépend de ce que le programme y a mis. Dans l'ordre le plus courant, on tombe sur plusieurs catégories de cibles.

Les données d'un objet voisin

C'est le cas le plus simple à observer et celui qui sert de base à tout le reste. Deux structures allouées l'une après l'autre dans le tas, un débordement sur la première qui vient mordre sur la seconde. L'objet voisin change de valeur sans que le code l'ait jamais touché directement.

Les variables, structures et pointeurs

Une structure allouée dynamiquement peut contenir n'importe quoi, y compris des flags de contrôle ou des identifiants. Mais c'est le cas des pointeurs qui devient gênant. Un pointeur corrompu peut mener à écrire à un endroit complètement différent du buffer d'origine.

Les métadonnées de l'allocateur

Il faut savoir que malloc ne se contente pas de retourner des octets bruts. Il réserve en réalité un bloc un peu plus grand que demandé avec un en-tête qui décrit la taille du chunk et l'état du bloc voisin. Ces informations sont utilisées par free pour fusionner les blocs libérés entre eux. Si elles sont corrompues, free peut faire n'importe quoi.

Observer le tas sous GDB

GDB permet de voir l'état du tas à un instant donné. Sur un binaire compilé avec les symboles de debug, on peut lancer le programme, poser un breakpoint après les deux malloc et regarder les adresses retournées.

gcc -g -o heap heap.c
gdb ./heap
(gdb) break main
(gdb) run
(gdb) next

Une fois arrivé après les malloc, on inspecte les deux pointeurs.

(gdb) print a
$1 = 0x5555555592a0 "..."

(gdb) print b
$2 = 0x5555555592d0 "..."

Les deux adresses sont proches, a commence à 0x...92a0, b à 0x...92d0, soit 48 octets plus loin. malloc(32) réserve en réalité un chunk de 48 octets, ce qui explique l'écart. C'est cette différence entre taille demandée et taille réellement occupée qui rend les calculs d'offset un peu moins évidents que sur la pile.

Lire le contenu brut du tas

On peut examiner le contenu du tas avec x.

(gdb) x/96bx a
0x5555555592a0: 0x41 0x41 0x41 ...

Avant le débordement, on voit les métadonnées du chunk de a dans les premiers octets, puis les données. Après le memset, on voit une longue traînée de 0x41 qui dépasse allègrement la frontière entre a et b. En comparant les deux états, on voit exactement où commence b, combien d'octets ont débordé et ce qui a été touché.

Les extensions qui simplifient la vie

Il existe des extensions GDB comme pwndbg ou gef qui affichent directement la structure des chunks, les tailles, les flags, et repèrent les incohérences. C'est très pratique mais pas indispensable pour comprendre le principe.

Contrôler les données d'un objet voisin

Reprenons l'exemple, mais en rendant la corruption visible.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char nom[32];
    int admin;
} utilisateur;

int main(void) {
    utilisateur *u = malloc(sizeof(utilisateur));
    char *buffer = malloc(32);

    u->admin = 0;
    strcpy(u->nom, "Invite");

    printf("Admin avant : %d\n", u->admin);

    memset(buffer, 'A', 64);

    printf("Admin après : %d\n", u->admin);

    free(u);
    free(buffer);
    return 0;
}

Ici, u et buffer sont alloués dans cet ordre. Selon la disposition choisie par l'allocateur, buffer peut se retrouver après u en mémoire, ou l'inverse. Une bonne partie du travail d'analyse consiste justement à vérifier dans quel ordre les allocations se sont posées.

Si buffer suit u, alors écrire 64 octets dans buffer ne touche pas u, il déborde vers l'avant, sur les chunks suivants. Si c'est u qui suit buffer, alors le débordement de buffer remonte directement dans u->nom puis dans u->admin, qui passe de 0 à une valeur non nulle. Le flag d'admin n'a jamais été modifié par le code mais il a changé quand même.

C'est ce genre de corruption qu'on cherche à observer sur le tas. On ne détourne pas de registre ni d'adresse de retour, on modifie juste une donnée qui n'aurait jamais dû changer simplement parce qu'elle se trouvait juste à côté d'un buffer mal écrit.

Heap et stack, ce qui change vraiment

Un petit tableau pour fixer les idées.

StackHeap
Variables localesAllocations dynamiques
RBP, adresse de retourObjets, pointeurs, métadonnées
Frame structurée, ordre prévisibleOrganisation dépendante de l'allocateur
Stack canaryChecks spécifiques de glibc
RIP souvent au centreConséquences variées, parfois indirectes

Sur la pile, la disposition est relativement stable d'une exécution à l'autre et la cible finale est presque toujours l'adresse de retour. Sur le tas, ça dépend de l'ordre des malloc, des tailles demandées, de ce qui a été libéré entre-temps. Deux exécutions du même binaire peuvent donner des dispositions différentes si l'ASLR est actif ou si le programme alloue de façon dynamique.

Les protections côté heap

Les protections classiques vues dans l'article précédent (ASLR, NX, PIE) s'appliquent aussi ici, elles ne sont pas spécifiques au heap. Ce qui est propre au heap, ce sont les vérifications internes de l'allocateur. glibc moderne vérifie par exemple la cohérence des tailles de chunks lors d'un free, détecte certains débordements simples et a introduit le tcache qui a modifié pas mal de choses dans la façon dont les blocs sont réutilisés.

La démarche, résumée

Face à un binaire suspecté vulnérable à un heap buffer overflow, la démarche ressemble à celle du stack mais avec quelques étapes en plus.

  1. Identifier la vulnérabilité, repérer une écriture non bornée dans un buffer alloué dynamiquement.
  2. Localiser le buffer, retrouver les malloc qui l'entourent et l'ordre dans lequel ils sont appelés.
  3. Observer les allocations, vérifier sous GDB à quelles adresses les buffers ont été placés et dans quel ordre.
  4. Provoquer l'overflow, envoyer une entrée assez longue pour dépasser la taille allouée.
  5. Identifier ce qui est corrompu, comparer l'état du tas avant et après, repérer quel objet voisin ou quelle métadonnée a été touché.

Comment l'éviter

Comme pour la pile, la racine du problème est toujours la même, une écriture qui ne vérifie pas la taille de la destination. Les solutions sont plus ou moins identiques.

Le plus simple c'est d'éviter les fonctions qui ne vérifient pas la taille de ce qu'elles écrivent comme strcpy, strcat, sprintf ou gets. Sur le tas, on utilise plutôt strncpy, snprintf ou memcpy en précisant nous-mêmes la taille à ne pas dépasser.

Tableau des tailles de valeurs (LP64)

TypeTaille (bits)MinimumMaximum
char8-128127
unsigned char80255
short16-32 76832 767
unsigned short16065 535
int32-2 147 483 6482 147 483 647
unsigned int3204 294 967 295
long64-9 223 372 036 854 775 8089 223 372 036 854 775 807
unsigned long64018 446 744 073 709 551 615
long long64-9 223 372 036 854 775 8089 223 372 036 854 775 807
unsigned long long64018 446 744 073 709 551 615
float32-3,4 × 10³⁸ (approximatif)3,4 × 10³⁸ (approximatif)
double64-1,7 × 10³⁰⁸ (approximatif)1,7 × 10³⁰⁸ (approximatif)
long double128 (80 utiles)-1,1 × 10⁴⁹³² (approximatif)1,1 × 10⁴⁹³² (approximatif)

À noter : long fait 64 bits sous Linux 64 bits mais 32 bits sous Windows 64 bits. long long fait 64 bits dans les deux cas.

char *dest = malloc(32);
snprintf(dest, 32, "%s", source);

malloc(n * sizeof(int)) peut aussi poser problème si n vient d'une entrée utilisateur et qu'il est très grand. La multiplication peut dépasser la capacité maximale d'un entier et repartir de zéro, sans qu'aucune erreur ne s'affiche, ce qui donne un buffer beaucoup plus petit que ce que le code croit avoir alloué. C'est un bug classique, et souvent difficile à repérer à la simple lecture.

size_t n = lire_entree();
if (n > SIZE_MAX / sizeof(int)) return -1;
int *tab = malloc(n * sizeof(int));

Le vrai point à retenir c'est que sur le heap, la taille demandée à malloc et la taille réellement utilisable ne coïncident pas toujours, et que ce qui est écrit en trop ne disparaît jamais, ça finit toujours par écraser quelque chose d'autre, un buffer voisin, un pointeur, ou les métadonnées de l'allocateur. La bonne question à se poser n'est pas seulement combien on écrit, mais aussi ce qu'il y a juste après en mémoire.

En terme d'outils, -Wall -Wextra signale quelques cas, cppcheck en repère d'autres. Le plus efficace reste AddressSanitizer, activable avec -fsanitize=address, qui détecte à l'exécution les débordements heap, stack et globals, et indique précisément l'allocation et l'écriture fautives.

Conclusion

Avec la pile, un débordement finissait presque toujours par taper l'adresse de retour, c'était prévisible. Sur le tas il n'y a pas vraiment de cible fixe, tout dépend de l'ordre dans lequel les malloc ont été faits, de leur taille réelle et de ce que l'allocateur a fait des blocs libérés avant. Ça peut être un objet voisin, un pointeur, ou les métadonnées internes de l'allocateur.

La méthode reste la même que sur la pile, on regarde le tas sous GDB, on repère où commencent et finissent les blocs et on compare l'état avant et après le débordement.

Voir aussi d'autres catégories...

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