Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Heap exploitation attacks the dynamic memory allocator itself. Where a stack overflow corrupts a return address in a predictable frame, heap bugs corrupt allocator metadata or leave dangling pointers, and the attacker turns the allocator’s own bookkeeping into a write primitive. Two of the most common and productive primitives on modern Linux are the use-after-free (UAF) — using a pointer after the object it referenced has been freed — and tcache poisoning, which abuses glibc’s per-thread cache free-list so that a later malloc returns a chunk at an address the attacker chose. Together they convert a lifetime bug into an arbitrary allocation, and an arbitrary allocation is a general read/write primitive.
The relevant allocator here is glibc’s ptmalloc, and specifically the tcache (thread-local cache) that glibc has used by default since version 2.26 (2017). The tcache was introduced for speed, but its simple, lightly checked, singly-linked design made several classic techniques dramatically easier — which is why later glibc releases added targeted hardening (a double-free key in 2.29, pointer obfuscation via safe-linking in 2.32). Understanding the tcache free-list layout, exactly what a free-then-use lets you corrupt, and what the modern checks do (and do not) prevent is the core of contemporary heap exploitation.
Attack Prerequisites
Heap exploitation is situational — it depends on allocation sizes, the order of frees, and what the program does with freed pointers. Typical requirements:
- A memory-lifetime bug — a UAF (dangling pointer used after
free), a double-free, or a heap overflow that reaches an adjacent chunk’s metadata. - Attacker influence over freed-chunk contents — after a
free, the ability to write into that region (via the dangling pointer or a reallocated alias) so you can overwrite the free-listnextpointer. - Control of allocation sizes so freed chunks land in the tcache bin you intend to poison (tcache serves chunks up to size
0x410). - A heap leak on modern glibc (>= 2.32) — safe-linking obfuscates the
nextpointer with the chunk’s own address, so poisoning to an arbitrary location requires knowing a heap address first.
How It Works
The tcache keeps, per thread, an array of bins indexed by chunk size (64 bins by default), each a singly-linked LIFO free-list holding up to tcache_count = 7 chunks of that size. When you free a small chunk, glibc pushes it onto the front of its size’s tcache bin; when you malloc that size, glibc pops the front chunk off. The link is stored *inside the freed chunk’s own user data*: the first 8 bytes of a freed chunk become the next pointer to the following free chunk (the tcache_entry->next field), and the second 8 bytes hold a key field used for double-free detection. Crucially, the classic tcache path performs far fewer integrity checks than the older fastbin/smallbin paths — it does not, for instance, verify the size of the chunk a next pointer points to.
Tcache poisoning exploits that next pointer. If a UAF or overflow lets you overwrite the next field of a chunk sitting in a tcache bin, you have told the allocator to link to an address of your choosing. The next two allocations of that size then return: first the legitimate chunk you corrupted, and second the arbitrary address you wrote into next. malloc hands you a pointer into, say, a GOT entry, a __free_hook/__malloc_hook (on glibc where those still exist), a stack location, or another object’s function pointer — after which a normal write through the returned pointer becomes an arbitrary write. Two allocations and one controlled next is the whole technique.
Double-free is the other classic trigger. Freeing the same chunk twice places it in the bin twice, so it can be handed out by two separate mallocs while the attacker still holds a reference — an aliasing condition that also leads to controlling a next pointer. glibc 2.29 added a specific defense: on free, each tcache entry’s key field is set (in modern glibc to a randomized per-thread tcache_key; in 2.29 it was the address of the tcache_perthread_struct), and if a chunk being freed already carries that key, glibc walks the bin and aborts with “free(): double free detected in tcache 2”. Bypassing it means clearing or corrupting the key before the second free (which a UAF write can do), so the check raises the bar without closing the door.
Vulnerable Code: A Use-After-Free
The essential UAF is a pointer that is freed but not cleared, then used again. Because glibc caches the freed chunk and reissues it, the dangling pointer and a freshly malloc‘d object can alias the same memory:
#include <stdlib.h>
#include <string.h>
struct obj { void (*handler)(void); char name[24]; };
void bug(void) {
struct obj *p = malloc(sizeof *p);
free(p); // chunk goes to a tcache bin...
// BUG: 'p' is never set to NULL -> dangling pointer
// Attacker allocates the same size; glibc returns the SAME chunk,
// and can write attacker data over what 'p' still points at:
char *q = malloc(sizeof(struct obj));
strcpy(q, "AAAAAAAAAAAAAAAAAAAAAAAA"); // overwrites p->handler etc.
p->handler(); // USE-AFTER-FREE: calls attacker-controlled ptr
}
CThe double-free variant is the same missing p = NULL, exercised twice; the freed chunk’s next/key fields are what the allocator (and the attacker) then manipulate:
char *a = malloc(0x20);
free(a);
free(a); // DOUBLE FREE: 'a' pushed to the same tcache bin twice
// (glibc >= 2.29 aborts here IF a->key still equals tcache_key;
// a UAF write that clears 'key' first evades the check)
CWalkthrough: Tcache Poisoning to an Arbitrary Chunk
The goal is to make malloc return a pointer to a target address. Inspect the heap and bins in gdb+pwndbg to see the free-list forming and the next pointer you will overwrite:
gdb -q ./heap_target
pwndbg> heap # list chunks and their sizes/flags
pwndbg> bins # show tcache/fast/small bins and their linked order
pwndbg> vis_heap_chunks # visualize chunk layout, fd/next pointers inline
pwndbg> tcache # dump the tcache_perthread_struct (counts + entries)BashThe exploitation sequence with a UAF write primitive is: free a chunk so it enters the tcache bin, overwrite its next with the target address through the dangling pointer, then allocate twice. On glibc >= 2.32 you must mangle the target with safe-linking first, because the stored value is next XOR (chunk_address >> 12) — hence the required heap leak:
from pwn import *
# --- helpers wrapping the target's menu (alloc/free/edit) ---
def alloc(i, data=b''): ... # request a chunk of the poisoned size
def free(i): ... # free index i (leaves dangling ptr = the bug)
def edit(i, data): ... # write into a freed chunk (the UAF write)
target = 0x404060 # e.g. a GOT entry or __free_hook to overwrite
# Safe-linking (glibc >= 2.32): stored next = ptr XOR (chunk_addr >> 12).
# 'heap_leak' is a known chunk address obtained from an earlier info leak.
def protect(pos, ptr):
return ptr ^ (pos >> 12)
alloc(0, b'victim') # index 0
free(0) # chunk 0 -> tcache[size], next currently = 0
# Overwrite chunk 0's 'next' with the (mangled) target via the UAF write.
edit(0, p64(protect(heap_leak, target)))
alloc(1, b'padding') # pops chunk 0 off the bin
evil = alloc(2, p64(0xdeadbeef)) # pops the ARBITRARY chunk == 'target'
# the write inside alloc(2) lands at 'target' -> arbitrary write achieved
PythonOnce malloc returns a pointer to a chosen address, the follow-on depends on the target: overwriting a GOT entry (Partial RELRO) or a live function pointer redirects a subsequent call to system/a one_gadget; on older glibc, overwriting __free_hook with system and then freeing a chunk whose data is "/bin/sh" yields a shell. Note that glibc 2.34 removed __malloc_hook/__free_hook, so modern targets pivot to GOT/PLT, _IO_FILE structures, or other writable function pointers instead.
Note: Safe-linking (glibc >= 2.32) is often misdescribed as encryption; it is a cheap obfuscation:
PROTECT_PTRXORs thenextpointer with the storing chunk’s address shifted right by 12 (page-granular). It has two practical consequences — you need a heap address leak to forge a validnext, and the demangled pointer must be 16-byte aligned ormallocaborts with a misalignment check. Against the *first* chunk freed into an empty bin the stored value is0 XOR (addr>>12), which itself leaksaddr>>12if you can read it back — a common way to recover the heap base.
Opsec: Heap technique choice is tightly coupled to the exact glibc version: tcache_count, the double-free key, safe-linking, and the removal of malloc/free hooks all changed across 2.26 -> 2.34. Fingerprint the target libc before choosing a technique; a chain written for 2.27 (no key, no safe-linking, hooks present) will simply abort on 2.35. In gdb,
binsandvis_heap_chunksafter each step are the fastest way to confirm the bin state matches your mental model before committing the poisoning write.
Detection and Defense
Heap corruption is prevented primarily by memory-safe object lifetimes and by keeping the hardened allocator checks intact; residual bugs are caught by sanitizers and telemetry:
- Null out freed pointers (
free(p); p = NULL;) and prefer ownership models that make UAF structurally impossible; in C++ use smart pointers, in new code prefer memory-safe languages for the highest-risk surfaces. - Keep modern glibc — the tcache double-free key (>= 2.29) and safe-linking (>= 2.32) raise the cost of poisoning and double-free substantially; do not run ancient libc on exposed services.
- Full RELRO removes the GOT as a write target for the arbitrary-write stage; combine with PIE + ASLR so the attacker also needs leaks.
- Test with sanitizers — AddressSanitizer and Valgrind/Memcheck detect UAF, double-free, and heap overflow deterministically in CI; hardened allocators (glibc
MALLOC_CHECK_, GWP-ASan, scudo) catch a fraction at runtime. - Detection — glibc’s own aborts (“double free detected in tcache 2”, “malloc(): unaligned tcache chunk detected”, “corrupted top size”) are high-signal crash strings; EDR/heap-integrity monitoring and core-dump analysis surface exploitation attempts in production.
Real-World Impact
Use-after-free is one of the most prevalent memory-safety bug classes in large C/C++ codebases — browsers, language runtimes, kernels, and image/font parsers — and consistently ranks near the top of industry weakness lists such as the CWE Top 25 (UAF is CWE-416, double-free CWE-415). tcache-based techniques are the default toolkit for glibc heap challenges in CTFs and for userland Linux exploitation research, and the visible glibc hardening timeline (keys, safe-linking, hook removal) is a direct response to how effective tcache poisoning proved to be after 2.26.
Conclusion
Heap exploitation converts a lifetime or metadata bug into control of the allocator. A use-after-free lets a dangling pointer alias attacker-controlled memory; tcache poisoning overwrites a freed chunk’s singly-linked next pointer so two allocations later malloc returns an arbitrary chunk, giving an arbitrary write. Modern glibc fights back with the double-free key and safe-linking (which forces a heap leak and enforces alignment), and newer releases removed the malloc/free hooks — so real exploitation now pairs a precise understanding of the free-list with a leak and a careful, version-matched target choice.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments