Skip to content

CWE-415: Double Free - C

Overview

In C, double free occurs when free() runs twice on the same pointer without a malloc()/calloc()/realloc() in between. Because C has no built-in ownership tracking, this almost always comes down to unclear responsibility for a pointer: two code paths, an error handler and the normal path, or a linked structure traversed twice, each believing they must free the same allocation.

Primary Defence: C has no compiler-enforced single-ownership mechanism, so ownership has to be decided and then written into the signatures: every allocation gets exactly one function responsible for releasing it, and that function takes a pointer to the caller's pointer (void buffer_free(Buffer **bufp)) so it can leave the caller holding NULL. Nulling after free() is the mitigation that makes a second call harmless; deciding who owns the allocation is what stops there being one.

Common Vulnerable Patterns

Conditional Double Free

// VULNERABLE - freed unconditionally, then freed again on the error path
char *buf = malloc(100);
free(buf);

if (error) {
    free(buf);  // DOUBLE FREE! Same pointer freed twice
}

Why this is vulnerable: The buffer is freed unconditionally, then freed again when error is true. After the first free(buf), the memory has returned to the heap allocator but buf still holds the same address - a dangling pointer - so the error path hands the allocator an address it has already reclaimed. Marking one block free twice corrupts the allocator's own bookkeeping (free lists, bin structures) and can create circular references in the free lists. An attacker who controls the chunk metadata a later allocation reads back turns that into an arbitrary write, and from there code execution.

Multiple Execution Paths

// VULNERABLE - the function frees its argument on some paths, and says so nowhere
void func(char *data) {
    if (condition) {
        free(data);   // caller's pointer is now dangling, and the caller can't tell
        return;
    }

    process(data);
    free(data);       // every path frees - but only sometimes after process()
}

// Caller
char *data = malloc(100);
func(data);
free(data);           // DOUBLE FREE - the caller believes it still owns data

Why this is vulnerable: func is internally consistent - the early return means the two free calls can never both run for one call. The defect is at the boundary. func takes a char * by value, so nulling data inside it would change nothing the caller can see, and the signature carries no hint that the argument is consumed. A caller reading only the prototype has no way to learn that func took ownership, so the natural thing - free what you allocated - is the second free.

This is the shape that survives review, because neither half is wrong on its own. Both are correct under an ownership convention, and they hold different ones. In a codebase where the convention is unwritten, the usual outcome is defensive frees on both sides of the call rather than one of them being deleted.

Use-After-Free Leading to Double Free

// VULNERABLE - a stale reference to an already-freed list frees every node again
typedef struct Node {
    void *data;
    struct Node *next;
} Node;

void delete_list(Node *head) {
    Node *current = head;
    while (current != NULL) {
        Node *next = current->next;
        free(current->data);
        free(current);
        current = next;
    }
}

// Elsewhere
Node *list = create_list();
delete_list(list);
// ... later, another function has a stale reference
delete_list(list);  // DOUBLE FREE of every node

Why this is vulnerable: The first delete_list(list) frees every node and its data, but list itself is never nullified, so it still points at freed memory. Any other code path holding that reference can call delete_list again, walking what it takes for a valid list and freeing every node a second time. If the allocator has handed those addresses out again between the two calls, the second traversal corrupts unrelated data structures.

Secure Patterns

Free Through the Caller's Variable, Not a Copy of It

#include <stdlib.h>

typedef struct {
    char *data;
    size_t size;
} Buffer;

// Takes Buffer ** so it can null the caller's own variable, not a local copy
void buffer_free(Buffer **bufp) {
    if (bufp == NULL || *bufp == NULL) return;

    free((*bufp)->data);   // free(NULL) is a no-op, so no check is needed here
    free(*bufp);
    *bufp = NULL;          // the caller's pointer is NULL when this returns
}

// Caller
Buffer *buf = buffer_create(1024);
buffer_free(&buf);
buffer_free(&buf);   // safe: buf is already NULL, so this returns immediately

Why this works: The parameter is a pointer to the caller's variable, so the function can assign through it. That is the only way in C for a release function to leave its caller in a state where a repeat call is harmless - a Buffer * parameter is a copy, and nulling a copy is invisible the instant the function returns. The free((*bufp)->data) needs no NULL guard because C defines free(NULL) as doing nothing (C11 7.22.3.3p2); a guard in front of it is noise that suggests a check is required when it is not.

Read the limit honestly: this protects the one variable whose address was passed. If the same Buffer * was copied into a struct field, a callback's context, or a second local, that copy is still dangling and freeing through it is the same bug. Nulling is a mitigation for one variable; deciding that exactly one place owns the allocation is the fix.

Safe Free Wrapper

#include <stdlib.h>

// Works on any pointer type without a cast, because the macro is expanded
// against the argument's real type rather than converted to void **.
#define SAFE_FREE(ptr) do { \
    free(ptr);              \
    (ptr) = NULL;           \
} while (0)

// NOTE: SAFE_FREE expands its argument twice - pass a plain lvalue, never an
// expression with a side effect. See the explanation below.

typedef struct {
    char *name;
    int *values;
} Resource;

void cleanup_resource(Resource *res) {
    if (res == NULL) return;
    SAFE_FREE(res->name);
    SAFE_FREE(res->values);
}

int main(void) {
    Resource res = { .name = NULL, .values = NULL };

    res.name = malloc(100);
    res.values = malloc(sizeof(int) * 10);
    if (res.name == NULL || res.values == NULL) {
        cleanup_resource(&res);
        return 1;
    }

    cleanup_resource(&res);
    cleanup_resource(&res);  // safe: both members are already NULL

    return 0;
}

Why this works: Pairing the free and the assignment in one macro means the two cannot drift apart the way they do when a maintainer adds a free on a new error path and forgets the line under it. There is no NULL guard because free(NULL) is already a no-op, and the assignment is harmless on a pointer that is already NULL.

Two things about the macro are worth knowing before it goes into a header. It expands its argument twice, so an argument with a side effect - SAFE_FREE(*p++) - frees one pointer and nulls a different one; only pass it a plain lvalue. And it deliberately is not the void safe_free(void **pp) function that usually appears alongside this pattern: safe_free(&res->name) passes a char ** where a void ** is expected, which is a constraint violation requiring a diagnostic, and the (void **) cast that silences it is only guaranteed to work for char * members - C requires void * and char * to share a representation but says nothing of the sort about int *, so (void **)&res->values is not strictly conforming. The macro has neither problem because it never changes the pointer's type.

Both of these are still mitigations rather than the fix. The macro makes a second release harmless for the variable it was applied to; it does not decide who owns the allocation, and it cannot reach a copy of the address held anywhere else. Where the codebase allows it, C++ std::unique_ptr moves that decision into the type system - see the C++ guidance.

Give a Shared Structure a Single Destroy Entry Point

// Answers "Use-After-Free Leading to Double Free" above: the caller cannot
// hold a stale head pointer, because destroying the list nulls it.
void list_destroy(Node **headp) {
    if (headp == NULL) return;

    Node *current = *headp;
    *headp = NULL;              // clear the caller's handle before freeing anything

    while (current != NULL) {
        Node *next = current->next;
        free(current->data);
        free(current);
        current = next;
    }
}

// Caller
Node *list = create_list();
list_destroy(&list);
list_destroy(&list);   // safe: list is NULL, so the loop never starts

Why this works: The caller's handle is NULL when the function returns, so a second list_destroy(&list) sees NULL and does nothing - the same T ** technique as the other patterns here, applied to a whole structure rather than a single allocation.

Clearing *headp before the traversal rather than after is worth doing, for a narrower reason than it may appear. Within a single thread it means anything reached from inside the loop - a per-node destructor, a cleanup routine that walks the same handle, an atexit path - finds NULL rather than a list halfway through being freed. It is not a concurrency fix and must not be read as one: the load of *headp and the store of NULL are two separate non-atomic operations, so another thread or a signal handler can still observe the old head between them. Making this safe against either needs a lock held across the whole teardown, or an atomic exchange to claim the head, and neither is what this pattern is.

For a structure genuinely reachable from more than one place, nulling one handle is not enough at all: a second owner holding its own Node * into the middle of the list is unaffected by anything done to *headp. At that point the structure needs a reference count, with list_destroy decrementing it and freeing only at zero - or a design in which exactly one component holds the list and the others ask it for data.

Testing

  • Compile with AddressSanitizer (-fsanitize=address -g -O1) - it reports double frees with the exact call stacks of both free() calls. The -g is what makes those stacks readable; without it the report is bare addresses.
  • Run under Valgrind (valgrind --tool=memcheck) as an independent check.
  • Explicitly exercise every error path and every function that walks a shared data structure more than once.

Additional Resources