Skip to content

CWE-416: Use After Free - C++

Overview

In C++, use-after-free occurs when code dereferences, reads, or writes through a pointer or reference after the object it referred to has been deleted - directly via a dangling raw pointer, or indirectly via a container, callback, or alias that still holds the old address. The freed memory may already have been reallocated to an unrelated object by the time it's accessed.

Primary Defence: Tie an object's lifetime to a scope or to reference-counted ownership using std::unique_ptr/std::shared_ptr rather than managing raw pointer lifetimes by hand, and make every non-owning alias - a raw pointer, a reference, an iterator, a std::string_view or std::span - either shorter-lived than what it refers to or a std::weak_ptr that can be asked. Most use-after-free in current C++ is the second half rather than the first: the owner is correct and something is still looking at the object through a view that outlived it.

Common Vulnerable Patterns

Dangling Alias After Delete

// VULNERABLE - alias still points at obj's memory after obj is deleted
struct Object { int field; };

Object *obj = new Object();
Object *alias = obj;
delete obj;
alias->field = 5;  // USE AFTER FREE

Why this is vulnerable: alias and obj point to the same memory, but only obj is deleted explicitly. alias is now a dangling pointer - the compiler has no way to know it refers to freed memory, so alias->field = 5 writes into memory the allocator may have already reused, corrupting an unrelated object.

Callback Capturing by Reference

// VULNERABLE - the lambda outlives what it captured
#include <functional>
#include <memory>

struct Session { void touch(); };

std::function<void()> makeHandler() {
    auto session = std::make_unique<Session>();

    return [&session] {          // captures the *local variable* by reference
        session->touch();        // USE AFTER FREE once makeHandler has returned
    };
}

Why this is vulnerable: [&session] captures a reference to the local unique_ptr, not to the Session. The moment makeHandler returns, that local is destroyed - which both ends the reference's validity and deletes the Session - so every later call through the returned std::function reads a destroyed object to reach an already-deleted one. Nothing at the call site looks wrong, because the std::function is a perfectly valid object; what it closed over is not.

[&] is where this usually arrives. It is the shortest capture to type and the default in most examples, and it is safe exactly as long as the closure does not outlive the enclosing scope - a condition that holds for std::sort and fails for anything queued, stored, or posted to an executor. The tell is not the capture list but the destination: a lambda that is returned, stored in a member, or handed to a scheduler must own or share what it uses.

Note which of the alternatives the compiler rejects. [session] by value does not compile, because unique_ptr is move-only. [session = std::move(session)] fixes the lifetime and still does not compile as written, because std::function requires a copyable callable - it needs std::move_only_function<void()> (C++23) or a shared_ptr capture instead. Only [&session], the one that is wrong, compiles first time. That ordering is what keeps this shape in codebases: every safe option costs a second edit and the unsafe one costs none.

Callback With Freed Context

// VULNERABLE - ctx may already be freed by the time the callback fires
void registerCallback(void (*cb)(void*), void *ctx);

void callback(void *ctx) {
    MyData *data = static_cast<MyData*>(ctx);
    data->value = 42;  // ctx might already be freed by the time this runs
}

Why this is vulnerable: The registration API takes a plain function pointer and a void*, so the context arrives stripped of everything the type system knew about it - including whether anyone is keeping it alive. Nothing guarantees the object outlives the registration, and the callback has no way to ask: a void* to a freed object is indistinguishable from a void* to a live one.

This is a C-shaped API, and the usual advice does not apply to it as written. "Capture a std::shared_ptr" is the right idea and cannot be done here, because a lambda with captures does not convert to void (*)(void*) - only a captureless one does. Either the context pointer must itself carry ownership (see the secure pattern below), or registerCallback must be replaced with an overload taking std::function<void()> or std::move_only_function<void()> (C++23), which can hold a capturing closure.

A View or Reference Outliving What It Views

// VULNERABLE - the container reallocates and every pointer into it dangles
#include <string>
#include <string_view>
#include <vector>

void tally() {
    std::vector<int> scores{1, 2, 3};
    int &first = scores[0];
    scores.push_back(4);      // may reallocate the whole buffer
    first = 10;               // USE AFTER FREE - first refers to the old buffer
}

void greet() {
    // VULNERABLE - the view outlives the temporary it was built from
    std::string_view name = std::string("alice") + "@example.com";
    use(name);                // USE AFTER FREE - the temporary died at the semicolon
}

Why this is vulnerable: Neither line involves new, delete, or a raw owning pointer, which is why this shape gets missed wherever use-after-free is treated as a manual-memory problem. std::vector::push_back invalidates every pointer, reference and iterator into the vector when it reallocates - the old buffer is deleted and first refers into it. The string_view case is worse for being invisible: the concatenation produces a temporary std::string whose lifetime ends at the end of the full expression, and string_view is a non-owning pair of pointer and length, so name is dangling before it is ever used. Binding a temporary to a reference extends its lifetime; binding it to a view does not, and the two look identical.

What makes this the dominant modern-C++ shape is that the standard library hands out non-owning references everywhere: std::string_view, std::span, c_str(), data(), every iterator, and every reference returned by operator[], front(), back() or at(). Each is valid only until the owner is modified or destroyed, and none of them says so at the use site.

Secure Patterns

Smart Pointers for Automatic Lifetime Management

// std::unique_ptr (single ownership)
auto ptr = std::make_unique<Object>();
ptr->method();  // safe
ptr.reset();    // freed and set to nullptr
// ptr is now nullptr - dereferencing crashes immediately instead of corrupting memory

// std::shared_ptr (shared ownership)
auto shared = std::make_shared<Object>();
auto alias = shared;  // both point to the same object
shared.reset();       // object still valid - alias holds a reference
alias.reset();        // now freed - last reference gone

Why this works: Both types make the release a consequence of the owner's lifetime rather than a call someone has to place correctly. unique_ptr names a single owner, and moving it leaves the source holding null, so there is never a second pointer through which the object can be reached after release. shared_ptr keeps the object alive for as long as any owner holds it, so an alias created by copying one is an owner rather than a hostage to whoever releases first.

Read the guarantee precisely, because it is narrower than "smart pointers prevent use-after-free". It covers accesses made through the smart pointer. Calling get() and keeping the result, taking a reference to *ptr, or storing an iterator into a unique_ptr<std::vector<T>> all produce raw aliases that the smart pointer knows nothing about and will not outlive - which is the failure the later patterns on this page are about.

RAII Ties Lifetime to Scope

#include <cstddef>
#include <vector>

class Resource {
public:
    explicit Resource(std::size_t n) : data(n) {}
    // No destructor, no copy/move declarations: vector already does all of it
    char *bytes() { return data.data(); }   // valid only while this Resource lives
private:
    std::vector<char> data;
};

void useResource() {
    Resource r(100);
    // use r
}   // data is released here, on every path out, including during unwinding

Why this works: The class has no new, no delete, and no destructor, so there is no release call that a future maintainer can duplicate, skip, or reach twice. std::vector<char> releases its buffer when the Resource is destroyed, and because the vector defines copy and move correctly, Resource is copyable and movable without either operation producing a second owner of the same buffer - the rule of zero.

The hand-written alternative holds a char*, deletes it in a destructor, and then assigns data = nullptr after the delete[]. That assignment is a dead store: the object's lifetime is ending, so nothing can read the member afterwards, and a destructor is never re-entered for the same object. Where nulling does matter is the case one line above - a member released by a close()-style method while the object goes on living - and writing it in a destructor teaches the wrong rule for that case.

Note the honest limit in bytes(). RAII fixes who releases the buffer; it does nothing about a caller who keeps the returned pointer past the Resource's lifetime, which is the pattern below.

Own or Share What a Stored Callback Uses

#include <functional>
#include <memory>

// Answers both callback patterns above.

// 1. The closure outlives its enclosing scope, so it owns what it uses.
//    std::function requires a copyable callable, so the capture is a shared_ptr;
//    a moved-in unique_ptr needs std::move_only_function<void()> (C++23) instead.
std::function<void()> makeHandler() {
    auto session = std::make_shared<Session>();
    return [session] { session->touch(); };   // by value: the closure is an owner
}

// 2. The object may legitimately be gone by the time the callback fires,
//    so the callback holds a weak reference and checks.
std::function<void()> makeObserver(const std::shared_ptr<Session> &session) {
    return [weak = std::weak_ptr<Session>(session)] {
        if (auto s = weak.lock()) {   // null if the Session has since been destroyed
            s->touch();
        }
    };
}

// 3. Where a C-shaped `void (*)(void*)` registration cannot be changed,
//    the context pointer has to carry ownership across the boundary.
void registerCallback(void (*cb)(void*), void *ctx);

void trampoline(void *ctx) {
    std::unique_ptr<Session> owned(static_cast<Session*>(ctx));  // takes ownership back
    owned->touch();
}   // destroyed here - so this registration is one-shot, and must be documented as such

void schedule(std::unique_ptr<Session> session) {
    registerCallback(&trampoline, session.release());   // ownership crosses the API
}

Why this works: In each case the callback's own type states what it may assume about the object, so the question "is this still alive?" is answered where the callback is written rather than left to whoever schedules it.

Capturing session by value in (1) makes the closure a co-owner: the Session cannot be destroyed while the std::function exists, because the count cannot reach zero. Note the constraint that shapes the code - std::function requires a copyable callable, so a unique_ptr moved into the capture will not fit it. That is a compile error rather than a lifetime bug, but reaching for [&] is the usual way people get past it, which turns a diagnostic into a use-after-free. std::move_only_function<void()> (C++23) is the type that accepts the moved-in unique_ptr.

(2) is for the case where keeping the object alive is the wrong answer - an observer should not prevent the thing it observes from being destroyed. weak_ptr holds no strong reference, and lock() returns either a shared_ptr that is guaranteed valid for as long as it is held, or null. Checking the result of lock() is not optional and is the whole mechanism; a weak_ptr used via expired() and then lock() is a race, since the object can be destroyed between the two calls.

(3) is the escape hatch for a registration API that cannot be changed. release() hands the raw pointer over and gives up ownership, and the trampoline immediately re-wraps it in a unique_ptr so it is destroyed exactly once, on every path out of the callback including a thrown exception. The cost is that the contract is now in a comment rather than in a type: the callback must fire exactly once, and a registration API that can drop, cancel, or re-fire a callback leaks or double-frees under this scheme. Establish which of those the API does before using it.

Take Views as Parameters, Never Store Them

#include <cstddef>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

// Answers "A View or Reference Outliving What It Views" above.

// A view is safe as a parameter: the caller's object outlives the call.
std::size_t countVowels(std::string_view text);

// Store an owner, not a view. This member cannot dangle.
class Account {
public:
    explicit Account(std::string email) : email_(std::move(email)) {}
    std::string_view email() const { return email_; }   // valid while *this is
private:
    std::string email_;   // NOT std::string_view
};

void appendScore(std::vector<int> &scores, int value) {
    scores.push_back(value);
    int &latest = scores.back();   // taken after the last mutation, used before the next
    latest += 1;
}

Why this works: One rule covers every case above: a non-owning view may live in a parameter or a short-lived local, and may not live in a member, a container, or a return value that outlives its source. A parameter is safe because the caller necessarily holds the argument for the duration of the call. A member is not, because nothing ties its lifetime to the viewed object's.

Account stores std::string and hands out std::string_view rather than the reverse, which puts the ownership decision in one place. email() still returns something that dangles if the Account is destroyed - that is inherent to views and is why the comment says what it is valid for - but it can no longer dangle while the Account is alive, which is the failure the vulnerable version had.

appendScore shows the ordering rule for references into containers: take the reference after the last mutation and drop it before the next. It is not enough to know push_back invalidates - insert, erase, resize, reserve, clear and assignment all do, on different conditions, and std::vector and std::deque have different rules from std::list and std::map. Where a reference must survive a mutation, store an index and re-index, or use a container with stable references such as std::deque (stable for references, not iterators, on end insertion) or std::list.

Testing

  • Compile with AddressSanitizer (-fsanitize=address -g -O1) and run the test suite - it reports use-after-free immediately, with both the free and the invalid access call stacks. Add -fsanitize-address-use-after-scope and run with ASAN_OPTIONS=detect_stack_use_after_return=1 to cover the dangling-reference cases, which are otherwise stack accesses ASan has no reason to object to.
  • Run under Valgrind as an independent check, and know where it stops. Memcheck's addressability tracking covers heap blocks, so it catches the new/delete patterns on this page; it does not red-zone stack frames the way AddressSanitizer does, so a reference into a destroyed stack object or an expired temporary goes unreported. The two tools overlap less here than they do for double free, and neither alone is sufficient.
  • Turn on the compiler's lifetime warnings and treat them as build failures: -Wdangling-reference (GCC 13+, included in -Wall) and Clang's -Wdangling family catch the returned-reference and view-of-a-temporary shapes at compile time, which is the only place some of them can be caught - a dangling std::string_view built from a temporary may never fault, because the freed bytes are usually still readable and still hold the old characters.
  • Exercise every callback and asynchronous path with the owning object destroyed before the callback runs, not only after. A test that always tears down in the convenient order proves nothing about the case the fix exists for.
  • For the container cases, assert against a container that has actually reallocated: reserve a small capacity, take the reference, then push past it. A std::vector that never outgrows its initial buffer keeps every reference valid and the test passes for the wrong reason.

Additional Resources