Tcache poisoning: turning a use-after-free into an arbitrary write
Since glibc 2.26 (2017) every thread has a tcache: a set of
LIFO singly-linked lists, one per size class, that serve small
malloc/free calls without touching the arena locks. It
is blazing fast. It is also the structure with the fewest integrity checks in the
whole allocator, which is why it is the first target of every heap challenge.
How the freelist is built
When you free a small chunk, glibc pushes it onto the head of the list for its size. The pointer to the next free chunk is written into the first 8 bytes of the chunk's own data area — that is, into memory the application considered its own an instant earlier:
typedef struct tcache_entry {
struct tcache_entry *next; // first 8 bytes of the freed chunk
struct tcache_perthread_struct *key; // anti double-free
} tcache_entry;
A malloc of the same size unlinks the head and returns that chunk;
the new head becomes whatever next pointed at. No check that
next points to a real chunk, to a coherent size, or inside the heap.
That is the whole thing.
The primitive: forging next
If after a free you can still write into the chunk (a
use-after-free), you overwrite next with the address
where you want malloc to hand you memory. Two allocations later,
malloc returns it.
a = malloc(0x20);
free(a); // tcache[0x20] head -> a, a->next = NULL
// use-after-free: a is freed but we still hold the pointer
*(void **)a = target; // a->next = target (the address we want)
malloc(0x20); // unlinks a; the head is now 'target'
void *p = malloc(0x20); // returns 'target': arbitrary read/write
Now p points where you chose. Write to p and you are
writing to the arbitrary address. Typical targets: a function's GOT entry (redirect
it to system), __free_hook or __malloc_hook
in older glibc, a stack frame's return address if you have leaked the stack, or
__exit_funcs.
The 2020 brake: safe-linking
glibc 2.32 introduced safe-linking. The next
pointer is no longer stored in the clear, but masked with the address of its own
location:
#define PROTECT_PTR(pos, ptr) \
((tcache_entry *)((((size_t)(pos)) >> 12) ^ ((size_t)(ptr))))
#define REVEAL_PTR(ptr) PROTECT_PTR(&(ptr), ptr)
In practice: stored_next = (address_of_slot >> 12) XOR real_next.
If you write a raw address like before, when glibc unmasks it it gets garbage and
usually crashes. It also adds an alignment check: the revealed pointer must be
16-byte aligned.
Why it stops almost nobody
The masking uses a key that is not secret: it is
address_of_slot >> 12, the heap's page number. If you have a heap
leak — and in an exploit chain you usually do, often for free from the freed
tcache itself — you reconstruct the key and encrypt the fake pointer yourself:
# heap_leak = any address inside the heap
def protect(pos_addr, ptr):
return (pos_addr >> 12) ^ ptr
# we want a->next to reveal 'target'; a lives at heap_addr
forged = protect(heap_addr, target)
uaf_write(a, forged) # the tcache is poisoned just like before
The alignment constraint is more real: target must land on a
16-byte boundary. Often that means choosing an aligned target or nudging the
offset slightly. But it is not a defence, it is a speed bump.
The double-free and the anti-abuse key
glibc checks tcache double-frees with the key field: on free it
writes a value identifying that thread's tcache, and on the next free, if it finds
it unchanged, it suspects a double-free and aborts. The bypass is trivial once you
have a UAF: overwrite key with anything different before freeing
again, and the check does not fire.
The moral
The tcache is a textbook case of how optimising by removing checks
shifts the cost onto security. Safe-linking raised the bar — you now need a heap
leak where before you did not — but it did not close the bug class. The real
defence is upstream: do not free twice, null pointers after free, and compile with
allocator hardening (GLIBC_TUNABLES=glibc.malloc.check=3, or
quarantine allocators like hardened_malloc).