Skip to content

CWE-415: Double Free - C++

Overview

In C++, double free occurs when delete/delete[] (or a C free() call reached from C++ code) runs twice on the same pointer without a reallocation in between, or when two owners both believe they are responsible for releasing the same resource. Manual new/delete pairing, ambiguous ownership in data structures, and cleanup that runs both explicitly and via a destructor are the most common causes.

Primary Defence: Use std::unique_ptr for single ownership and std::shared_ptr for shared ownership instead of raw new/delete. They manage release automatically when ownership is established correctly: create additional shared owners by copying an existing std::shared_ptr, and never establish independent ownership from a raw pointer something already owns, including one obtained through get().

Common Vulnerable Patterns

Exception-Based Double Free

// VULNERABLE - the catch block deletes a pointer the try block already deleted
#include <exception>

void process(Widget *ptr) {
    try {
        delete ptr;
        mayThrow();          // any throw after this point reaches the catch below
    } catch (const std::exception &) {
        delete ptr;          // DOUBLE FREE - ptr was already deleted above
        throw;
    }
}

Why this is vulnerable: The delete ptr in the try block succeeds and returns the memory to the allocator, but ptr is unchanged and still holds the address. The catch block is written as a general "release whatever this function acquired" handler, which is correct for every resource the function still holds and wrong for the one it has already released - and nothing in the code marks which is which. The wider the try block, the harder that is to see, because the set of things released before the throw depends on where in the block it happened.

The correct fix is not a flag saying whether the delete already ran. It is to stop having a release call in the handler at all: an owning member or local (std::unique_ptr<Widget>) releases exactly once during unwinding, on every path out, and the catch block reduces to whatever the function actually wants to report.

Note what this pattern is not. A constructor that throws part-way through does not produce a double free: [except.ctor] destroys the subobjects that were fully constructed and does not run the class's own destructor, so a raw new-ed member assigned before the throw is leaked (CWE-401), not deleted twice. Reaching for "the destructor will free it again" is the wrong model, and it points at the wrong fix - the member needs to be an owning type so that its own destructor runs as part of that subobject destruction.

Destructor/Cleanup Double Free

// VULNERABLE - manual cleanup() and the destructor both free the same buffer
class Resource {
private:
    char *buffer;

public:
    Resource() : buffer(new char[1024]) {}

    ~Resource() {
        delete[] buffer;  // Freed in destructor
    }

    void cleanup() {
        delete[] buffer;  // Manual cleanup
        // Missing: buffer = nullptr;
    }
};

// Usage
Resource res;
res.cleanup();  // Manually clean up
// res goes out of scope -> destructor runs -> DOUBLE FREE!

Why this is vulnerable: cleanup() releases the buffer early but leaves buffer holding the old address, so the destructor deletes it a second time when res goes out of scope. The destructor is not optional and cannot be skipped: an early return, a thrown exception, or simply the end of the enclosing block all reach it, which is why "call cleanup() and then don't let the object be destroyed" is not an available fix. What has gone wrong is that the class has two release paths for one resource and no state saying which of them has run.

Resource also declares neither a copy constructor nor a copy assignment operator, so the compiler supplies member-wise ones that copy the buffer pointer. Passing a Resource by value hands two objects the same address and destroys both - a double free reached without calling cleanup() at all, and the more common way this class fails in practice.

The fix is to delete cleanup() and let the destructor be the single release path, holding the buffer in std::vector<char> or std::unique_ptr<char[]> so that copying is either correct or rejected. If early release genuinely has to be exposed, buffer = nullptr after the delete[] is what makes the destructor's delete[] harmless - and no if (buffer != nullptr) guard is needed in front of it, because delete[] nullptr is defined to do nothing. A guard there implies a check is required and invites a maintainer to add the same guard where it genuinely is not enough.

Raw Owning Pointers in a Node Graph

// VULNERABLE - the container owns its children, and callers delete them too
#include <vector>

class Node {
public:
    std::vector<Node*> children;   // raw pointers say nothing about who owns them
    Node* parent = nullptr;        // non-owning, but indistinguishable from the above

    ~Node() {
        for (Node* child : children) {
            delete child;          // the destructor assumes it owns every child
        }
    }
};

void buildTree() {
    Node* root = new Node();
    Node* child = new Node();
    root->children.push_back(child);

    delete child;   // caller assumes it owns what it new-ed
    delete root;    // root's destructor deletes child again - DOUBLE FREE
}

Why this is vulnerable: children and parent are the same type, Node*, and one is an owning edge while the other is not. Nothing in the code records that, so the rule lives only in whatever the author had in mind, and every caller has to know it. Here the caller assumed the ownership rule that the language actually encourages - free what you allocated - and the destructor assumed the opposite.

The parent back-edge is the reason a raw pointer is tempting at all: making both edges owning would leave two objects each holding the other alive. That is a real problem, and it is a leak rather than a double free - the fix in the secure patterns below gives the downward edge ownership and the upward edge none.

Secure Patterns

std::unique_ptr for Single Ownership

#include <cstddef>
#include <memory>

class DataProcessor {
private:
    std::unique_ptr<char[]> buffer;

public:
    explicit DataProcessor(std::size_t size) : buffer(std::make_unique<char[]>(size)) {}

    // Cannot be copied, preventing double-free
    DataProcessor(const DataProcessor&) = delete;
    DataProcessor& operator=(const DataProcessor&) = delete;

    // Move semantics transfer ownership safely
    DataProcessor(DataProcessor&&) = default;
    DataProcessor& operator=(DataProcessor&&) = default;
};

void processData() {
    DataProcessor processor(1024);   // the buffer is on the heap; the owner is not
    // buffer is deleted once when processor goes out of scope, on every path
}

Why this works: There is no delete anywhere in this code, so there is no second one. std::unique_ptr<char[]> releases its allocation exactly once when it is destroyed, and DataProcessor needs no destructor of its own to make that happen - the implicit one destroys the member.

Copying is where "exactly once" would otherwise break, and unique_ptr already settles it. Because unique_ptr's own copy constructor is deleted, DataProcessor's implicit one is deleted too, and because the class declares no destructor or copy operations, the move operations are implicitly generated. Deleting all four declarations above leaves the semantics unchanged - this is the rule of zero, and it is the reason to reach for an owning member type rather than a char* and a hand-written destructor. They are spelled out here only to make the intent legible to a reader.

What they are not is optional bookkeeping you can carry over to a class holding a raw owning pointer. There, the compiler generates a member-wise copy that duplicates the address and gives you two destructors for one allocation - so the four declarations become mandatory, along with a destructor, and that is the rule of five. The whole argument for the member type is that it moves you from the second rule to the first.

processor itself is an ordinary local. Reaching for std::make_unique<DataProcessor>(1024) would add a second heap allocation for the owner object and buy nothing - the allocation that needs managing is already managed.

std::shared_ptr for Shared Ownership

#include <cstddef>
#include <memory>
#include <vector>

class SharedResource {
private:
    std::vector<int> data;
public:
    explicit SharedResource(std::size_t size) : data(size) {}
};

void shareResourceSafely() {
    auto resource = std::make_shared<SharedResource>(100);
    std::vector<std::shared_ptr<SharedResource>> workers;

    for (int i = 0; i < 10; i++) {
        workers.push_back(resource);  // reference count increases
    }
    // resource is freed only when the last shared_ptr is destroyed
    // no manual tracking needed, no double-free possible
}

Why this works: std::shared_ptr keeps the reference count in a control block that is a distinct object from the one being managed, with its own lifetime: each copy increments the count, each destruction decrements it, and the managed object is destroyed exactly once when the strong count reaches zero. Because the count lives outside the object, no individual shared_ptr has to know how many others exist, which is what removes the "have I already released this?" question that manual ownership leaves open.

Two limits are worth knowing rather than discovering. The count updates are atomic, so copying and destroying a shared_ptr from several threads is safe - but that says nothing about the object it points at, which needs its own synchronisation, and nothing about concurrent assignment to the same shared_ptr variable, which needs std::atomic<std::shared_ptr> (C++20) or the deprecated free-function atomic_load/atomic_store overloads. And std::make_shared fuses the control block and the object into a single allocation, which is why it is preferred over std::shared_ptr<T>(new T) - one allocation instead of two - but it means a surviving std::weak_ptr keeps that whole block alive after the object itself has been destroyed. For a large object with long-lived weak references, shared_ptr<T>(new T) releases the object's storage promptly and is the right trade.

One thing the type system does not check is duplicated ownership. Every constructor that takes a raw pointer establishes an independent ownership group with its own control block, so std::shared_ptr<int> a(p); followed by std::shared_ptr<int> b(p); compiles, is well-formed, and double-frees: each group releases p when its own last owner goes. The example above avoids this because every additional owner is a copy of an existing shared_ptr and they all share one control block. New owners come from copying an owner, never from the raw pointer a second time and never from get(). std::unique_ptr has the same hole - two constructed from one raw pointer both delete it - and the shared_ptr<T>(new T) form recommended just above is safe only because that pointer is fresh and nothing else holds it.

Breaking Circular References With std::weak_ptr

#include <memory>
#include <utility>
#include <vector>

class Node : public std::enable_shared_from_this<Node> {
private:
    std::vector<std::shared_ptr<Node>> children;  // owning edge, downward
    std::weak_ptr<Node> parent;                   // non-owning edge, upward

    Node() = default;                             // force construction through create()

public:
    // shared_from_this() is only valid once a shared_ptr already owns the object,
    // so the only way to get a Node is one that guarantees it.
    static std::shared_ptr<Node> create() {
        return std::shared_ptr<Node>(new Node());
    }

    void addChild(std::shared_ptr<Node> child) {
        child->parent = shared_from_this();
        children.push_back(std::move(child));
    }

    std::shared_ptr<Node> getParent() const { return parent.lock(); }  // null if gone
};

Why this works: The two edges now have different types, so the ownership rule is in the code rather than in a convention. shared_ptr on the downward edge means each node has exactly one release path - the parent's children vector - and weak_ptr on the upward edge never releases anything, so the caller-versus-destructor ambiguity that produced the double free has nowhere left to live. parent.lock() is what a reader of the back-edge must use, and it returns null once the parent is gone rather than a dangling pointer.

Putting the ownership in the type is also what fixes the cycle, which is a separate failure with a separate symptom: two shared_ptrs pointing at each other keep both counts permanently above zero, so neither object is ever destroyed and the memory is leaked (CWE-401) rather than double-freed. weak_ptr does not participate in the strong count, so the cycle never forms.

The private constructor and create() factory are load-bearing, not style. shared_from_this() requires that a shared_ptr already owns the object; calling it on a Node built any other way - a stack local, a std::make_unique<Node> - throws std::bad_weak_ptr (C++17 onward; before that it was undefined behaviour). Since addChild calls it unconditionally, a class that lets callers construct a Node directly hands them a Node on which addChild always throws. create() also cannot use std::make_shared<Node>, because make_shared constructs the object itself and has no access to a private constructor.

RAII With Custom Deleters

#include <cerrno>
#include <cstdio>
#include <memory>
#include <system_error>

struct FileCloser {
    void operator()(std::FILE* fp) const { std::fclose(fp); }
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr openFile(const char* path, const char* mode) {
    FilePtr fp(std::fopen(path, mode));
    if (!fp) {
        throw std::system_error(errno, std::generic_category(), path);
    }
    return fp;   // ownership moves to the caller; closed exactly once, wherever it ends
}

Why this works: Custom deleters extend RAII past new/delete to any resource with a paired release call - FILE*, malloced memory, OS handles, library contexts. Ownership is expressed once, in the type, so no call site can forget the release and no two call sites can both perform it.

The deleter has no null check because unique_ptr never calls it with a null pointer: its destructor is specified to have no effect when get() == nullptr ([unique.ptr.single.dtor]), and reset only invokes the deleter on the old value when that value was non-null. A check there is unreachable through the smart pointer and mostly serves to imply the deleter is a general-purpose close function that anyone may call - which std::fclose is not, since passing it a null FILE* is undefined behaviour. Keep the deleter to exactly the release call, and let the type decide when it runs.

Handling the failed std::fopen inside openFile matters for the same reason. Returning a null FilePtr compiles and looks like the caller can check it, but a caller who does not check gets a FILE* of null passed to std::fread rather than a diagnosable failure - the CWE-476 shape sitting inside the fix for this one. Use std::FILE and std::fclose rather than the unqualified names: <cstdio> is only required to declare these in namespace std, and whether they also appear in the global namespace is unspecified.

Testing

  • Compile with AddressSanitizer (-fsanitize=address) and exercise every error/exception path - it reports double frees immediately.
  • Run under Valgrind (valgrind --tool=memcheck) for an independent check.
  • Specifically test exception-throwing paths and destructor/manual-cleanup combinations, since those are where double frees hide.

Additional Resources