Débordements de tas, use-after-free et double free
Comment les bugs mémoire du tas corrompent l'allocateur et l'état des objets, pourquoi le use-after-free est si dangereux, et les défenses qui les arrêtent.
Les bugs de tas sont la classe dominante de problèmes de sûreté mémoire exploitables dans les grandes bases de code C et C++ telles que les navigateurs, les noyaux et les piles multimédia. Ils sont plus difficiles à repérer que les débordements de pile parce que la mémoire corrompue appartient à des objets dont la durée de vie s'étend sur de nombreuses fonctions, et parce que la comptabilité interne de l'allocateur se trouve juste à côté des données utilisateur. Ce guide explique les trois bugs de tas classiques, ce qui tourne mal à l'intérieur de l'allocateur, et les défenses qui fonctionnent. Il suppose acquises les bases de l'agencement du tas vues dans comment un processus agence sa mémoire.
Trois bugs, une seule cause racine
| Bug | CWE | Ce qui se passe | Cause racine typique |
|---|---|---|---|
| Débordement de tampon de tas | CWE-122 | Une écriture dépasse la fin d'une allocation et déborde dans le chunk suivant | Contrôle de longueur manquant ou erroné |
| Use-after-free | CWE-416 | Un pointeur est utilisé après la libération de son objet | Propriété floue, callbacks, caches |
| Double free | CWE-415 | Le même pointeur est passé deux fois à free | Deux chemins de code croient tous deux posséder l'objet |
Les trois violent la même règle : un pointeur ne doit être utilisé que dans les limites et durant la durée de vie de l'objet qu'il désigne. C et C++ laissent les deux moitiés de cette règle au programmeur.
À l'intérieur de l'allocateur
Dans le ptmalloc2 de la glibc, chaque allocation est un chunk : un petit en-tête contenant la taille du chunk et des bits de drapeau, suivi des données utilisateur. Lorsqu'un chunk est libéré, l'allocateur réutilise la zone de données utilisateur pour stocker les pointeurs de liste qui le chaînent dans une liste de chunks libres. Pour les petites tailles, les chunks libérés vont d'abord dans un cache par thread (le tcache), puis dans les fast bins et dans les bins unsorted, small et large.
chunk A (utilisé) chunk B (libre, dans le tcache)
+------------------+ +------------------+
| size | P | | size | P |
+------------------+ +------------------+
| données utilis. | | next (masqué) |
| ... | | key |
| ... | | données périmées |
+------------------+ +------------------+
écriture au-delà de A ---> corrompt l'en-tête et le pointeur de liste de B
Deux conséquences pour les défenseurs :
- Un débordement hors d'un chunk atterrit dans l'en-tête du chunk suivant. Le champ de taille, les drapeaux et, si le voisin est libre, ses pointeurs de liste sont tous corruptibles.
- Un chunk libéré contient encore des données, et ses premiers octets deviennent des pointeurs de l'allocateur. Le code qui continue d'utiliser un objet libéré lit les métadonnées de l'allocateur comme s'il s'agissait de ses propres champs, et les écritures dessus corrompent la liste de chunks libres.
Historiquement, les listes de chunks libres corrompues permettaient à un attaquant de faire retourner par l'allocateur un pointeur vers une adresse arbitraire. Les mainteneurs de la glibc ont progressivement fermé ces voies, ce qui explique pourquoi la recherche moderne en exploitation se concentre sur la corruption au niveau des objets plutôt que sur les internes de l'allocateur. Nous n'irons pas plus loin dans les techniques ; ce qui compte ici, c'est ce que fait l'allocateur pour les arrêter.
Durcissement de l'allocateur dans la glibc
| Protection | Depuis | Ce qu'elle fait |
|---|---|---|
| Contrôles d'intégrité | De longue date, étendus au fil du temps | Valide les tailles et les liens de liste lors de malloc/free, avorte avec des messages tels que free(): invalid pointer ou malloc(): corrupted top size |
| Détection du double free dans le tcache | glibc 2.29 | Un champ key marque les chunks déjà dans le tcache, si bien qu'un second free avorte avec free(): double free detected in tcache 2 |
| Safe-linking | glibc 2.32 | Les pointeurs de liste libre sont masqués par XOR avec l'adresse où ils sont stockés (décalée), et l'alignement est vérifié, si bien qu'un pointeur corrompu est très probablement détecté |
Suppression des malloc_hooks | glibc 2.34 | Les pointeurs de fonction globaux __malloc_hook/__free_hook, une cible de longue date, n'existent plus |
Lorsqu'un de ces contrôles se déclenche, le processus avorte avec SIGABRT et un message sur stderr. Ce message est un cadeau : il vous dit que les métadonnées du tas sont corrompues, même si le bug s'est produit bien plus tôt. Le guide des rapports de crash recense les messages courants et ce qu'ils signifient habituellement.
Allocateurs durcis
Certains projets remplacent entièrement l'allocateur générique :
- Scudo, qui fait partie de compiler-rt de LLVM, est l'allocateur par défaut sur Android. Il ajoute des sommes de contrôle sur les en-têtes de chunks, un placement aléatoire et une quarantaine qui retarde la réutilisation de la mémoire libérée.
- hardened_malloc de GrapheneOS isole les classes de taille dans des régions séparées, utilise des pages de garde et met la mémoire à zéro lors du free.
- PartitionAlloc dans Chromium sépare les allocations par type ou partition, et MiraclePtr (
raw_ptr<T>) met en quarantaine la mémoire libérée tant que des références pendantes existent encore, spécifiquement pour neutraliser le use-after-free.
Ces choix échangent un peu de mémoire et de vitesse contre une corruption du tas bien plus difficile à exploiter. Pour les démons exposés au réseau qui parsent des entrées non fiables, l'échange vaut souvent la peine d'être évalué.
Le use-after-free en pratique
Le use-after-free ressemble rarement à free(p); p->x = 1; dans une seule fonction. Il se cache dans des durées de vie qui traversent les modules :
/* Vulnérable : la session est libérée en cas d'erreur, mais l'appelant l'utilise encore. */
int handle_message(struct conn *c, const struct msg *m) {
if (m->type == MSG_CLOSE) {
session_destroy(c->session); /* libère c->session ... */
return -1; /* ... mais c->session pointe encore dessus */
}
return session_process(c->session, m);
}
void on_error(struct conn *c) {
log_session(c->session); /* lit de la mémoire libérée */
}
La même forme apparaît avec les boucles d'événements (un callback se déclenche après la libération de son contexte), les caches (une entrée est évincée alors qu'un lecteur la détient), les itérateurs (un conteneur est modifié pendant l'itération et se réalloue) et les erreurs de comptage de références (un release de trop).
Corriger la propriété, pas les symptômes
/* Corrigé : la destruction efface toute référence propriétaire en un seul endroit. */
void conn_close_session(struct conn *c) {
session_destroy(c->session);
c->session = NULL; /* les utilisations ultérieures échouent bruyamment */
}
int handle_message(struct conn *c, const struct msg *m) {
if (m->type == MSG_CLOSE) {
conn_close_session(c);
return -1;
}
if (c->session == NULL) return -1;
return session_process(c->session, m);
}
Les corrections durables découlent d'un modèle de propriété clair :
- Un propriétaire par objet. Documentez qui le libère, et faites passer la destruction par une fonction qui efface aussi le pointeur du propriétaire.
- En C++, encodez la propriété dans les types.
std::unique_ptrpour la propriété unique,std::shared_ptr/std::weak_ptrquand les durées de vie se chevauchent réellement, et ne stockez jamais un pointeur brut qui survit à son propriétaire. - Invalidez lors du free. Mettre à NULL le pointeur propriétaire transforme un UAF silencieux en un crash déterministe sur ce chemin.
- Méfiez-vous de l'invalidation des itérateurs et des références. Faire croître un
std::vectorinvalide les pointeurs vers son contenu ;reallocen C aussi. - Envisagez les langages sûrs pour la mémoire pour les composants où les durées de vie sont complexes. Le borrow checker de Rust rejette toute cette classe à la compilation ; voir langages sûrs pour la mémoire.
Double free
Un double free survient lorsque deux chemins de code croient tous deux posséder un objet, typiquement un chemin d'erreur et un chemin de nettoyage :
/* Vulnérable : buf est libéré en cas d'erreur, puis à nouveau par le nettoyage commun. */
char *buf = malloc(len);
if (read_all(fd, buf, len) < 0) {
free(buf);
goto out;
}
...
out:
free(buf); /* second free sur le chemin d'erreur */
La correction est un point de nettoyage unique et la mise à NULL après free, qui rend le second free(NULL) inoffensif :
out:
free(buf);
buf = NULL;
Détecter les bugs de tas
AddressSanitizer est le cheval de trait. Il entoure chaque allocation de tas de zones rouges (redzones), et il garde la mémoire libérée en quarantaine au lieu de la réutiliser immédiatement, si bien qu'un accès ultérieur par un pointeur pendant touche une mémoire empoisonnée et est signalé avec trois traces de pile : où l'accès fautif s'est produit, où la mémoire a été libérée, et où elle a été allouée. Cette troisième trace est souvent la voie la plus rapide vers la cause racine. Voir AddressSanitizer, UBSan et compagnie pour savoir lire ces rapports.
| Outil | Débordement de tas | Use-after-free | Double free | Surcoût | Où l'utiliser |
|---|---|---|---|---|---|
| ASan | Oui | Oui (dans la fenêtre de quarantaine) | Oui | ~2x CPU, 2–3x mémoire | Tests, CI, fuzzing |
| HWASan (AArch64) | Oui, probabiliste | Oui, probabiliste | Oui | Moins de mémoire qu'ASan | Tests et builds dogfood sur arm64 |
| Valgrind Memcheck | Oui | Oui | Oui | 10–50x plus lent | Débogage sans recompilation |
| GWP-ASan | Échantillonné | Échantillonné | Échantillonné | Négligeable | Production |
| Contrôles glibc | Certains cas de métadonnées | Rarement | Cas tcache et fastbin | Aucun surcoût | Toujours actif |
Associez ASan au fuzzing guidé par la couverture : les fuzzers sont particulièrement doués pour atteindre les chemins d'erreur inhabituels où se cachent les double free et les bugs de durée de vie.
Points clés à retenir
- Les débordements de tas, le use-after-free et le double free brisent tous la règle selon laquelle les pointeurs ne sont valides que dans les limites et la durée de vie d'un objet.
- Les métadonnées de l'allocateur se trouvent à côté des données utilisateur, si bien que les bugs de tas peuvent corrompre l'allocateur lui-même ; les contrôles d'intégrité et le safe-linking de la glibc en détectent une grande partie et avortent.
- Le use-after-free est un problème de durée de vie et de propriété. Corrigez-le avec une propriété unique, l'invalidation lors du free et, quand c'est possible, des langages ou des types qui encodent la propriété.
- Exécutez les tests et les fuzzers sous ASan, envisagez un allocateur durci pour les services exposés, et traitez les messages d'abort de l'allocateur en production comme une corruption mémoire confirmée.