algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

tcache poisoning, explained like you'll have to use it

2026-01-184 min readheap pwn glibc

Every heap writeup I read when I started skipped the part I actually needed: the why. They'd say "now poison the tcache" like that was a sentence a human could parse. This is the post I wish I'd had — built around a deliberately vulnerable toy, not a real CVE, so you can reproduce every step yourself.

the allocator, in one paragraph#

glibc's malloc keeps freed chunks around to hand back fast. The fastest of these caches is the tcache: a per-thread, per-size singly-linked list. Free a chunk, it goes on the front of the list for its size. Allocate that size again, you get the front chunk back. The list is threaded through the freed chunks themselves — the first 8 bytes of a free chunk's data area hold the next pointer.

That last sentence is the entire vulnerability surface. The link lives inside memory you used to own. If you can write to a freed chunk, you can rewrite next.

the toy#

note.c — the vulnerable bitsc
char *notes[8];

void edit(int i, char *src, size_t n) {
    memcpy(notes[i], src, n);     // no check that notes[i] is still live
}

void delete(int i) {
    free(notes[i]);
    /* notes[i] = NULL;  <-- the missing line that makes this a UAF */
}

void *create(size_t n) {
    int i = next_free_slot();
    notes[i] = malloc(n);         // tcache hands back our poisoned chunk
    return notes[i];
}

The bug is boring and common: delete() frees but never nulls the pointer, so edit() can still write through it. That's a use-after-free, and a UAF on a tcache'd chunk is a write to the next link.

the poison#

The plan is three moves:

  1. Free a chunk so it lands in the tcache. Its next is whatever was there before (often NULL if the list was empty).
  2. Write through the dangling pointer, overwriting next with the address you want malloc to eventually return.
  3. Allocate twice. The first malloc hands back the chunk you freed; the second follows your forged next and hands back your chosen address as if it were a fresh allocation.
tcache list, before and after the overwritetxt
1
2
3
4
5
6
7
 before:   head -> [chunkA | next=0x0]

 overwrite chunkA.next with &target:
           head -> [chunkA | next=&target]

 malloc() #1 returns chunkA, list head becomes &target
 malloc() #2 returns &target   <-- arbitrary allocation

Now you own a pointer to an address you picked. Point it at a __free_hook, a GOT entry, a return address on a leaked stack — whatever your target's mitigations leave standing — and you've converted a missing NULL into control flow.1

then glibc moved the cheese#

Around glibc 2.32 the next pointer stopped being a raw address. Safe-linking XORs it with a value derived from the chunk's own location:

how safe-linking mangles the pointer (glibc)c
1
2
3
#define PROTECT_PTR(pos, ptr) \
    ((__typeof__(ptr)) ((((size_t) (pos)) >> 12) ^ ((size_t) (ptr))))
#define REVEAL_PTR(ptr)  PROTECT_PTR(&ptr, ptr)

So you can't just write &target anymore — you have to write target XOR (chunk_addr >> 12). Which means you need a heap leak first, to know chunk_addr. The primitive didn't die; it grew a prerequisite.

glibc tcache next what you need to poison
≤ 2.31 raw pointer just the write
≥ 2.32 XOR-mangled the write + a heap leak

alignment check, too

Modern glibc also verifies that chunks coming out of the tcache are 16-byte aligned. A forged next pointing at some random unaligned address will abort with "malloc(): unaligned tcache chunk detected". Pick targets that are already aligned, or align them yourself.

what to actually take away#

The mechanics matter less than the mental model: freed memory is still attacker-controlled memory, and allocators keep their bookkeeping inside it. Every heap technique — fastbin dup, house of spirit, the whole "house of" bestiary — is a variation on lying to the allocator about its own metadata.

Get this one cold on a toy you built yourself before you touch a real target. Reproduce it under different glibc versions and watch the recipe change. The day safe-linking stops feeling like a wall and starts feeling like one more XOR you account for is the day heap pwn clicks.


  1. Where you aim is the whole game and it's target-specific. The primitive is generic; turning it into a shell depends on what's writable and what's leaked. That's the part no tutorial can hand you.