Use-After-Free Is a Lifetime Bug, Not a C Bug

Use-After-Free Is a Lifetime Bug, Not a C Bug

Use-after-free gets filed under 'C is dangerous', which is the wrong lesson and hides the real one. The bug is not the free. It is a reference that outlived the thing it pointed to, and nothing enforced that the two lifetimes had to match. That failure shows up in any language that lets a pointer outlive its referent without the type system stopping it. C is just the one honest enough not to hide it from you. Here is the actual mechanism, a reproducible control-flow hijack on your own machine, and why the fix is lifetime ownership, not avoiding a language.

October 8, 2026
Harrison Guo
8 min read
Security Systems

Use-after-free gets taught as a C problem. The lesson lands as “C lets you touch freed memory, so C is dangerous, so use a safer language.” That is true and it is also the wrong lesson, because it hides what the bug actually is and why it keeps showing up in places that are not C.

The bug is not the free. The bug is a reference that outlived the thing it pointed to. Two lifetimes that were supposed to match, the object’s and the pointer’s, and nothing enforced that they did. Call it what it is: a lifetime ownership failure. Nobody owned the answer to “is this still alive”, so a pointer kept pointing at a corpse.

Once you see it that way, everything about use-after-free, including why it is exploitable and why the real fix is not “write less C”, falls out.

The bug is a mismatch between two lifetimes

Here is the whole thing in four lines.

char *p = malloc(64);   // object is born, p points at it
free(p);                // object dies; p still holds the address
// ... later ...
strcpy(p, attacker);    // p is used: it points at freed memory

p is a dangling pointer. The object it named is gone, but p did not get the memo. Nothing in C ties the lifetime of p to the lifetime of what it points at, so the two drifted apart and the code kept running as if they had not.

Notice what is not the problem. free is not the problem; freeing memory is correct and necessary. Holding a pointer is not the problem. The problem is holding a pointer to something whose life you already ended, with no owner responsible for making sure that cannot happen. The free and the use can be a thousand lines and three callbacks apart, which is why these are hard to see and harder to review.

Why it is a weapon, not just a crash

A dangling pointer on its own might just crash. What turns it into an exploit is the allocator, and this is the part worth understanding because it is the entire technique.

When you free a small block, the allocator does not return it to the OS. It puts it on a free list, bucketed by size. On glibc that near-size cache is the tcache: a per-thread, per-size linked list, last-in-first-out. When you next malloc a block of that same size, the allocator pops the most recent one off the list and hands it straight back. Fast, and for an attacker, perfect.

So the move is three steps:

  1. Get the target object freed, leaving a dangling pointer to it.
  2. Immediately allocate an object of the same size, filled with bytes you choose. The allocator hands you the slot the dangling pointer still points at. This is the reclaim.
  3. Trigger a use of the dangling pointer. It now reads the bytes you planted.

If the freed object had a function pointer or a C++ vtable pointer in it, step three is control-flow hijack: the stale pointer calls through a field you now own. Here is the shape, self-contained, the kind of thing you run on your own machine to watch it happen:

struct conn { void (*on_close)(void); char name[56]; };  // 64 bytes

struct conn *c = malloc(sizeof *c);
c->on_close = legit_handler;
free(c);                               // c is now dangling

// reclaim: same size, attacker-controlled contents
char *evil = malloc(64);
fill_with_pointer(evil, (void*)win);   // overwrite where on_close sits

c->on_close();                         // calls win(), not legit_handler

You did not exploit the free. You exploited the allocator’s promise to reuse. The dangling pointer never changed. What sat under it did.

flowchart LR
  a["malloc: object born
pointer p aims at it"] f["free: object dies
p still holds the address"] r["reclaim: malloc same size
attacker data fills the slot"] u["use p
reads or calls attacker bytes"] a --> f --> r --> u

The reclaim is the real skill

Everything hard about use-after-free exploitation is making step two land. You need an allocation of exactly the right size, at the right moment, with contents you control, to fall into the slot the dangling pointer holds. On a quiet single-threaded program that is easy. On a busy one, other allocations race you for the slot, so attackers groom the heap: spray many same-size allocations to make reclaim near-certain, arrange the free list so the slot they want is the one that gets handed back.

This also explains why a freed object is such a good information leak, not just a write target. A freshly freed chunk has free-list bookkeeping written into its own body. Read a dangling pointer to a just-freed large chunk and you are often reading allocator-internal addresses, which leaks where the library is mapped and defeats address randomization. One dangling pointer frequently gives both halves of an exploit: the leak that tells you where things are, and the reclaim that lets you redirect execution.

It is a lifetime bug even when the code looks correct

If use-after-free were really about careless free calls, careful code would be safe. It is not, and concurrency is the proof.

thread A: refcount hits 0, frees the object
thread B: was between "check refcount" and "use object", uses it

Neither thread is obviously wrong. The free is correct, the use is correct, and the object’s lifetime ended in the gap between B’s check and B’s use because the two threads disagreed about who still held it. A huge share of kernel use-after-free bugs are exactly this: a reference-count race, where the lifetime is decided at runtime by timing instead of by any single owner. The bytes-level symptom is identical to the four-line example. The cause is the same word: nobody owned the lifetime.

Mitigations raise the cost; ownership removes the bug

Allocators have grown defenses, and it is worth being precise about what they do. A tcache key lets the allocator notice some double-frees. Safe-linking obfuscates the free-list pointers so an attacker cannot forge one without first leaking a heap address. These are real and they make exploitation meaningfully harder. They are also speed bumps on the technique, not repairs to the bug. The reference still outlived its referent; you just have to work harder to cash it in.

DefenseWhat it raisesWhat it leaves untouched
tcache keydetects some double-freesthe dangling pointer still exists
safe-linkingforging a free-list pointer now needs a heap leakthe reference still outlived its referent
ASLR / NXthe leak and code reuse get harderthe lifetime mismatch itself
GC / RAII / borrow checkernothing: it removes the bug, no free while a reference is livethis is the actual fix, not a speed bump
Defense tcache key
What it raises detects some double-frees
What it leaves untouched the dangling pointer still exists
Defense safe-linking
What it raises forging a free-list pointer now needs a heap leak
What it leaves untouched the reference still outlived its referent
Defense ASLR / NX
What it raises the leak and code reuse get harder
What it leaves untouched the lifetime mismatch itself
Defense GC / RAII / borrow checker
What it raises nothing: it removes the bug, no free while a reference is live
What it leaves untouched this is the actual fix, not a speed bump

The actual fix is to make the lifetime mismatch impossible, and there are only a few ways to do that:

  • A garbage collector never frees an object while a reference to it is live. That is lifetime ownership handed to the runtime, paid for in pauses and memory. The bug is gone because the free in step one cannot happen early.
  • RAII and smart pointers tie an object’s life to a scope or a reference count, so the free is automatic and tied to ownership rather than left to a human to place correctly.
  • Rust’s borrow checker refuses, at compile time, to build a program in which a reference could outlive the value it borrows. The dangling pointer is rejected before it can exist. The exact lifetime rule C leaves to your discipline is the rule Rust makes the type system enforce.

That last one is the whole thesis in one sentence. The difference between C and safe Rust here is not that one has use-after-free and the other does not have the concept. It is that both have lifetimes, and only one checks them for you. Which is why Rust’s remaining memory bugs live in unsafe and across FFI, the two places the check is switched back off.

The one sentence to take

A use-after-free is what happens when a reference outlives the thing it refers to and no owner was responsible for keeping those two lives in sync. C does not cause that. C declines to prevent it, and then the allocator’s reuse turns the leftover pointer into a read, a leak, or a call you do not control. Stop reading it as a quirk of one language and start reading it as the question every system has to answer about every object it hands a reference to: who owns the answer to “is this still alive”, and what happens to everyone still pointing when it is not.

🎧 More Ways to Consume This Content

I occasionally advise small teams on backend reliability, Go performance, and production AI systems. Learn more: /services

Comments

This space is waiting for your voice.

Comments will be supported shortly. Stay connected for updates!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.