Tcache poisoning: trasformare un use-after-free in scrittura arbitraria
Dal glibc 2.26 (2017) ogni thread ha una tcache: un insieme di
liste concatenate LIFO, una per classe di dimensione, che servono le
malloc/free piccole senza toccare gli arena lock. È
velocissima. È anche la struttura con meno controlli di integrità di tutto
l'allocatore, ed è per questo che è il primo bersaglio di ogni sfida di heap.
Come è fatta la freelist
Quando liberi un chunk piccolo, glibc lo mette in testa alla lista della sua dimensione. Il puntatore al prossimo chunk libero viene scritto nei primi 8 byte dell'area dati del chunk stesso — cioè nella memoria che l'applicazione considerava sua un istante prima:
typedef struct tcache_entry {
struct tcache_entry *next; // primi 8 byte del chunk liberato
struct tcache_perthread_struct *key; // anti double-free
} tcache_entry;
Una malloc della stessa dimensione stacca la testa e restituisce
quel chunk; il nuovo capolista diventa ciò che next indicava. Nessun
controllo che next punti a un chunk vero, a una dimensione coerente,
o dentro l'heap. Qui sta tutto.
La primitiva: falsificare next
Se dopo una free riesci ancora a scrivere nel chunk (un
use-after-free), sovrascrivi next con l'indirizzo
dove vuoi che malloc ti consegni memoria. Due allocazioni dopo, malloc
te lo restituisce.
a = malloc(0x20);
free(a); // testa della tcache[0x20] -> a, a->next = NULL
// use-after-free: a è liberato ma abbiamo ancora il puntatore
*(void **)a = target; // a->next = target (l'indirizzo che vogliamo)
malloc(0x20); // stacca a; ora la testa è 'target'
void *p = malloc(0x20); // restituisce 'target': scrittura/lettura arbitraria
A questo punto p punta dove hai scelto. Scrivi su p e
stai scrivendo sull'indirizzo arbitrario. I bersagli tipici: la voce GOT di una
funzione (dirottala su system), __free_hook o
__malloc_hook nelle glibc più vecchie, il return address di uno stack
frame se hai leakato lo stack, o __exit_funcs.
Il freno del 2020: safe-linking
glibc 2.32 ha introdotto il safe-linking. Il puntatore
next non è più memorizzato in chiaro, ma mascherato con l'indirizzo
della sua stessa locazione:
#define PROTECT_PTR(pos, ptr) \
((tcache_entry *)((((size_t)(pos)) >> 12) ^ ((size_t)(ptr))))
#define REVEAL_PTR(ptr) PROTECT_PTR(&(ptr), ptr)
In pratica: next_memorizzato = (indirizzo_dello_slot >> 12) XOR
next_reale. Se provi a scrivere un indirizzo grezzo come prima, quando
glibc lo smaschera ottiene spazzatura e di solito crasha. Aggiunge anche un
controllo di allineamento: il puntatore rivelato deve essere allineato a 16 byte.
Perché non ferma quasi nessuno
Il maskaggio usa una chiave che non è segreta: è
indirizzo_dello_slot >> 12, cioè il numero di pagina dell'heap. Se
hai un leak dell'heap — e in una catena di exploit di solito ce l'hai, spesso
gratis proprio dalla tcache liberata — ricostruisci la chiave e cifri tu il
puntatore falso:
# heap_leak = un indirizzo qualsiasi dentro l'heap
def protect(pos_addr, ptr):
return (pos_addr >> 12) ^ ptr
# vogliamo che a->next riveli 'target'; a sta a heap_addr
forged = protect(heap_addr, target)
uaf_write(a, forged) # ora la tcache è avvelenata come prima
Il vincolo di allineamento è più reale: target deve finire su un
confine di 16 byte. Spesso significa scegliere un bersaglio allineato o
aggiustare l'offset di poco. Ma non è una difesa, è un dosso.
Il doppio-free e la chiave anti-abuso
glibc controlla i double-free sulla tcache con il campo key: alla
free scrive un valore che identifica la tcache di quel thread, e alla free
successiva se lo ritrova uguale sospetta un double-free e aborta. Il bypass è
banale una volta che hai una UAF: sovrascrivi key con qualunque cosa
diversa prima di liberare di nuovo, e il controllo non scatta.
La morale
La tcache è un caso di scuola di come ottimizzare rimuovendo controlli
sposti il costo sulla sicurezza. Il safe-linking ha alzato l'asticella —
adesso ti serve un leak dell'heap dove prima non serviva — ma non ha chiuso la
classe di bug. La difesa vera resta a monte: non liberare due volte, azzerare i
puntatori dopo la free, e compilare con hardening dell'allocatore
(GLIBC_TUNABLES=glibc.malloc.check=3, o allocatori con quarantena
come quello di hardened_malloc).