CWE-415: Double Free
Overview
Double free occurs when free() is called twice on the same memory pointer without reallocation between calls, corrupting heap metadata and enabling arbitrary code execution through heap exploitation, or causing application crashes.
Risk
Critical: Double free enables heap corruption, arbitrary code execution (overwrite function pointers, GOT entries), information disclosure (reading freed memory), denial of service (crashes), and is commonly exploited in modern attacks.
Remediation Steps
Core principle: Enforce single ownership for memory and never free the same allocation twice.
Locate the Double Free Vulnerability
When reviewing security scan results:
- Examine data_paths: Trace where the same pointer is freed multiple times
- Identify free locations: Find all free() or delete calls for the same pointer
- Check control flow: Look for multiple execution paths that free the same memory
- Review exception handling: Ensure exception unwind doesn't cause double free
- Analyze cleanup code: Check destructors, cleanup functions, error handlers
Use Smart Pointers (Primary Defense - C++)
// std::unique_ptr (single ownership)
std::unique_ptr<Object> ptr(new Object());
// Automatically freed, can't double-free
// Better: use make_unique
auto ptr = std::make_unique<Object>();
// std::shared_ptr (shared ownership)
std::shared_ptr<Object> ptr = std::make_shared<Object>();
// Reference counted, automatically freed when last reference goes away
Why this works: Smart pointers automatically manage memory lifetime and prevent double frees. unique_ptr has single ownership semantics - moving it transfers ownership, preventing accidental double delete. shared_ptr uses reference counting to ensure memory is freed exactly once when the last reference is destroyed.
Set Pointers to NULL After Free (C)
For C code without smart pointers:
free(ptr);
ptr = NULL; // Prevents double-free
// Later code
if (ptr != NULL) {
free(ptr); // Safe: won't execute if already freed
ptr = NULL;
}
Implementation details:
- Always set pointers to NULL immediately after freeing
- Check for NULL before every free() call
- Use this pattern consistently throughout the codebase
- Document the ownership model clearly
Implement Defensive Patterns
Create safe wrapper functions:
void safe_free(void **pp) {
if (pp != NULL && *pp != NULL) {
free(*pp);
*pp = NULL;
}
}
// Usage
safe_free((void**)&ptr);
// Idempotent: can call multiple times safely
Additional defensive techniques:
- Use wrapper macros:
#define SAFE_FREE(p) do { free(p); (p) = NULL; } while(0) - Implement custom allocators that track allocations
- Use memory debugging tools during development
- Establish clear ownership rules for all pointers
Avoid Manual Memory Management
Modern approaches:
- Use RAII in C++ (Resource Acquisition Is Initialization)
- Prefer garbage-collected languages when security-critical
- Minimize manual free() calls - use containers and smart pointers
- Use memory pools with clear ownership semantics
- Consider arena allocators where entire pools are freed at once
C++ RAII example:
class Resource {
public:
Resource() : data(new char[100]) {}
~Resource() { delete[] data; } // Automatic cleanup
Resource(const Resource&) = delete; // Prevent copies
private:
char* data;
};
Test for Double Free Vulnerabilities
Testing strategies:
- Enable memory sanitizers:
-fsanitize=address(AddressSanitizer) - Use Valgrind to detect double frees:
valgrind --tool=memcheck - Test all error handling paths explicitly
- Review code with static analysis tools
- Run fuzzing tests to trigger edge cases
Verification steps:
- Run test suite with memory debugging enabled
- Check for heap corruption errors
- Test exception scenarios and cleanup paths
- Verify with dynamic analysis tools
Common Vulnerable Patterns
Conditional Double Free
char *buf = malloc(100);
free(buf);
if (error) {
free(buf); // DOUBLE FREE! Same pointer freed twice
}
Why is this vulnerable: The buffer is freed unconditionally on the first line, then freed again if the error condition is true. After the first free(buf), the memory is returned to the heap allocator, but buf still holds the same address value (a "dangling pointer"). When the error path executes free(buf) again, it passes the same address to the allocator twice. This corrupts the heap's internal metadata (free lists, bin structures) because the allocator attempts to mark the same memory block as free twice, potentially creating circular references in free lists. Attackers can exploit this to achieve arbitrary write primitives by manipulating heap chunk metadata, leading to code execution when the corrupted heap structures are used in subsequent allocations.
Multiple Execution Paths
void func(char *data) {
if (condition) {
free(data);
return;
}
process(data);
free(data); // Might double-free if already freed above
}
Why is this vulnerable: When condition is true, data is freed and the function returns early. However, if the caller doesn't know the function freed the memory and calls func again, or if there's a code path that frees data before calling this function, the bottom free(data) executes on already-freed memory. This pattern is especially dangerous because the vulnerability depends on runtime conditions and control flow, making it hard to detect through code inspection alone. In complex codebases with multiple layers of function calls, ownership semantics become unclear - it's ambiguous whether the caller or callee is responsible for freeing memory. This ambiguity leads to defensive programming where both caller and callee free the same pointer "just to be safe," creating a double-free vulnerability.
Exception-Based Double Free
try {
free(ptr);
throw exception();
} catch (...) {
free(ptr); // DOUBLE FREE! Exception unwind frees already-freed pointer
}
Why is this vulnerable: The free(ptr) in the try block executes successfully, releasing the memory. When the exception is thrown immediately after, execution jumps to the catch block, which then attempts to free the same pointer again during cleanup. This pattern reflects a fundamental misunderstanding of exception handling - developers often write catch blocks that attempt to "clean up everything" without considering what was already cleaned up before the exception. In more complex scenarios, the double-free can occur through constructor/destructor chains: if an object's constructor throws after partially initializing members (some of which were dynamically allocated), and the destructor is written to unconditionally free all members, those partial allocations get freed twice - once during exception unwind and once in the destructor. This is why C++ RAII exists - to handle cleanup automatically without manual intervention.
Use-After-Free Leading to Double Free
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 stale reference
delete_list(list); // DOUBLE FREE of all nodes
Why is this vulnerable: The first delete_list(list) frees all nodes in the linked list and their data. However, the list pointer itself isn't nullified, so it still points to the freed memory (a dangling pointer). If any other code path holds a reference to list and later calls delete_list again - perhaps in an error handler, cleanup routine, or different thread - it traverses what it thinks is a valid list but is actually freed memory. This double-frees every node. Even worse, the memory might have been reallocated between the two calls, so the second delete_list could corrupt completely unrelated data structures. This pattern is common in complex data structures where multiple parts of the code keep references without clear ownership rules.
Destructor/Cleanup Double Free
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 is this vulnerable: The cleanup() method deletes the buffer to release resources early, but doesn't set buffer = nullptr. When res goes out of scope, the destructor runs automatically and attempts to delete the same buffer again. This pattern violates the RAII principle - resources should either be managed automatically (destructor only) or manually, not both. Mixing automatic and manual cleanup creates ambiguity about resource ownership and lifecycle. The vulnerability becomes worse when exceptions are involved: if an exception occurs after cleanup(), the destructor still runs during stack unwinding, guaranteeing a double-free. The correct fix is to either remove the manual cleanup() method and rely on RAII, or implement proper state tracking (buffer = nullptr after delete) and defensive checks (if (buffer != nullptr)) in the destructor.
Secure Patterns
std::unique_ptr for Single Ownership (C++)
#include <memory>
class DataProcessor {
private:
std::unique_ptr<char[]> buffer;
public:
DataProcessor(size_t size) : buffer(std::make_unique<char[]>(size)) {}
// Automatically freed when DataProcessor is destroyed
// 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;
};
// Usage
void processData() {
auto processor = std::make_unique<DataProcessor>(1024);
// Memory automatically freed when processor goes out of scope
// Impossible to double-free
}
Why this works: std::unique_ptr enforces single ownership semantics at compile time, making double-free impossible. When the unique_ptr goes out of scope or is reset, it automatically calls delete exactly once on the managed pointer, then sets its internal pointer to nullptr. The deleted copy constructor prevents accidental duplication of ownership. Move operations transfer ownership by nullifying the source pointer, ensuring only one unique_ptr ever owns the resource. This RAII (Resource Acquisition Is Initialization) pattern guarantees deterministic cleanup without manual intervention, eliminating the entire class of double-free vulnerabilities that arise from manual memory management.
std::shared_ptr for Shared Ownership (C++)
#include <memory>
#include <vector>
class SharedResource {
private:
std::vector<int> data;
public:
SharedResource(size_t size) : data(size) {}
void process() { /* ... */ }
};
void shareResourceSafely() {
// Create shared resource
auto resource = std::make_shared<SharedResource>(100);
std::vector<std::shared_ptr<SharedResource>> workers;
// Multiple owners share the same resource
for (int i = 0; i < 10; i++) {
workers.push_back(resource); // Reference count increases
}
// Resource is freed only when last shared_ptr is destroyed
// No manual tracking needed, no double-free possible
}
Why this works: std::shared_ptr uses atomic reference counting to track how many pointers share ownership of a resource. Each copy of a shared_ptr increments the reference count, and each destruction decrements it. When the count reaches zero, the resource is freed exactly once. The reference counting is thread-safe using atomic operations, preventing race conditions in concurrent code. Unlike manual reference counting, shared_ptr handles the count automatically, eliminating human error in tracking ownership. The control block (storing the reference count) is allocated separately from the managed object, ensuring the count survives even if the original pointer is destroyed, guaranteeing single deletion when all owners release their references.
NULL Pointer Pattern (C)
#include <stdlib.h>
#include <stdio.h>
typedef struct {
char *data;
size_t size;
} Buffer;
void buffer_free(Buffer *buf) {
if (buf == NULL) return;
// Free the data and immediately set to NULL
if (buf->data != NULL) {
free(buf->data);
buf->data = NULL; // Critical: prevents double-free
}
// Free the buffer itself
free(buf);
}
int process_data(const char *input) {
Buffer *buf = malloc(sizeof(Buffer));
if (buf == NULL) return -1;
buf->size = 1024;
buf->data = malloc(buf->size);
if (buf->data == NULL) {
free(buf);
return -1;
}
// Process data...
if (/* error condition */) {
buffer_free(buf);
return -1;
}
buffer_free(buf);
return 0;
// Safe: buffer_free checks for NULL, second call is harmless
}
Why this is a compromise: Setting pointers to NULL after freeing prevents crashes from double-free because free(NULL) is a no-op in C. However, this is a defensive workaround that masks underlying design flaws - properly designed code should never call free twice on the same pointer. The NULL check hides the bug instead of preventing it, allowing double-free attempts to silently succeed rather than failing obviously during development. This pattern is acceptable only in legacy codebases or complex error-handling scenarios where refactoring ownership is impractical. The correct solution is to ensure clear ownership semantics (single owner responsible for freeing) or use RAII patterns in C++ (unique_ptr/shared_ptr). The NULL pattern should be a last resort when architectural fixes aren't feasible, not the primary defense against double-free.
Safe Free Wrapper (C)
#include <stdlib.h>
// Macro version - type-safe and efficient
#define SAFE_FREE(ptr) do { \
if ((ptr) != NULL) { \
free(ptr); \
(ptr) = NULL; \
} \
} while(0)
// Function version for pointer-to-pointer
void safe_free(void **pp) {
if (pp != NULL && *pp != NULL) {
free(*pp);
*pp = NULL;
}
}
// Usage example
typedef struct {
char *name;
int *values;
} Resource;
void cleanup_resource(Resource *res) {
if (res == NULL) return;
// Safe to call multiple times
SAFE_FREE(res->name);
SAFE_FREE(res->values);
// Or using function version
safe_free((void**)&res->name);
safe_free((void**)&res->values);
}
int main() {
Resource res = {
.name = malloc(100),
.values = malloc(sizeof(int) * 10)
};
cleanup_resource(&res);
cleanup_resource(&res); // Safe: idempotent
return 0;
}
Why this is a compromise: The safe free wrapper makes the NULL-check-and-nullify pattern consistent and harder to misuse by encapsulating it in a macro or function. However, like the NULL pointer pattern itself, this is a defensive measure that hides design problems - code shouldn't need idempotent cleanup functions that can be called multiple times. The wrapper makes double-free attempts safe rather than impossible, trading silent bugs for crash prevention. While useful for legacy C code where ownership refactoring is impractical, it's not the ideal solution. Better approaches include clear ownership rules (each allocation has exactly one responsible free), RAII in C++ (unique_ptr/shared_ptr), or arena/pool allocators that batch allocations. Use this pattern only when architectural improvements aren't feasible, and document why cleanup might execute multiple times.
RAII with Custom Deleters (C++)
#include <memory>
#include <cstdio>
// Custom deleter for FILE*
struct FileCloser {
void operator()(FILE* fp) const {
if (fp != nullptr) {
fclose(fp);
}
}
};
using FilePtr = std::unique_ptr<FILE, FileCloser>;
// Factory function
FilePtr openFile(const char* path, const char* mode) {
return FilePtr(fopen(path, mode));
}
void processFile(const char* filename) {
FilePtr file = openFile(filename, "r");
if (!file) {
return; // File not opened, no cleanup needed
}
// Process file...
// Automatically closed when file goes out of scope
// Cannot be double-closed
}
// Custom deleter for array allocated with malloc
struct MallocDeleter {
void operator()(void* ptr) const {
free(ptr);
}
};
template<typename T>
using MallocPtr = std::unique_ptr<T, MallocDeleter>;
MallocPtr<char> allocateBuffer(size_t size) {
return MallocPtr<char>(static_cast<char*>(malloc(size)));
}
Why this works: Custom deleters extend RAII beyond new/delete to any resource that requires cleanup, including FILE handles, malloc'd memory, OS handles, and library-specific resources. The deleter is called exactly once when the unique_ptr is destroyed or reset, ensuring proper cleanup without double-free risk. By wrapping C-style resources in smart pointers with custom deleters, you get automatic cleanup, exception safety, and compile-time ownership enforcement. The deleter's NULL check makes the cleanup idempotent, and the smart pointer's move semantics ensure ownership transfer without duplication. This pattern bridges C++ safety guarantees with C APIs, eliminating manual cleanup code that's prone to double-free vulnerabilities in error paths and exception scenarios.