Skip to content

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.

Published on 7 min read

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

BugCWEWhat happensTypical root cause
Heap buffer overflowCWE-122A write runs past the end of an allocation into the next chunkMissing or wrong length check
Use-after-freeCWE-416A pointer is used after its object was freedUnclear ownership, callbacks, caches
Double freeCWE-415The same pointer is passed to free twiceTwo 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:

  1. 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.
  2. 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

ProtectionSinceWhat it does
Integrity checksLong-standing, expanded over timeValidates sizes and list links on malloc/free, aborts with messages such as free(): invalid pointer or malloc(): corrupted top size
tcache double-free detectionglibc 2.29A key field marks chunks already in the tcache, so a second free aborts with free(): double free detected in tcache 2
Safe-linkingglibc 2.32Free-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 removedglibc 2.34The 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

A struct is allocated, freed while a pointer is kept, then its memory is reused by unrelated data that the stale pointer reads

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_ptr for single ownership, std::shared_ptr/std::weak_ptr when 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::vector invalidates pointers into it; so does realloc in 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.

ToolHeap overflowUse-after-freeDouble freeOverheadWhere to use
ASanYesYes (within quarantine window)Yes~2x CPU, 2–3x memoryTests, CI, fuzzing
HWASan (AArch64)Yes, probabilisticYes, probabilisticYesLower memory than ASanTests and dogfood builds on arm64
Valgrind MemcheckYesYesYes10–50x slowerNo-rebuild debugging
GWP-ASanSampledSampledSampledNegligibleProduction
glibc checksSome metadata casesRarelytcache and fastbin casesNone extraAlways 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.

Related guides