Heap Overflows, Use-After-Free and Double Free
How heap memory bugs corrupt allocator metadata and object state, why use-after-free is so dangerous, and the allocator hardening, sanitizers and ownership rules that stop them.
Heap bugs are the dominant class of exploitable memory-safety issues in large C and C++ code bases such as browsers, kernels and media stacks. They are harder to spot than stack overflows because the corrupted memory belongs to objects whose lifetime spans many functions, and because the allocator's own bookkeeping sits right next to user data. This guide explains the three classic heap bugs, what goes wrong inside the allocator, and the defences that work. It assumes the heap layout basics from how a process lays out memory.
Three bugs, one root cause
| Bug | CWE | What happens | Typical root cause |
|---|---|---|---|
| Heap buffer overflow | CWE-122 | A write runs past the end of an allocation into the next chunk | Missing or wrong length check |
| Use-after-free | CWE-416 | A pointer is used after its object was freed | Unclear ownership, callbacks, caches |
| Double free | CWE-415 | The same pointer is passed to free twice | Two code paths both believe they own the object |
All three violate the same rule: a pointer must only be used within the bounds and the lifetime of the object it points to. C and C++ leave both halves of that rule to the programmer.
Inside the allocator
In glibc's ptmalloc2, every allocation is a chunk: a small header holding the chunk size and flag bits, followed by the user data. When a chunk is freed, the allocator reuses the user-data area to store list pointers that link it into a free list. For small sizes, freed chunks first go to a per-thread cache (the tcache), then to fast bins and to the unsorted, small and large bins.
chunk A (in use) chunk B (free, in tcache)
+------------------+ +------------------+
| size | P | | size | P |
+------------------+ +------------------+
| user data | | next (mangled) |
| ... | | key |
| ... | | stale user data |
+------------------+ +------------------+
write past end of A ---> corrupts B's header and list pointer
Two consequences follow for defenders:
- An overflow out of one chunk lands in the next chunk's header. The size field, the flags and, if the neighbour is free, its list pointers are all corruptible.
- A freed chunk still holds data, and its first bytes become allocator pointers. Code that keeps using a freed object reads allocator metadata as if it were its own fields, and writes to it corrupt the free list.
Historically, corrupted free lists let an attacker make the allocator return a pointer to an arbitrary address. The glibc maintainers have steadily closed those paths, which is why modern exploitation research focuses on object-level corruption rather than allocator internals. We will not go further into techniques; what matters here is what the allocator does to stop them.
Allocator hardening in glibc
| Protection | Since | What it does |
|---|---|---|
| Integrity checks | Long-standing, expanded over time | Validates sizes and list links on malloc/free, aborts with messages such as free(): invalid pointer or malloc(): corrupted top size |
| tcache double-free detection | glibc 2.29 | A key field marks chunks already in the tcache, so a second free aborts with free(): double free detected in tcache 2 |
| Safe-linking | glibc 2.32 | Free-list pointers are XOR-mangled with the address they are stored at (shifted), and alignment is checked, so a corrupted pointer is very likely to be detected |
malloc_hooks removed | glibc 2.34 | The global __malloc_hook/__free_hook function pointers, a long-standing target, no longer exist |
When one of these checks fires, the process aborts with SIGABRT and a message on stderr. That message is a gift: it tells you heap metadata is corrupted, even if the bug happened much earlier. The crash report guide lists the common messages and what they usually mean.
Hardened allocators
Some projects replace the general-purpose allocator entirely:
- Scudo, part of LLVM's compiler-rt, is the default allocator on Android. It adds chunk header checksums, randomized placement and a quarantine that delays reuse of freed memory.
- hardened_malloc from GrapheneOS isolates size classes in separate regions, uses guard pages and zeroes memory on free.
- PartitionAlloc in Chromium separates allocations by type or partition, and MiraclePtr (
raw_ptr<T>) quarantines freed memory while dangling references to it still exist, specifically to neutralise use-after-free.
These trade some memory and speed for making heap corruption much harder to exploit. For network-facing daemons that parse untrusted input, the trade is often worth evaluating.
Use-after-free in practice
Use-after-free rarely looks like free(p); p->x = 1; in one function. It hides in lifetimes that cross modules:
/* Vulnerable: the session is freed on error, but the caller still uses it. */
int handle_message(struct conn *c, const struct msg *m) {
if (m->type == MSG_CLOSE) {
session_destroy(c->session); /* frees c->session ... */
return -1; /* ... but c->session still points to it */
}
return session_process(c->session, m);
}
void on_error(struct conn *c) {
log_session(c->session); /* reads freed memory */
}
The same shape appears with event loops (a callback fires after its context was freed), caches (an entry is evicted while a reader holds it), iterators (a container is modified during iteration and reallocates) and reference counting errors (one release too many).
Fixing ownership, not symptoms
/* Fixed: destruction clears every owning reference in one place. */
void conn_close_session(struct conn *c) {
session_destroy(c->session);
c->session = NULL; /* later uses fail loudly */
}
int handle_message(struct conn *c, const struct msg *m) {
if (m->type == MSG_CLOSE) {
conn_close_session(c);
return -1;
}
if (c->session == NULL) return -1;
return session_process(c->session, m);
}
Durable fixes follow from a clear ownership model:
- One owner per object. Document who frees it, and route destruction through a function that also clears the owner's pointer.
- In C++, encode ownership in types.
std::unique_ptrfor single ownership,std::shared_ptr/std::weak_ptrwhen lifetimes genuinely overlap, and never store a raw pointer that outlives its owner. - Invalidate on free. Nulling the owning pointer turns a silent UAF into a deterministic crash on that path.
- Beware iterator and reference invalidation. Growing a
std::vectorinvalidates pointers into it; so doesreallocin C. - Consider memory-safe languages for components where lifetimes are complex. Rust's borrow checker rejects this whole class at compile time; see memory-safe languages.
Double free
A double free occurs when two code paths both believe they own an object, typically an error path and a cleanup path:
/* Vulnerable: buf is freed on error, then again by the common cleanup. */
char *buf = malloc(len);
if (read_all(fd, buf, len) < 0) {
free(buf);
goto out;
}
...
out:
free(buf); /* second free on the error path */
The fix is a single cleanup point and nulling after free, which makes the second free(NULL) a harmless no-op:
out:
free(buf);
buf = NULL;
Detecting heap bugs
AddressSanitizer is the workhorse. It surrounds every heap allocation with redzones, and it keeps freed memory in a quarantine instead of reusing it immediately, so a later access through a dangling pointer hits poisoned memory and is reported with three stack traces: where the bad access happened, where the memory was freed, and where it was allocated. That third trace is often the fastest route to the root cause. See AddressSanitizer, UBSan and friends for how to read these reports.
| Tool | Heap overflow | Use-after-free | Double free | Overhead | Where to use |
|---|---|---|---|---|---|
| ASan | Yes | Yes (within quarantine window) | Yes | ~2x CPU, 2–3x memory | Tests, CI, fuzzing |
| HWASan (AArch64) | Yes, probabilistic | Yes, probabilistic | Yes | Lower memory than ASan | Tests and dogfood builds on arm64 |
| Valgrind Memcheck | Yes | Yes | Yes | 10–50x slower | No-rebuild debugging |
| GWP-ASan | Sampled | Sampled | Sampled | Negligible | Production |
| glibc checks | Some metadata cases | Rarely | tcache and fastbin cases | None extra | Always on |
Pair ASan with coverage-guided fuzzing: fuzzers are particularly good at reaching the unusual error paths where double frees and lifetime bugs hide.
Key takeaways
- Heap overflows, use-after-free and double free all break the rule that pointers are only valid within an object's bounds and lifetime.
- Allocator metadata lives next to user data, so heap bugs can corrupt the allocator itself; glibc's integrity checks and safe-linking detect much of this and abort.
- Use-after-free is a lifetime and ownership problem. Fix it with single ownership, invalidation on free and, where possible, languages or types that encode ownership.
- Run tests and fuzzers under ASan, consider a hardened allocator for exposed services, and treat allocator abort messages in production as confirmed memory corruption.