C++ Use-After-Free (UAF): Causes, ASan, and Ownership Rules

Key takeaways

Use-after-free is touching an object after its storage was released. It rarely crashes at the bug; it corrupts whatever reuses the memory. Learn the common sources (container invalidation, raw pointers into owned objects, callbacks outliving their target), how to read an ASan report, and ownership rules that prevent it.

What use-after-free means

A use-after-free (UAF, CWE-416) is any access to an object after its storage was released: reading or writing through a pointer after delete or free, using a reference to an element that a container has destroyed, or calling a method on an object whose owner has already gone. The C++ standard calls all of these undefined behaviour, which means the compiler and runtime owe you nothing.

int* ptr = new int(10);
delete ptr;
*ptr = 42;  // undefined behaviour: the int no longer exists

The reason UAF is so much worse than a null dereference is that it usually doesn’t crash at the bug. delete hands the block back to the allocator, which typically keeps it mapped and reuses it for the next allocation of a similar size. The write above most likely succeeds, silently changing whatever object now lives there. The program fails somewhere else, sometimes much later, in code that is correct. That distance between cause and symptom is what makes UAF expensive to debug, and it’s also why these bugs are a common route for security exploits: an attacker who can control what gets allocated into the freed slot can control what the stale pointer sees.

Where UAF comes from in practice

Hardly anyone writes delete p; *p = 1; in one function. Real UAFs come from a lifetime ending in one place while a pointer or reference lives on in another.

Container invalidation

A pointer, reference or iterator into a container is only valid as long as the container doesn’t move its elements:

std::vector<int> vec = {1, 2, 3};
int* first = &vec[0];
vec.push_back(4);          // may reallocate: all old element storage is freed
std::cout << *first;       // possibly reads freed memory

Whether push_back reallocates depends on capacity(), so this works in some runs and not others, which is the worst kind of bug to diagnose. std::string behaves the same way on growth, and erase invalidates everything from the erased position on. Holding an index instead of a pointer, or reserving capacity up front when you must hold pointers, avoids it. The same logic applies to erasing while iterating:

for (auto it = v.begin(); it != v.end(); ) {
    if (*it == 2) it = v.erase(it);   // erase returns the next valid iterator
    else ++it;
}

The iterator invalidation post lists the rules per container. std::string_view and std::span are non-owning too; a view into a std::string that gets reassigned or destroyed is a UAF waiting to happen.

Raw pointers into owned objects

Smart pointers make the owner explicit, but .get() hands out a raw pointer that knows nothing about the owner’s lifetime:

auto owner = std::make_unique<Widget>();
Widget* raw = owner.get();
owner.reset();              // Widget destroyed
std::cout << raw->value;    // UAF

The same shape appears with classes that return interior pointers:

class Container {
    int* data;
public:
    Container() : data(new int(10)) {}
    ~Container() { delete data; }
    int* getData() { return data; }
};

int* ptr;
{
    Container c;
    ptr = c.getData();
}           // c destroyed, data deleted
*ptr = 42;  // UAF

(Container also breaks the rule of three: copying it would delete data twice. Holding a std::unique_ptr<int> or a plain int member fixes both problems.)

Returning a reference to a local is the stack-memory version, and it’s the one case compilers reliably catch:

warning: reference to local variable 'name' returned [-Wreturn-local-addr]

Treat that warning as an error. See dangling references for the less obvious variants, such as references bound to temporaries.

Callbacks that outlive their target

Asynchronous code is where UAF becomes routine. A timer, network completion handler or UI callback captures this or a raw pointer, the object is destroyed, and then the callback fires:

void Session::startTimer() {
    timer.onExpire([this] { onTimeout(); }); // `this` may be gone when this runs
}

The standard fix is to capture a std::weak_ptr and check it when the callback runs. This requires the object to be owned by a shared_ptr and to inherit from std::enable_shared_from_this:

class Session : public std::enable_shared_from_this<Session> {
public:
    void startTimer() {
        std::weak_ptr<Session> weak = shared_from_this();
        queue.push_back([weak] {
            if (auto self = weak.lock()) self->onTimeout();
            else std::cout << "session gone, skipping\n";
        });
    }
    void onTimeout() { std::cout << "timeout for live session\n"; }
};

auto s = std::make_shared<Session>();
s->startTimer();
s.reset();                   // Session destroyed before the callback runs
for (auto& f : queue) f();   // prints "session gone, skipping"

weak.lock() either returns a shared_ptr that keeps the object alive for the duration of the call, or an empty one. Capturing a shared_ptr directly ([self = shared_from_this()]) also prevents the UAF, but it keeps the object alive until the callback is destroyed, which can turn a UAF into a leak or a reference cycle. Choose based on whether the callback should extend the object’s life. The alternative without shared ownership is to cancel or unregister callbacks in the destructor, which only works if cancellation is synchronous with respect to the callback thread.

With threads, lifetime and synchronization mix: one thread releasing the last reference while another is still using a raw pointer is a UAF and a data race at once. shared_ptr’s reference count is thread-safe, but only if each thread holds its own shared_ptr copy.

Double free

Deleting the same pointer twice corrupts the allocator’s bookkeeping, and every allocation after that is suspect. It usually has the same root cause as UAF, two places believing they own the same object, and often both bugs appear together. glibc detects some cases and aborts with messages like free(): double free detected in tcache 2; other allocators may carry on silently. The cure is structural: exactly one owner per resource, expressed as a unique_ptr member or RAII wrapper, and no hand-written delete outside it.

Finding UAF with AddressSanitizer

AddressSanitizer (ASan) is the tool that makes UAF tractable. It instruments every memory access and keeps freed blocks in a quarantine instead of reusing them immediately, with their shadow memory marked as poisoned. A stale access is then reported at the moment it happens, not when something unrelated breaks later.

g++ -std=c++17 -O1 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf
./uaf

(Clang takes the same flags; MSVC has /fsanitize=address. Some toolchains, such as the TDM MinGW build of GCC, don’t ship the ASan runtime, and linking fails with cannot find -lasan.)

A report has three stack traces, and reading them in the right order saves time:

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 ...
WRITE of size 4 at 0x602000000010 thread T0
    #0 ... in main uaf.cpp:4

0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 ... in operator delete(void*, unsigned long)
    #1 ... in main uaf.cpp:3

previously allocated by thread T0 here:
    #0 ... in operator new(unsigned long)
    #1 ... in main uaf.cpp:2

The first trace is where the stale access happened. The “freed by” trace is usually the more interesting one: it tells you who ended the lifetime, and the bug is almost always a disagreement between that code and the code in the first trace about who owns the object. The “previously allocated” trace identifies which object it was.

Two limits to know. The quarantine is finite (256 MB by default, tunable with ASAN_OPTIONS=quarantine_size_mb=...), so an access long after the free, once the block has been reused, may be missed or reported against the wrong object. And ASan only sees code paths that actually run, so it needs tests that exercise the lifetime scenario. ASAN_OPTIONS=detect_stack_use_after_return=1 extends detection to references into stack frames that have returned, at extra cost.

Valgrind’s Memcheck finds the same class of bug without recompiling, reporting Invalid read of size 4 followed by Address ... is 0 bytes inside a block of size 4 free'd. It’s far slower, so it tends to be used for one-off investigations rather than whole test suites. See sanitizers and Valgrind for setup.

Without either tool, recognisable fill patterns help: the MSVC debug CRT fills freed heap memory with 0xDD bytes, so a pointer or value of 0xDDDDDDDD in the debugger strongly suggests a read from freed memory.

The failure pattern I see again and again is a UAF that only shows up in release builds. The debug build’s allocator happened not to reuse the freed block before the stale read, so everything looked fine; the release allocator reused it immediately. People then spend a day suspecting the optimizer. Running the test suite under ASan in CI catches these without anyone having to guess.

Ownership rules that prevent UAF

Tools find UAFs; design prevents them. The rules that do most of the work:

  • One owner per object, expressed in the type: a value member, a std::unique_ptr, or a container element. If ownership is genuinely shared, std::shared_ptr, but that should be a decision, not a default.
  • Raw pointers and references are non-owning borrows, and a borrow must not outlive the owner. Function parameters are the safe case: the caller keeps the object alive for the duration of the call. Storing a borrowed pointer in a member or a callback is where lifetimes need to be checked.
  • Don’t hold pointers into containers across mutation. Hold indices or keys, or re-fetch after the container changes.
  • Callbacks capture weak_ptr (or ownership, deliberately), never raw this, when they can run after the capturing object might be gone.
  • No delete outside RAII types. If a single destructor frees a resource, forgetting and double-freeing both become impossible. See RAII and smart pointers.

Setting a pointer to nullptr after delete is often recommended, and it helps a little: a later dereference through that variable becomes a reliable crash instead of silent corruption. It does nothing for the other copies of the pointer, which is where real UAFs live. I treat it as a debugging aid, not a fix.