hackweb
notes on hacking and technology
IT EN
main menu
user menu
you are not logged in

Tcache poisoning: turning a use-after-free into an arbitrary write

April 2, 2026 · 3 min read · #binary-exploitation #heap #glibc

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).


« back to home

latest posts
 
your IP address:
216.73.216.108
visitor #1
MOTD:
Every abstraction leaks somewhere.
Here we look at where.
topics