C++ Heap Corruption: Double Free and Wrong delete

Key takeaways

Heap corruption is hard because the crash almost never happens where the bug is: an overrun or double delete damages allocator metadata, and a later, unrelated malloc or free fails. This guide explains the main causes, why they behave that way, and how to make tools stop at the real bug.

What is heap corruption?

Heap corruption is damage to the allocator’s bookkeeping, or to other objects on the heap, caused by code that writes where it should not or frees memory incorrectly.

int* arr = new int[10];
arr[10] = 42;  // out of bounds: index 10 is one past the end
delete[] arr;

The defining problem is delay. This write does not crash. On most platforms, arr[10] lands in the allocator’s metadata for the next chunk or in the first bytes of some other object, and the program keeps running. The crash happens later, sometimes much later, when malloc or free reads that damaged metadata, or when the other object is used. The stack trace then points at innocent code: a std::string constructor, a push_back, or free() inside a third-party library.

That is why heap corruption has a reputation for being hard to debug. The usual approach, “look at where it crashed”, leads you to the wrong place. The rest of this article is about the causes, why each one damages the heap, and how to get tools to stop at the faulty write instead of the later crash.

Why the crash happens somewhere else

Allocators such as glibc’s malloc keep bookkeeping data right next to your memory. Each chunk has a small header (its size and flags), and freed chunks are linked into free lists through pointers stored in the freed memory itself. So the memory directly before and after your array is not empty space. It is the data structure the allocator relies on for its next operation.

When an overrun changes the size field of the next chunk, nothing checks it at that moment. The next time the allocator splits, merges, or frees that chunk, it reads the damaged size and either detects the inconsistency and aborts, or follows a bad pointer. glibc’s typical messages give a hint about what happened:

  • free(): double free detected in tcache 2: the same pointer was freed twice.
  • free(): invalid pointer / munmap_chunk(): invalid pointer: the pointer passed to free does not point to the start of a heap chunk, often because of delete instead of delete[] or a pointer that was moved.
  • malloc(): corrupted top size / corrupted size vs. prev_size: the metadata was overwritten, usually by a buffer overrun.

These messages identify the kind of damage, but not the code that caused it. The function in the stack trace is only the first code that noticed.

Four ways to corrupt the heap

int* arr = new int[10];
arr[15] = 42;          // 1. out-of-bounds write
int* ptr = new int(10);
delete ptr;
delete ptr;            // 2. double delete
int* arr2 = new int[10];
delete arr2;           // 3. should be delete[]
int* p = new int(10);
delete p;
*p = 42;               // 4. write after free

All four are undefined behavior, which means the language gives no guarantee about what happens. In practice, “nothing visible happens” is a very common outcome in a test run, and that is exactly what makes these bugs survive into production.

Each cause in a few lines of code

Buffer overflow with strcpy

#include <cstring>
#include <string>
void bufferOverflow() {
    char* buffer = new char[10];
    strcpy(buffer, "This is too long");  // 17 bytes into a 10-byte buffer
    delete[] buffer;
}
void safeBuffer() {
    char* buffer = new char[20];
    strncpy(buffer, "This is safe", 19);
    buffer[19] = '\0';
    delete[] buffer;
}
void useString() {
    std::string str = "This is safe";  // manages its own size
}

strcpy copies until the source’s terminating zero, with no idea how big the destination is. This is the classic C string overrun, and it is still common in C++ code that talks to C APIs. strncpy limits the copy, but it does not add a terminator if the source is too long, which is why the example sets buffer[19] explicitly. Forgetting that line swaps an overrun for an unterminated string, which causes a read overrun later. The real fix is to not manage the size by hand: std::string and std::vector grow as needed, and at the C boundary use std::string::c_str() or std::vector<char> with an explicit size.

Double free

#include <memory>
void doubleFree() {
    int* ptr = new int(10);
    delete ptr;
    delete ptr;  // undefined behavior
}
void safeFree() {
    int* ptr = new int(10);
    delete ptr;
    ptr = nullptr;
    delete ptr;  // deleting nullptr is a no-op
}
void smartPointer() {
    auto ptr = std::make_unique<int>(10);  // deleted exactly once, automatically
}

A double free is dangerous because the freed block goes back onto a free list. Freeing it a second time can put it on the list twice. The next two allocations of that size then receive the same address, and two unrelated objects overwrite each other. Since version 2.29, glibc detects a free of a block that is still sitting in its per-thread cache (tcache) and aborts, which catches many simple cases. It cannot catch every pattern: once the block has been handed out again to a new allocation, the second delete frees someone else’s live object, and nothing detects that.

Setting the pointer to nullptr after delete only protects that one variable. In real double-free bugs, the second delete almost always comes through a different pointer to the same object: a copy of a struct holding a raw pointer, a second container that thinks it owns the object, or a class with a raw pointer member and a compiler-generated copy constructor. Two copies both delete the same pointer in their destructors. That last case is the most common one I run into in older code bases, and the fix is ownership, not nulling: std::unique_ptr makes the copy a compile error, and the Rule of Zero means you never write the destructor that deletes twice.

delete instead of delete[]

void wrongDelete() {
    int* arr = new int[10];
    delete arr;     // undefined behavior: must be delete[]
}
void correctDelete() {
    int* arr = new int[10];
    delete[] arr;
    
    int* ptr = new int(10);
    delete ptr;
}
void smartPointer() {
    auto arr = std::make_unique<int[]>(10);  // uses delete[] for you
    auto ptr = std::make_unique<int>(10);
}

For int, delete instead of delete[] often appears to work, which is why this bug survives. It becomes visible with class types that have destructors. For new T[n], most implementations store the element count in a hidden “array cookie” just before the returned pointer, so that delete[] knows how many destructors to run. delete does not know about the cookie. It runs only one destructor and passes the wrong address to the deallocation function, which the allocator reports as free(): invalid pointer, or it silently corrupts the heap. Watch out in particular for a std::unique_ptr<T> (not T[]) holding a new T[n] array. It compiles fine and calls the wrong delete.

Use-after-free

#include <iostream>
#include <memory>
void useAfterFree() {
    int* ptr = new int(10);
    delete ptr;
    std::cout << *ptr << std::endl;  // reads freed memory
}
void safeUse() {
    int* ptr = new int(10);
    std::cout << *ptr << std::endl;
    delete ptr;
}
void smartPointer() {
    auto ptr = std::make_unique<int>(10);
    std::cout << *ptr << std::endl;
}

A read after free often “works”, because the memory still holds the old value until it is reused. A write after free is heap corruption: the freed block may already contain free-list pointers, or it may already belong to a new object. In real code, use-after-free rarely looks this obvious. It usually comes from an iterator, reference, or std::string_view that outlives the container it points into (for example, keeping a reference to a std::vector element across a push_back that reallocates), or from a callback that fires after its object was destroyed. See C++ use-after-free for those patterns.

Detection: stop at the bug, not at the crash

The key idea for all tools below is the same: make the faulty access itself fail immediately, instead of waiting for the allocator to trip over the damage.

AddressSanitizer (ASan) is the first tool to reach for:

g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp
./a.out

ASan surrounds every allocation with poisoned “redzones” and keeps freed memory in a quarantine for a while instead of reusing it at once. Every memory access is checked against a shadow map, so an overrun is reported at the exact write, with the stack of the write, the stack where the block was allocated, and (for use-after-free) the stack where it was freed. That third stack trace is usually what solves the bug. The cost is roughly 2× slowdown and considerably more memory, which is fine for tests and CI. Run your test suite under ASan in CI, not just when you are hunting a crash. See C++ sanitizers for the other sanitizers.

Valgrind (Memcheck) needs no recompilation, which helps when you cannot rebuild a dependency:

valgrind --tool=memcheck --track-origins=yes ./program

It is much slower than ASan (often 20–50×), and it cannot detect overruns within stack or global arrays, but it reliably reports invalid heap reads, writes, and frees with stack traces. See C++ Valgrind.

On Windows, enable full page heap for the executable with gflags /p /enable program.exe /full (from the Debugging Tools for Windows). Each allocation is then placed at the end of its own page, followed by an inaccessible guard page, so the first out-of-bounds byte triggers an access violation right at the faulty instruction in the debugger. MSVC also supports /fsanitize=address. The debug CRT’s _CrtCheckMemory() can be called periodically to narrow down when corruption first appears.

When you cannot use any of these, for example because the bug only appears on a production build, a hardware watchpoint helps once you know which address gets damaged. In gdb, watch *(long*)0x5555555592a8 stops at the instruction that writes there.

When tools are not enough: a debugging approach

The frustrating version of this bug is a crash that shows up in production a few times a week, always somewhere different, and never on a developer machine. Different allocation patterns under real load move the damaged chunk around, so each crash dump points to different innocent code. When I see a cluster of crashes inside malloc, free, or container code, spread across unrelated call sites, I stop reading individual stack traces and treat it as heap corruption until proven otherwise.

What works in that situation is to reproduce the workload, not the crash. Build the same code with ASan, replay production-like traffic or inputs through it (a load test, a fuzzer, or recorded requests), and let ASan report the first bad write. Once you have that report, the fix is usually small. Getting there is the hard part, and it is much faster with a sanitizer build in CI from the start than with a production crash dump.

Everyday code that corrupts the heap

Off-by-one loops

int* arr = new int[10];
for (int i = 0; i < 10; i++) {  // < 10, not <= 10
    arr[i] = i;
}
delete[] arr;
std::vector<int> vec(10);        // or let a container track the size

i <= 10 writes one element past the end, the textbook heap overrun. With std::vector, vec.at(i) checks bounds and throws, and many standard libraries have a debug mode (_GLIBCXX_ASSERTIONS for libstdc++, iterator debugging in MSVC debug builds) that also checks operator[].

Mixing allocation families

// ❌ Each family must be paired with its own release
int* a = new int[10];
free(a);                 // wrong: new[] must use delete[]
int* b = static_cast<int*>(malloc(10 * sizeof(int)));
delete[] b;              // wrong: malloc must use free
a = static_cast<int*>(realloc(a, 20 * sizeof(int)));  // wrong: realloc only for malloc memory

// ✅ Let a container handle growth
std::vector<int> vec(10);
vec.resize(20);

new/delete, new[]/delete[], and malloc/free/realloc are separate families, and memory from one must be released by the same family. new may use a different allocator than malloc (and can be replaced by the program), and new[] may add the array cookie described above. Mixing them can appear to work with one compiler and standard library and break with another. A common source of this bug is a C library that returns memory the caller must release. Read its documentation to see which function releases it, and wrap it in a std::unique_ptr with a custom deleter.

Checking only the upper bound of an index

int* arr = new int[10];
if (index >= 0 && index < 10) {  // check both ends
    arr[index] = 42;
}
delete[] arr;

Checking only the upper bound misses negative indexes, which write into the chunk before the array, the part that most often holds allocator metadata. If index is a size_t, a negative value from a subtraction wraps around to a huge number, so the upper-bound check catches it. With a signed int, it does not.

Raw owning pointers in structs

struct Node {
    int* data;
    Node* next;
};
void deleteNode(Node* node) {
    delete[] node->data;
    delete node;
}
struct NodeSafe {
    std::unique_ptr<int[]> data;
    std::unique_ptr<NodeSafe> next;
};

With raw pointers, every place that copies a Node, frees it, or unlinks it must agree on who deletes data. One mistake produces a leak or a double delete. NodeSafe makes ownership part of the type: it can be moved but not copied, and memory is released exactly once. One caveat: a very long list of unique_ptr<NodeSafe> is destroyed recursively, and can overflow the stack. Long lists need an iterative destructor.

Preventing it structurally

auto ptr = std::make_unique<int>(10);
auto arr = std::make_unique<int[]>(10);
std::vector<int> vec(10);

Almost all heap corruption in modern C++ comes from manual memory management. The most effective prevention is structural: containers track their own size, smart pointers delete exactly once with the correct form of delete, and RAII ties lifetime to scope (see C++ RAII and smart pointers). The second layer is a sanitizer build in CI, so any remaining new/delete code, C interop, or pointer arithmetic is checked every time the tests run.

FAQ

Q1: When does heap corruption happen?

A: Buffer overruns, double free, the wrong form of delete, mixed allocator families, and writes after free.

Q2: How do I detect it?

A: AddressSanitizer first, Valgrind when you cannot recompile, and page heap on Windows. All three stop at the faulty access instead of the later crash.

Q3: How do I prevent it?

A: Containers, smart pointers, and RAII for ownership, plus sanitizer builds in CI.

Q4: What are the symptoms?

A: Crashes inside malloc/free or container code, glibc abort messages, and crashes that move to a different place on every run.

Q5: delete vs delete[]?

A: delete for an object from new, delete[] for an array from new[]. Mixing them is undefined behavior, and with class types it usually crashes.