Heap Exploitation
Stack overflows get all the headlines, but Use-After-Free (CWE-416) ranks #4 on the 2023 CWE Top 25 — with 44 known-exploited CVEs, more than SQL injection. The heap is where real programs keep their interesting data: dynamically allocated strings, C++ objects, structs full of function pointers. Learning to attack it is learning to attack the real world.
The heap vs. the stack
Before diving into attacks, make sure the terrain is clear.
| Property | Stack | Heap |
|---|---|---|
| Lifetime | Tied to the function call frame | Controlled explicitly by the programmer (malloc / free) |
| Addressing | %esp/%ebp + fixed offset |
Pointer returned by malloc |
| Managed by | Compiler / CPU | A user-space allocator library |
| Protection | Stack canaries guard the return address | No canary; no return address to protect |
| What lives there | Local variables, saved registers, return address | Dynamically-sized objects, structs, C++ objects |
The heap sits above the BSS/data segments in the Linux 32-bit address space and grows upward (toward higher addresses), while the stack grows downward from the top of user space.
High address
[ kernel space ]
[ stack (grows ↓) ]
...
[ heap (grows ↑) ]
[ BSS / data / text ]
Low address
malloc() is responsible for requesting pages from the OS (brk/mmap) and carving them into pieces the program can use.
How glibc malloc works: chunks and bins
On Linux, malloc is implemented by ptmalloc (pthreads malloc), an adaptation of Doug Lea's dlmalloc enhanced for multi-threading. Other implementations include Google's tcmalloc and Facebook's jemalloc, but ptmalloc is what you get on any standard Ubuntu/Debian system.
The malloc_chunk header
The basic unit ptmalloc manages is a chunk. Every allocation is wrapped in a small header:
struct malloc_chunk {
size_t prev_size; // size of the PREVIOUS adjacent chunk (when that chunk is free)
size_t size; // size of THIS chunk; low bits are flag bits
struct malloc_chunk *fd; // forward pointer — only used when chunk is FREE
struct malloc_chunk *bk; // back pointer — only used when chunk is FREE
};
Key details:
prev_sizerecords the size of the physically adjacent chunk before this one in memory. It is valid only when that previous chunk is currently free (so malloc can navigate backward to coalesce).sizestores the chunk's total size. The three least-significant bits are used as flag bits; the most important is bit 0 (PREV_IN_USE): if set, the chunk immediately before this one in memory is currently allocated.fd/bk(forward/backward pointers) form a doubly-linked list of free chunks. These fields overlap the user data area — malloc reuses that space to store list links because no live data occupies a free chunk.
When you call malloc(n), the pointer you receive points to the fd field (the first usable byte), not to the prev_size field. The header is just before the pointer you hold.
Free lists (bins)
ptmalloc keeps several bins — categorized free-chunk lists — to serve allocations quickly:
- Fast bins: singly-linked lists of small chunks (up to ~128 bytes on 64-bit). Freed fast chunks go here immediately, without coalescing. They are returned LIFO.
- Unsorted bin: a single doubly-linked bin that acts as a staging area. Any chunk that is not a fast chunk gets placed here first on
free(), giving a chance for reuse before sorting. - Small / large bins: sorted by size; used once the unsorted bin is drained.
First-fit behavior: when you free() a normal chunk and then malloc() something of equal or smaller size, ptmalloc will hand back the same (or nearby) address from the unsorted bin.
char *a = malloc(300); // 0x***010
char *b = malloc(250); // 0x***150 (guards against coalescing with top)
free(a);
a = malloc(250); // 0x***010 — same address! first-fit from unsorted bin
Attack 1: Heap buffer overflow
Buffer overflows work identically on the heap as on the stack — an unchecked write goes past the end of the allocated region. The difference is what you can hit:
- No return address nearby: stack canaries and stack-based CFI don't apply. There is no
%eip-bearing return address adjacent to the buffer. - Adjacent objects on the heap: right after your buffer sits another
malloc_chunk— its header, and then whatever the next object stores (could be astructcontaining a function pointer).
The classic function-pointer overwrite
Consider two consecutive malloc calls:
struct data { char name[64]; }; // buffer; no function pointer
struct fp { void (*fp)(); // function pointer
char __pad[64 - sizeof(unsigned long)]; };
struct data *d = malloc(sizeof(struct data));
struct fp *f = malloc(sizeof(struct fp));
f->fp = nowinner;
strcpy(d->name, argv[1]); // no bounds check!
// ...
f->fp(); // calls wherever fp points
Because d and f are allocated consecutively, d->name (64 bytes) sits immediately before f->fp in memory. Writing more than 64 bytes through strcpy overwrites f->fp with the attacker's chosen address — exactly the function-pointer hijack we saw on the stack, but now via heap adjacency.
This is why heap canaries don't exist: the threat model is different. There is no single critical slot like a return address; the dangerous data is application-specific (
structfields, vtable pointers, etc.).
Attack 2: Use-After-Free (UAF)
A use-after-free bug has three steps:
- Allocate memory and store a pointer to it.
free()the memory — but keep the pointer (a dangling pointer).- Later, use the dangling pointer to read or write.
char *data = malloc(20);
strcpy(data, "Hello, World!");
free(data);
// data is now a dangling pointer — the memory belongs to malloc again
strcpy(data, "New Data"); // undefined behavior: writing freed memory
printf("%s\n", data); // undefined behavior: reading freed memory
Why UAF is exploitable
After free(data), ptmalloc may hand that same memory to the next malloc() call for a different object. If the attacker can control what gets placed in that recycled slot, they control the memory that the old dangling pointer sees. For example:
- Program frees a
struct userthat contains anadminflag. - Attacker triggers a second allocation of the same size, gets the same address, and fills it with controlled data — including a non-zero
adminbyte. - Program reads through the old dangling pointer and sees
admin = 1.
Combined with objects that contain function pointers, UAF becomes a control-flow hijack primitive.
Defending against UAF
The canonical fix is simple but often forgotten:
free(data);
data = NULL; // null the pointer immediately; any later dereference segfaults safely
Tooling: Clang/LLVM's MemorySanitizer and AddressSanitizer instrument the binary to detect accesses to freed memory at run time — essential for finding UAF bugs during testing.
Attack 3: Corrupting malloc metadata (the free-list attack)
The most powerful heap attacks corrupt the allocator's own bookkeeping. Because fd and bk live inside the chunk data area when the chunk is free, a heap overflow that reaches a free chunk can overwrite those pointers.
The unsafe-unlink primitive (classic dlmalloc)
When two adjacent free chunks are coalesced, ptmalloc does roughly:
// remove chunk P from its doubly-linked bin list
P->bk->fd = P->fd;
P->fd->bk = P->bk;
If an attacker has corrupted P->fd and P->bk with chosen addresses, those two writes become:
*(attacker_addr_A + 8) = attacker_addr_B
*(attacker_addr_B + 12) = attacker_addr_A
This is an arbitrary-write primitive: write a chosen value to a chosen address. Classically used to overwrite a GOT entry (Global Offset Table) with the address of shellcode or another function — turning a heap metadata corruption into a full control-flow hijack.
Safe-unlinking and modern mitigations
Modern ptmalloc added the safe-unlink check: before removing a chunk from a doubly-linked bin, it verifies P->fd->bk == P and P->bk->fd == P. If either check fails, the allocator aborts. This makes naive fd/bk corruption much harder, though not impossible (e.g., if the attacker can also set up a controlled pointer that passes the check).
Additional protections:
- ASLR randomizes the heap base address, making it harder to predict where allocations land.
- Allocator hardening in newer glibc versions adds more integrity checks (e.g., tcache pointer encryption in glibc ≥ 2.32).
- Fast-bin double-free detection: freeing the same chunk twice to the same fast bin is checked and causes abort.
Heap vs. stack overflow: a quick comparison
| Aspect | Stack overflow | Heap overflow |
|---|---|---|
| Target data | Saved return address, saved %ebp |
Adjacent objects, struct fields, function pointers, allocator metadata |
| Classic defense | Stack canary + NX | Safe-unlink, allocator checks, ASLR |
| Trigger | Write past a local buffer | Write past a malloc'd buffer |
| Result | Control %eip directly via ret |
Corrupt adjacent data; may need multiple steps to reach %eip |
Key takeaways
- The heap is dynamically allocated memory managed by
malloc/free; ptmalloc uses chunks with inline headers (prev_size,size,fd,bk) to track allocations. - Free chunks live in bins (fast bins, unsorted bin, small/large bins); first-fit behavior means a freed chunk can be immediately reused by the next compatible
malloc. - A heap buffer overflow can corrupt adjacent chunks or adjacent
structfields — including function pointers — without any return address or stack canary in the way. - Use-after-free (UAF) lets an attacker recycle freed memory for a new object whose contents they control, then have old code operate on that attacker-controlled data.
- Metadata corruption (overwriting
fd/bkin a free chunk) can produce an arbitrary-write primitive through the unlink operation; modern ptmalloc's safe-unlink check makes naive versions harder. - Defenses layer: safe-unlinking integrity checks, ASLR (heap base randomization), nulling pointers after
free(), and memory sanitizers (AddressSanitizer, MemorySanitizer). - Use-After-Free is CWE-416, rank #4 on the 2023 CWE Top 25 — a modern, actively exploited class, not a historical curiosity.