Use-after-free (CWE-416) is one of those bug classes that I wanted to understand deeply because it keeps showing up at the root of high-profile exploits. It occurs when a program continues to use a pointer after the memory it references has been freed. The freed memory may be reallocated for a different purpose, and the dangling pointer now reads or writes data that belongs to a completely different object. Attackers exploit this by controlling what gets allocated into the freed slot, replacing a data buffer with a crafted object that contains a function pointer, then triggering the dangling pointer to call through it. Reading through CVE databases, use-after-free is at the root of hundreds of browser exploits, kernel privilege escalations, and server compromises. This post covers C and C++, from the obvious free-then-use to the subtle shared-pointer aliasing and callback registration patterns that can evade expert review.

Why Use-After-Free Is Exploitable

When free() returns memory to the heap allocator, the allocator adds the block to a free list. The next malloc() of a similar size may return the exact same address. The dangling pointer now points to the new allocation. If the attacker controls the content of the new allocation, they control what the dangling pointer sees.

The exploitation chain works like this:

  1. Trigger the free: Cause the program to free an object while a pointer to it still exists.
  2. Reclaim the memory: Allocate a new object of the same size, which the allocator places at the same address.
  3. Use the dangling pointer: The program reads from or writes through the old pointer, now accessing the attacker’s data.
  4. Hijack control flow: If the original object had a function pointer or vtable pointer, the attacker’s replacement object provides a controlled value, redirecting execution.

Modern heap allocators (jemalloc, tcmalloc, PartitionAlloc) use size-class segregation and randomization to make exploitation harder, but not impossible. Chrome’s PartitionAlloc and Firefox’s mozjemalloc were specifically designed to mitigate use-after-free, yet new exploits continue to appear. The takeaway from the research is clear: mitigations buy you time, they don’t buy you safety.

The Easy-to-Spot Version

C: Free Then Dereference

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char name[64];
    void (*callback)(const char *);
} Handler;

void default_handler(const char *msg) {
    printf("Handler: %s\n", msg);
}

int main() {
    Handler *h = malloc(sizeof(Handler));
    strcpy(h->name, "primary");
    h->callback = default_handler;

    h->callback("initialized");

    free(h);

    // Use after free: h is dangling
    printf("Handler name: %s\n", h->name);
    h->callback("after free");

    return 0;
}

After free(h), the pointer h is dangling. The printf reads freed memory (information leak), and h->callback("after free") calls through a function pointer in freed memory. If an attacker can allocate a controlled buffer of sizeof(Handler) bytes between the free and the callback invocation, they replace the callback field with a pointer to their shellcode or a ROP gadget.

Every SAST tool flags this pattern, the free() and subsequent dereference are in the same function with no intervening reassignment. If only real-world bugs were this obvious.

C++: Delete Then Use Member

#include <iostream>
#include <string>

class Connection {
public:
    std::string host;
    int port;
    bool connected;

    Connection(const std::string& h, int p) : host(h), port(p), connected(true) {}

    void disconnect() {
        connected = false;
        std::cout << "Disconnected from " << host << ":" << port << std::endl;
    }

    void sendData(const std::string& data) {
        if (connected) {
            std::cout << "Sending to " << host << ": " << data << std::endl;
        }
    }
};

int main() {
    Connection* conn = new Connection("db.internal", 5432);
    conn->sendData("SELECT 1");

    delete conn;

    // Use after free
    conn->sendData("SELECT * FROM users");

    return 0;
}

After delete conn, the sendData call accesses the freed Connection object. The std::string host member has been destroyed, its internal buffer is freed. Accessing it is undefined behaviour that may crash, return garbage, or appear to work (if the memory hasn’t been reused yet). That intermittent nature is what makes this bug so treacherous in testing, it can pass thousands of test runs and then blow up in production under load when memory reuse patterns change.

The Hard-to-Spot Version

C: Dangling Pointer Through Realloc

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct Node {
    int id;
    char data[128];
    struct Node *next;
} Node;

typedef struct {
    Node *head;
    Node *cache;  // Points to the most recently accessed node
} LinkedList;

void list_append(LinkedList *list, int id, const char *data) {
    Node *node = malloc(sizeof(Node));
    node->id = id;
    strncpy(node->data, data, sizeof(node->data) - 1);
    node->data[sizeof(node->data) - 1] = '\0';
    node->next = list->head;
    list->head = node;
}

Node* list_find(LinkedList *list, int id) {
    Node *current = list->head;
    while (current) {
        if (current->id == id) {
            list->cache = current;  // Cache for fast re-access
            return current;
        }
        current = current->next;
    }
    return NULL;
}

void list_remove(LinkedList *list, int id) {
    Node *prev = NULL;
    Node *current = list->head;
    while (current) {
        if (current->id == id) {
            if (prev) {
                prev->next = current->next;
            } else {
                list->head = current->next;
            }
            free(current);
            return;
            // Bug: list->cache may still point to the freed node
        }
        prev = current;
        current = current->next;
    }
}

void list_print_cached(LinkedList *list) {
    if (list->cache) {
        // Use after free if cached node was removed
        printf("Cached: id=%d data=%s\n", list->cache->id, list->cache->data);
    }
}

This is the kind of bug that’s genuinely hard to catch. The cache pointer creates an alias to a node in the list. When list_remove frees a node, it doesn’t check whether cache points to the same node. A subsequent call to list_print_cached dereferences the dangling cache pointer. This pattern shows up frequently in data structures that maintain auxiliary pointers (iterators, cursors, LRU caches) alongside the primary structure.

The bug requires a specific sequence: find a node (populating the cache), remove that node, then access the cache. In testing, the sequence may not occur, and the freed memory may not be reused, so the bug appears to work correctly. I’ve caught myself missing bugs like this during reviews, the aliasing is easy to overlook when you’re focused on the list operations.

C++: Shared Pointer Aliasing with Raw Pointer Escape

#include <memory>
#include <vector>
#include <iostream>
#include <functional>

class EventBus {
public:
    struct Listener {
        std::string name;
        std::function<void(const std::string&)> callback;
    };

    void subscribe(std::shared_ptr<Listener> listener) {
        listeners_.push_back(listener);
    }

    void fire(const std::string& event) {
        for (auto& listener : listeners_) {
            listener->callback(event);
        }
    }

    void cleanup() {
        listeners_.erase(
            std::remove_if(listeners_.begin(), listeners_.end(),
                [](const std::shared_ptr<Listener>& l) {
                    return l.use_count() == 1;  // Only the bus holds it
                }),
            listeners_.end()
        );
    }

private:
    std::vector<std::shared_ptr<Listener>> listeners_;
};

class Service {
    EventBus& bus_;
    EventBus::Listener* cachedListener_ = nullptr;  // Raw pointer alias

public:
    Service(EventBus& bus) : bus_(bus) {}

    void registerHandler() {
        auto listener = std::make_shared<EventBus::Listener>();
        listener->name = "service_handler";
        listener->callback = [this](const std::string& event) {
            handleEvent(event);
        };
        cachedListener_ = listener.get();  // Raw pointer escapes shared_ptr
        bus_.subscribe(listener);
    }

    void handleEvent(const std::string& event) {
        std::cout << "Handling: " << event << std::endl;
    }

    void printListenerInfo() {
        if (cachedListener_) {
            // Use after free if cleanup() removed the listener
            std::cout << "Listener: " << cachedListener_->name << std::endl;
        }
    }
};

This one is insidious, and it’s a great example for understanding why smart pointers don’t automatically prevent use-after-free. The Service stores a raw pointer (cachedListener_) obtained from shared_ptr::get(). When EventBus::cleanup() removes listeners with a reference count of 1, the shared_ptr is destroyed and the Listener is freed. But cachedListener_ still holds the raw address. The subsequent printListenerInfo() call dereferences freed memory.

What makes this particularly deceptive is that the code uses smart pointers, it looks like it should be safe. The vulnerability is the raw pointer escape via .get(), which creates an untracked alias that the reference counting cannot protect. This pattern shows up in production C++ codebases more than you’d expect, usually in event-driven architectures where components need quick access to registered handlers.

C: Double Free Leading to Use-After-Free

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char *buffer;
    size_t size;
} Message;

Message* create_message(const char *text) {
    Message *msg = malloc(sizeof(Message));
    msg->size = strlen(text) + 1;
    msg->buffer = malloc(msg->size);
    memcpy(msg->buffer, text, msg->size);
    return msg;
}

void destroy_message(Message *msg) {
    free(msg->buffer);
    free(msg);
}

Message* duplicate_message(Message *original) {
    Message *copy = malloc(sizeof(Message));
    copy->size = original->size;
    copy->buffer = original->buffer;  // Shallow copy, shares the buffer
    return copy;
}

int main() {
    Message *original = create_message("sensitive data");
    Message *copy = duplicate_message(original);

    destroy_message(original);
    // copy->buffer is now dangling, it pointed to original's buffer

    // Use after free: reading freed memory
    printf("Copy: %s\n", copy->buffer);

    // Double free when destroying the copy
    destroy_message(copy);

    return 0;
}

duplicate_message performs a shallow copy, both original and copy share the same buffer pointer. When original is destroyed, copy->buffer becomes dangling. Reading it leaks whatever now occupies that memory. Destroying copy frees the same buffer again (double free), corrupting the heap allocator’s metadata and potentially enabling further exploitation.

Shallow copies of structures containing pointers are a perennial source of use-after-free bugs. What I found interesting researching this is how consistently this mistake appears across codebases of all maturity levels. The fix is a deep copy that allocates a new buffer.

Detection Strategies

Static Analysis

Tool Language What It Catches Limitations
Clang Static Analyzer C/C++ Simple use-after-free, double free Limited interprocedural analysis
cppcheck C/C++ Use after free in same function Cannot track pointers across function boundaries
Infer (Facebook) C/C++ Use-after-free, null dereference, memory leaks May produce false positives on complex code
Coverity C/C++ Use-after-free, double free, dangling pointers Commercial tool, deep interprocedural analysis
Clang -fsanitize=address C/C++ Runtime detection of use-after-free Requires test execution with triggering inputs

Runtime Detection

Tool How It Works Overhead
AddressSanitizer (ASan) Quarantines freed memory, detects access to quarantined regions 2x memory, 2x slowdown
Valgrind (Memcheck) Tracks all allocations, detects reads/writes to freed memory 10-50x slowdown
Electric Fence Places each allocation on a page boundary, unmaps on free High memory overhead
GWP-ASan Sampling-based ASan for production, low overhead Probabilistic detection

Manual Review Indicators

These are the patterns worth training yourself to look for during code reviews:

  1. Raw pointers stored alongside smart pointers, .get() on a shared_ptr or unique_ptr creates an untracked alias.
  2. Cache/cursor pointers in data structures, any auxiliary pointer that is not updated when the primary structure is modified.
  3. Shallow copies of structures containing pointers, memcpy of a struct or assignment without deep-copying pointed-to data.
  4. Callback registrations that outlive the registered object, event handlers, signal handlers, and timer callbacks that reference freed objects.
  5. Error paths that free resources but continue execution, a free() in an error handler followed by a goto or fall-through that uses the freed pointer.
  6. Container modifications during iteration in C++, erase during a range-based for loop invalidates iterators.

Remediation

C: Nullify After Free

void list_remove(LinkedList *list, int id) {
    Node *prev = NULL;
    Node *current = list->head;
    while (current) {
        if (current->id == id) {
            if (prev) {
                prev->next = current->next;
            } else {
                list->head = current->next;
            }
            // Clear cache if it points to the removed node
            if (list->cache == current) {
                list->cache = NULL;
            }
            free(current);
            return;
        }
        prev = current;
        current = current->next;
    }
}

Check and nullify all aliases before freeing. This is a manual discipline, the compiler can’t enforce it, and that’s what makes it so fragile. For complex data structures, a generation counter is worth considering: each node has a generation number, and pointers store both the address and the expected generation. After free and reallocation, the generation changes, and stale pointers detect the mismatch.

C: Deep Copy for Duplication

Message* duplicate_message(Message *original) {
    Message *copy = malloc(sizeof(Message));
    copy->size = original->size;
    copy->buffer = malloc(copy->size);
    memcpy(copy->buffer, original->buffer, copy->size);
    return copy;
}

Allocate a new buffer and copy the contents. Each Message owns its own buffer, and freeing one does not affect the other. Simple, but the shallow-copy version shows up in real code with surprising frequency.

C++: Eliminate Raw Pointer Escapes

class Service {
    EventBus& bus_;
    std::weak_ptr<EventBus::Listener> cachedListener_;  // Weak reference

public:
    Service(EventBus& bus) : bus_(bus) {}

    void registerHandler() {
        auto listener = std::make_shared<EventBus::Listener>();
        listener->name = "service_handler";
        listener->callback = [this](const std::string& event) {
            handleEvent(event);
        };
        cachedListener_ = listener;  // Store as weak_ptr
        bus_.subscribe(listener);
    }

    void handleEvent(const std::string& event) {
        std::cout << "Handling: " << event << std::endl;
    }

    void printListenerInfo() {
        if (auto listener = cachedListener_.lock()) {
            // Safe: lock() returns nullptr if the object was freed
            std::cout << "Listener: " << listener->name << std::endl;
        } else {
            std::cout << "Listener no longer exists" << std::endl;
        }
    }
};

Replace the raw pointer with std::weak_ptr. The lock() method returns a shared_ptr if the object still exists, or nullptr if it has been freed. This is the idiomatic C++ solution for non-owning references to shared objects, and it’s the first thing worth suggesting whenever you see .get() being stored somewhere.

C++: RAII and Unique Ownership

#include <memory>
#include <iostream>

class Resource {
    std::string name_;
public:
    Resource(const std::string& name) : name_(name) {
        std::cout << "Resource created: " << name_ << std::endl;
    }
    ~Resource() {
        std::cout << "Resource destroyed: " << name_ << std::endl;
    }
    const std::string& name() const { return name_; }
};

class ResourceManager {
    std::unique_ptr<Resource> resource_;
public:
    void create(const std::string& name) {
        resource_ = std::make_unique<Resource>(name);
    }

    // Return a raw pointer for read-only access, caller must not store it
    // Better: return a reference or use shared_ptr if lifetime is uncertain
    const Resource* get() const {
        return resource_.get();
    }

    void reset() {
        resource_.reset();  // Destroys the resource
    }
};

Use unique_ptr for exclusive ownership. The resource is automatically destroyed when the unique_ptr goes out of scope or is reset. If multiple components need access, use shared_ptr with weak_ptr for non-owning references.

Key Takeaways

  1. Every free() or delete must invalidate all pointers to the freed object. This is the fundamental discipline for preventing use-after-free in C/C++, and it’s the one that the research shows gets violated most often.
  2. Never store raw pointers obtained from smart pointers. Use weak_ptr for non-owning references to shared_ptr-managed objects. Seeing .get() stored in a member variable is a reliable red flag.
  3. Deep-copy structures that contain pointers. Shallow copies create shared ownership without the tracking to manage it.
  4. Enable AddressSanitizer in CI. ASan catches use-after-free at runtime with moderate overhead and should be part of every C/C++ project’s test pipeline. If you take one thing from this post, let it be this.
  5. Consider Rust for new projects where use-after-free is a concern. Rust’s ownership model prevents use-after-free at compile time (outside of unsafe blocks). The more I research memory safety bugs, the more compelling the case for Rust becomes for new systems code.
  6. Audit callback and event handler registrations. These are the most common source of use-after-free in large codebases, because the registration outlives the object. It’s a pattern worth specifically looking for during reviews.