The heap allocator (glibc's ptmalloc, jemalloc, the Windows heap) maintains a freelist — a linked list of available chunks. When you call free(p), the allocator links p onto the appropriate freelist. When you call malloc(size), it pulls a chunk off the front.
Calling free twice on the same pointer puts the chunk on the freelist twice. Subsequent malloc calls hand out the same memory to two different parts of the program. Each thinks it owns the chunk exclusively. Anything one writes, the other reads — including freelist pointers.
The simplest example
Real production double-frees rarely look this obvious. The typical pattern: a parent function frees a pointer it passed to a child, but the child also freed it in an error path. Or a refcount goes to zero in two places. Or an object's destructor calls free on a member that the caller already freed.
What actually happens on the heap
p = malloc(64) → allocator returns chunk at 0x600100. Bookkeeping: chunk is IN-USE.free(p) → allocator marks chunk FREE, links it to head of size-64 freelist. p still holds the value 0x600100 but it's now a dangling pointer.q = malloc(64) → allocator returns the same chunk at 0x600100. q == p. The chunk is IN-USE again, but two pointers reference it.free(p) (second free) → allocator marks chunk FREE, links it onto the freelist even though it's currently allocated to q. The freelist now contains an in-use chunk.r = malloc(64) → allocator pulls the (already-allocated) chunk off the freelist. Now p, q, and r all point at 0x600100. Any write through one is visible through all three.q. The attacker overwrites the chunk's freelist next-pointer (which the allocator stores inside the chunk while it's free). Next malloc follows that pointer and writes a chunk header to attacker-chosen memory: a function pointer, a vtable, a GOT entry. Arbitrary write → arbitrary execution.Where double frees hide
- Error paths. The function frees on success. The error handler also frees. Triggering the error after success runs both.
- Cleanup chains. Object A holds a pointer to buffer B. A's destructor frees B. But somewhere else, B is freed directly first, then A is destroyed.
- Refcounting bugs. The refcount decrement happens twice; the destructor runs twice; the free runs twice.
- realloc surprises. If
realloc(p, new_size)moves the allocation,pis implicitly freed. Code that then freespexplicitly is double-freeing. - Function pointer tables. A library has 12 cleanup functions in a table. Two of them free the same field.
Famous incidents
| Year | Bug | Outcome |
|---|---|---|
| 2002 | zlib double-free (CVE-2002-0059) | Found in compression library used by almost every product on Earth. Pre-auth exploitable in PostgreSQL, Apache, IIS, many others. |
| 2014 | OpenSSL CVE-2014-3567 | Double-free in SSL session ticket handling. DoS, potentially worse. |
| 2017 | WebKit (CVE-2017-2403) | Used in JavaScriptCore. Sandbox escape from Safari WebContent process. |
| 2020 | OpenSSL CVE-2021-3711 | SM2 decryption double-free. The bug had been latent for years. |
| 2022 | OpenSSL CVE-2022-3786 | X.509 parser; double-free during certificate verification with certain Punycode email addresses. The "Heartbleed 2" that wasn't, but still serious. |
Defenses
1. Set pointers to NULL after free
The cheapest defense. free(NULL) is defined and safe. Make every free(p) into free(p); p = NULL;. A second free becomes a no-op instead of corruption.
Convention many shops adopt: a macro FREE(p) that does both. Some style guides require it.
2. Use RAII in C++ / smart pointers
std::unique_ptr moves ownership explicitly. There's only one owner; only one deallocation. std::shared_ptr refcounts; deallocation happens exactly once. Either eliminates manual delete.
3. Modern allocators detect some double frees
glibc's tcache freelist, since glibc 2.27, checks whether the chunk being freed is already at the head of the freelist (the simplest double-free case). If yes, it aborts the program rather than corrupt the freelist. This catches naive double frees in seconds but not the more complex patterns where the attacker shuffles allocations between the two free calls.
4. AddressSanitizer
Compile your code with -fsanitize=address during testing. ASan detects double-free at runtime and aborts with a clean stack trace, pointing at both the first and second free. Integrate into CI. Many double-frees that ship to production were caught locally by ASan but ignored.
5. Move to memory-safe languages
Rust's borrow checker makes double-free impossible at compile time. The ownership model statically enforces that each allocation has exactly one owner who is responsible for freeing it. Go, Java, C#, Swift, Python — all use garbage collection or refcounting that prevents manual double-free.
Double-free is what happens when ownership of an allocation is unclear. Two parts of the code each think they're responsible for the cleanup; both call free; the second call corrupts the freelist.
The single-line fix — p = NULL after each free — defeats the simplest cases. The structural fix — a memory-safe language or RAII — defeats the whole class. The cost of either is far less than the cost of an exploited heap.
References
Formatted in APA 7.
- MITRE. (2024). CWE-415: Double free. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/415.html
- Free Software Foundation. (2024). The GNU C library reference manual: Memory allocation. https://www.gnu.org/software/libc/manual/html_node/Memory-Allocation.html
- Google. (2024). AddressSanitizer. https://github.com/google/sanitizers/wiki/AddressSanitizer
- OpenSSL Project. (2022). CVE-2022-3786: X.509 email address buffer overflow. https://www.openssl.org/news/secadv/20221101.txt
- Klabnik, S., & Nichols, C. (2023). The Rust programming language (2nd ed.). No Starch Press.