Case Study 35-2: Use-After-Free in a Simple Memory Allocator
Introduction
Use-after-free (UAF) vulnerabilities are now the dominant memory corruption exploitation technique. Stack overflows have been largely mitigated; heap corruption is harder to defend against automatically because it is fundamentally harder to detect. Understanding UAF at the assembly and heap layout level explains why these vulnerabilities are so dangerous and what defenses are effective against them.
This case study examines a UAF vulnerability in a simplified custom memory allocator. Custom allocators appear in high-performance servers, game engines, browsers, and kernel code — anywhere the overhead of malloc/free is unacceptable or their behavior is unpredictable. Custom allocators frequently lack the hardening of glibc malloc, making UAF bugs in them particularly dangerous.
🔐 Security Note: This analysis is for defensive purposes: understanding how UAF vulnerabilities arise and how they are exploited helps developers avoid them and helps security engineers detect and prevent them. The allocator examined is a simplified teaching example. Real-world UAF bugs in production software involve substantially more complexity.
The Vulnerable Custom Allocator
Consider a simple slab-style allocator for fixed-size connection objects in a server:
#define SLAB_SIZE 16
#define MAX_CONNECTIONS 256
typedef struct {
int fd;
uint32_t flags;
void (*handler)(int); /* function pointer: crucial for exploitation */
char data[4];
} Connection;
/* Free list is a linked list through the freed objects */
static Connection *free_list = NULL;
static Connection slab[MAX_CONNECTIONS];
Connection *alloc_connection(void) {
if (free_list != NULL) {
Connection *c = free_list;
free_list = *(Connection **)free_list; /* follow free list pointer */
return c;
}
/* ... fall back to slab allocation ... */
return NULL;
}
void free_connection(Connection *c) {
/* Store next free list pointer in the first 8 bytes of freed object */
*(Connection **)c = free_list;
free_list = c;
}
The Free List Layout in Memory
When a Connection object is freed, the allocator overwrites its first 8 bytes with the previous free list head pointer:
Active Connection:
┌──────────────┐
│ fd (int, 4B) │
├──────────────┤
│ flags (4B) │
├──────────────┤
│ handler ptr │ ← function pointer, 8 bytes
├──────────────┤
│ data[0..3] │
└──────────────┘
Freed Connection (same memory):
┌──────────────┐
│ next_free │ ← 8-byte pointer to next free Connection (or NULL)
│ (8 bytes) │ OVERWRITES fd+flags fields
├──────────────┤
│ handler ptr │ ← STILL CONTAINS OLD VALUE (not cleared!)
├──────────────┤
│ data[0..3] │
└──────────────┘
The critical observation: when freed, the first 8 bytes are overwritten with the free list pointer, but the handler function pointer at offset 8 is not cleared. Its value persists after free.
The Vulnerable Code Path
The server's connection handling:
void process_event(Connection *conn, int event) {
/* ... handle event ... */
if (conn->flags & FLAG_CLOSE) {
free_connection(conn); /* freed here */
}
}
void dispatch_all(void) {
for (int i = 0; i < n_connections; i++) {
Connection *conn = connections[i];
process_event(conn, events[i]); /* may free conn */
/* BUG: conn may be freed above, but we still use it here */
if (conn->handler) {
conn->handler(conn->fd); /* use-after-free! */
}
}
}
The bug: process_event may free the connection, but dispatch_all does not check for this and proceeds to call conn->handler on the freed memory.
Assembly-Level Analysis
The compiler generates this for the conn->handler call:
; dispatch_all inner loop, simplified
; conn is in rbx throughout the loop
mov rdi, [rbx] ; load conn->fd (first arg to handler)
mov rax, [rbx + 8] ; load conn->handler function pointer
test rax, rax ; if NULL, skip
jz .skip
call rax ; INDIRECT CALL through function pointer
.skip:
If conn has been freed and reallocated to hold a different Connection (or any other 16-byte object), then [rbx + 8] does not contain the handler we expect. It contains whatever the new allocation wrote there.
If an attacker can control what gets allocated at the freed address, they control the function pointer, and thus call rax jumps to attacker-controlled code.
The Exploitation Pattern
The three-step pattern for function-pointer-hijack via UAF:
Step 1: Trigger the Free
Trigger process_event with a connection that has FLAG_CLOSE set. The Connection object at address P is freed. The free list now has P at the head.
Before free:
connections[0] → P → Connection { fd=5, flags=FLAG_CLOSE, handler=0x401234, data=... }
After free_connection(P):
free_list → P → { next_free=NULL, (padding), handler=0x401234 still, data=... }
connections[0] still holds pointer P (BUG)
Step 2: Reclaim the Memory
Trigger a new alloc_connection(). The allocator returns P — the same address we just freed. The caller writes a new Connection object, potentially with a controlled handler field.
If the attacker controls the content of new allocations (e.g., the server reads connection parameters from the network), they write a chosen address at offset 8 of the new object — overwriting the handler field.
alloc_connection() returns P
New connection at P: { fd=6, flags=0, handler=ATTACKER_ADDRESS, data=... }
connections[0] still holds P (stale pointer)
Step 3: Trigger the Use
dispatch_all processes connections[0], which still holds the stale pointer P. It checks conn->handler and calls it. The call goes to ATTACKER_ADDRESS.
mov rax, [rbx + 8] ; loads ATTACKER_ADDRESS from P+8
call rax ; jumps to attacker-controlled location
With NX/DEP, ATTACKER_ADDRESS cannot be shellcode in data memory. But it can be the address of:
- A function that does something useful (like system())
- A ROP gadget (Chapter 37)
- An existing function with useful semantics
Defenses Against Use-After-Free
Immediate: Code Fix
The fix for this specific bug:
void dispatch_all(void) {
for (int i = 0; i < n_connections; i++) {
Connection *conn = connections[i];
handler_fn handler = conn->handler; /* save before potential free */
process_event(conn, events[i]);
/* Don't use conn after process_event — it may be freed */
/* Instead, we should not have called handler here at all.
Design fix: handler is called inside process_event */
}
}
The root fix is design: do not hold pointers to objects after they may be freed. Set pointers to NULL after freeing (c = NULL; free_connection(c_save);).
Allocator-Level: Poison Freed Memory
A hardened allocator fills freed memory with a distinctive pattern:
void free_connection(Connection *c) {
memset(c, 0xde, sizeof(Connection)); /* poison */
*(Connection **)c = free_list; /* overwrite first 8 bytes */
free_list = c;
}
Now conn->handler after free reads 0xdededededededede — an obviously invalid address that will fault on call. This converts a silent use-after-free into an immediate, detectable crash.
Runtime: AddressSanitizer
ASan maintains a shadow map of every heap allocation. On free, it marks the region as "freed." Any subsequent access triggers an immediate report:
==12345== ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000050
READ of size 8 at 0x602000000050 thread T0
#0 dispatch_all server.c:47
#1 main server.c:89
0x602000000050 is located 8 bytes inside of 16-byte region freed at:
#0 free_connection server.c:35
#1 process_event server.c:28
Type Safety: Pointer Nullification
After any free, null the pointer:
void safe_free_connection(Connection **cp) {
free_connection(*cp);
*cp = NULL;
}
Now conn->handler after free dereferences a NULL pointer — an immediate crash, not a silent use.
Language-Level: Use Rust or C++ Smart Pointers
In Rust, the borrow checker prevents this class of bug at compile time: you cannot hold a reference to memory that has been freed. In C++, std::shared_ptr and std::weak_ptr provide automatic lifetime tracking. These do not eliminate all UAF bugs, but they eliminate the most common patterns.
Why UAF is Dominant in Modern Exploitation
Stack overflows are largely mitigated: - Stack canaries detect overwrites before return - NX prevents shellcode execution from stack - ASLR makes address prediction hard
Heap UAF survives these mitigations better: - No canary on heap function pointers - Heap is non-executable, but the call goes to existing code (a function in the binary or library) - ASLR randomizes the heap base, but if the attacker can determine any heap address (information leak), they can calculate offsets - The allocator returns predictable addresses based on allocation patterns (heap grooming)
This is why browser exploit chains, kernel exploits, and server exploits in 2020-2025 are dominated by use-after-free and type confusion rather than stack overflow. The attack surface has shifted to wherever memory lifetime is complex — and complex memory lifetimes are unavoidable in real software.