Pointers & Memory · advanced · ~10 min
- Explain what a use-after-free (UAF) is and why C treats it as undefined behaviour - Trace how a freed heap block gets reused by the allocator, and how that reuse corrupts unrelated state - Recognise the common code shapes that create UAF: stale caches, returned scratch pointers, dangling aliases - Apply defensive habits — null-after-free, single ownership, no long-lived raw pointers into freed memory - Detect UAF at runtime with AddressSanitizer and reason about what its report is telling you - Distinguish a UAF from a related double-free and a NULL dereference
When you call free(p), you are handing a block of heap memory back to the allocator. From that instant, the memory is no longer yours — even though the pointer variable p still holds the old address. A use-after-free happens when the program later reads or writes through that stale address, touching memory that no longer belongs to it.
Think of free like checking out of a hotel room. You walk out, but you kept a copy of the key card. The room is now cleaned and given to the next guest. If you let yourself back in with your old key, you are wandering through someone else's space — maybe empty, maybe occupied, maybe rearranged. That is exactly what a stale pointer does to freed memory.
This lesson builds directly on free and ownership. There you learned that every heap allocation has exactly one owner responsible for freeing it, and that after free the pointer no longer names valid memory. Use-after-free is what goes wrong when that rule is broken in the other direction: instead of forgetting to free (a leak), you free and then keep using. In C this is undefined behaviour — the language makes no promise about what happens — which is why it is both a reliability bug and, in the hands of an attacker, an exploitation primitive.
In plain terms: freed memory is not "deleted," it is "returned." The bytes usually stay exactly as they were for a while, which is what makes UAF so sneaky — the buggy code often appears to work right up until the allocator reuses the block.
Use-after-free is not an academic edge case. It is consistently one of the most exploited bug classes in real software — browsers, kernels, media parsers, and language runtimes have all shipped critical UAF vulnerabilities. Microsoft and Google have both reported that memory-safety issues, with UAF prominent among them, account for a large share of the severe security bugs they fix.
There are two reasons it matters so much:
Silent corruption. A UAF rarely crashes at the exact line where the bug lives. The freed block gets reused elsewhere, and the program misbehaves far away — a value flips, a linked list loops, a callback jumps somewhere unexpected. These bugs are notoriously hard to reproduce because they depend on allocation timing.
Exploitability. If an attacker can influence what gets allocated into a freed block, they can steer the program. A classic pattern: free an object that still has a function pointer inside it, then get the allocator to reuse that block with attacker-controlled bytes, so the next call through the object jumps to code the attacker chose. You will only study this defensively here — the point is to understand why writing correct free/use discipline is a security responsibility, not just a tidiness one.
A stale or dangling pointer is a pointer variable that still holds an address whose memory has been freed. The pointer's value (the number) is unchanged by free — only the ownership of that memory changed.
Before free(p):
p ──────────────► [ heap block: 42 | ... ] (yours, valid)
After free(p):
p ──────────────► [ heap block: 42 | ... ] (returned to allocator,
bytes often unchanged,
but NOT yours anymore)
The danger is that p still looks usable. *p may even still read 42 immediately after the free, because the allocator hasn't handed the block out yet. That accidental success is a trap — it trains you to think the code is fine.
When it becomes a bug: the moment you dereference (*p, p->field, p[i]) or pass p somewhere that dereferences it.
Pitfall: assuming "it printed the right value, so it's fine." UAF is undefined behaviour at the moment of use, regardless of whether the observed value happened to be correct that time.
Knowledge check: After
free(p);, is the pointer variablepitself destroyed, or is it the memory it points to that is released? Explain the difference in your own words.
free doesn't erase the block; it records the block as available. A later malloc/calloc of a similar size will often return that very same block. Now two pointers name the same bytes — but only one of them is the legitimate owner.
Step 1: q = malloc(16); q ──► [ block A ]
Step 2: free(q); q ──► [ block A ] (A on free list; q now stale)
Step 3: r = malloc(16); r ──► [ block A ] (allocator reused A!)
Now *q and *r are the SAME memory.
Writing through r changes what q sees; using q corrupts r's object.
This is the heart of UAF exploitation: the reused block may hold a different type of object than the stale pointer expects.
| Situation | What the stale read/write does |
|---|---|
| Block not yet reused | Often returns old bytes (looks fine — dangerous illusion) |
| Block reused by same program | Corrupts the new legitimate object |
| Block reused with attacker data | Attacker-controlled bytes interpreted as your object |
| Block returned to OS | Immediate crash (segfault) |
When to care: any time a pointer might outlive the free of what it points to.
Pitfall: believing UAF "only matters under attack." Even with no attacker, reuse causes random-seeming corruption and data loss.
Knowledge check: In the trace above, after Step 3, why is writing through
rsafe but writing throughqundefined behaviour, even though they hold the same address?
Most real UAF bugs happen because two pointers refer to one block and one of them frees it while the other keeps going. The surviving pointer is now dangling.
node->next ──┐
├──► [ block ] free via one alias...
cache_ptr ──┘
free(node->next); // block returned
use(cache_ptr); // cache_ptr is now a UAF
This connects to free and ownership: the fix is to have exactly one owner and to make sure no other pointer is used after the owner frees.
When NOT to worry: if a second pointer is guaranteed to be discarded before the free, there's no window for misuse. The bug only exists when a stale alias is actually used.
Pitfall: freeing inside a function while the caller still holds the pointer and doesn't know it was freed. Ownership must be explicit in your API contracts.
Calling free twice on the same block is closely related. The second free corrupts the allocator's internal bookkeeping (its free list), which can itself be exploited. Nulling the pointer after freeing prevents both UAF and double-free, because free(NULL) is defined to do nothing.
| Bug | Trigger | Typical symptom |
|---|---|---|
| Use-after-free | Read/write through pointer after free | Corruption, crash, hijack |
| Double-free | free the same block twice |
Allocator corruption, crash |
| NULL deref | Use a pointer set to NULL | Immediate, deterministic crash |
Note the last row: turning a UAF into a NULL dereference (by nulling after free) is a good trade — a reliable crash is far easier to find and fix than silent corruption.
Knowledge check: Why does setting
p = NULL;right afterfree(p);protect against both a later use-after-free and an accidental double-free?
There is no special "UAF syntax" — it is a discipline around free. The key defensive shape is the free-then-null idiom, and a small helper that enforces it:
#include <stdlib.h>
/* free the block AND clear the caller's pointer in one step.
Take a pointer-to-pointer so we can null the caller's variable. */
static void free_and_null(void **pp) {
free(*pp); /* free(NULL) is a safe no-op, so double-free is guarded */
*pp = NULL; /* caller's pointer can no longer dangle */
}
/* usage */
char *buf = malloc(64);
/* ... use buf ... */
free_and_null((void **)&buf); /* buf is now NULL, not dangling */
Plain idiom without a helper:
free(p);
p = NULL; /* one extra line that removes an entire bug class for this variable */
Remember: nulling helps only the specific pointer variable you null. If other aliases exist, each of them can still dangle — the real fix there is single, clear ownership.
A use-after-free (UAF) happens when you keep using a pointer after the memory it points to has been released with free.
In C, this is undefined behaviour — the language makes no promise about what happens next. It is also one of the most popular building blocks attackers use to exploit programs.
Once memory is freed, the allocator is free to hand that same block to a later request. The block may be reused for something completely unrelated.
A pointer that still points at freed memory is called a stale (or dangling) pointer. Reading or writing through it touches whatever now lives there — corrupting state that belongs to another part of the program.
Null out pointers after freeing them. This turns a silent UAF into an obvious NULL dereference:
free(p);
p = NULL;
Never store a freed pointer in a long-lived structure. A pointer that outlives the memory it names is a UAF waiting to happen.
Build with AddressSanitizer (ASan) in CI. ASan is a compiler tool that detects memory errors at runtime, and it catches most use-after-free bugs as soon as they occur.
#include <stdio.h> #include <stdlib.h> #include <string.h>
/* A tiny "session" object. Imagine a login token plus a role flag. */ typedef struct { char user[16]; int is_admin; } Session;
/* Create a session on the heap. Returns NULL on allocation failure. */ static Session *session_create(const char *user, int is_admin) { Session *s = malloc(sizeof s); if (!s) return NULL; / always check malloc / snprintf(s->user, sizeof s->user, "%s", user); / bounded copy */ s->is_admin = is_admin; return s; }
/* Destroy a session. Takes Session** so it can null the caller's pointer, preventing the caller from dangling. */ static void session_destroy(Session **ps) { if (!ps) return; free(ps); / free(NULL) is safe, so this also guards double-free */ ps = NULL; / caller's pointer no longer points at freed memory */ }
int main(void) { Session *s = session_create("alice", 0); if (!s) { fprintf(stderr, "out of memory\n"); return 1; }
printf("user=%s is_admin=%d\n", s->user, s->is_admin);
/* --- WRONG (do NOT do this) -------------------------------------
free(s);
printf("%s\n", s->user); // use-after-free: s is stale here
----------------------------------------------------------------
We instead destroy through the helper, which nulls s for us. */
session_destroy(&s);
/* Because session_destroy set s = NULL, this is now a SAFE, defined
check instead of a silent use-after-free. */
if (s == NULL) {
printf("session cleaned up safely\n");
}
/* Calling destroy again is harmless now: free(NULL) does nothing. */
session_destroy(&s);
return 0;
}
/* --- What this does ---
This program builds a small heap Session object, prints it, and then tears it
down through a session_destroy helper that takes a Session **. Passing the
address of the pointer lets the helper both free the block and set the
caller's variable to NULL, closing the UAF window in one place.
Expected output:
user=alice is_admin=0
session cleaned up safely
Edge cases handled: malloc failure is checked; snprintf bounds the username copy so it can never overflow user[16]; the second session_destroy(&s) is safe because s is already NULL and free(NULL) is a defined no-op. The commented-out block shows the exact wrong pattern for contrast: freeing s and then reading s->user. */
session_create("alice", 0) calls malloc(sizeof *s). On success, s points at a fresh heap block; snprintf copies "alice" into s->user and is_admin is set to 0.if (!s) check catches an allocation failure so we never dereference a NULL pointer downstream.printf reads s->user and s->is_admin — this is a legitimate use while the block is owned.session_destroy(&s) receives ps = &s. Inside, free(*ps) releases the block. Then *ps = NULL writes through the address of the caller's variable, so back in main, s is now NULL.if (s == NULL) is now a defined comparison, not a dangling dereference. It prints the cleanup message. Had we instead written s->user here, that would be the use-after-free.session_destroy(&s) runs free(NULL) (a no-op) and sets NULL again — completely harmless, demonstrating the double-free guard.Trace of the key variable:
| Point in program | Value of s |
Heap block state | Safe to deref s? |
|---|---|---|---|
| after create | valid address | owned | yes |
| after first destroy | NULL |
freed | no (but s==NULL check is safe) |
| after second destroy | NULL |
freed | no |
The takeaway: the only reason step 5 is safe is that step 4 nulled the pointer. Without that, s would still hold the freed address and any use would be undefined behaviour.
Mistake 1 — Use the pointer right after freeing it.
/* WRONG */
free(s);
printf("%s\n", s->user); /* s is stale; UAF */
Why it's wrong: free(s) returns the block; s->user then reads memory you no longer own. It may print the right thing today and corrupt data tomorrow.
/* CORRECT */
printf("%s\n", s->user); /* read BEFORE freeing */
free(s);
s = NULL;
How to spot it: any dereference below a free on the same pointer.
Mistake 2 — Return a pointer into a freed local buffer.
/* WRONG */
char *make_label(void) {
char *tmp = malloc(32);
strcpy(tmp, "ready");
free(tmp); /* freed... */
return tmp; /* ...then returned. Caller gets a dangling pointer. */
}
Why it's wrong: the caller receives an address whose memory is already released. Fix: either don't free before returning (transfer ownership to the caller, who frees later), or return a copy.
/* CORRECT: hand ownership to the caller */
char *make_label(void) {
char *tmp = malloc(32);
if (!tmp) return NULL;
strcpy(tmp, "ready");
return tmp; /* caller frees when done */
}
Mistake 3 — Free through one alias, keep using another.
/* WRONG */
Session *a = session_create("bob", 1);
Session *b = a; /* b aliases a */
free(a);
printf("%d\n", b->is_admin); /* b dangles */
Why it's wrong: nulling a wouldn't even help b. The real fix is single ownership: decide which pointer owns the block and make sure no other pointer is used after the free.
Mistake 4 — Free inside a loop but keep the node pointer.
/* WRONG: frees node, then reads node->next */
free(node);
node = node->next; /* reads freed memory to advance */
/* CORRECT: capture next BEFORE freeing */
Node *next = node->next;
free(node);
node = next;
Compiler warnings. Turn them all on: -Wall -Wextra. Modern GCC/Clang can catch some obvious use after free with -Wuse-after-free (GCC) and static analysis (clang --analyze or scan-build). These won't catch every case but are free wins.
AddressSanitizer (ASan) — your best friend here. Compile and link with -fsanitize=address -g:
cc -fsanitize=address -g uaf.c -o uaf
./uaf
When a UAF fires, ASan prints something like ERROR: AddressSanitizer: heap-use-after-free, plus:
Those three stacks together usually pinpoint the bug immediately. Read them as a story: "allocated here, freed here, then used here."
Valgrind (valgrind ./prog) is an alternative that needs no recompile and reports Invalid read/Invalid write of freed blocks, though it's slower than ASan.
Runtime symptoms and what they suggest:
| Symptom | Likely cause |
|---|---|
| Segfault only sometimes | Reuse-dependent UAF (timing sensitive) |
| Values change with no assignment nearby | Freed block reused by another allocation |
Crash inside malloc/free itself |
Double-free or heap metadata corruption |
| Works in debug, breaks in release | Optimizer changed allocation timing |
Questions to ask when it misbehaves: Which pointer is used after which free? Are there aliases to the same block? Did a function free something the caller still holds? Did I capture ->next before freeing the node? Run under ASan first — it answers most of these for you.
Use-after-free is squarely a memory-safety / undefined-behaviour problem, and because the topic is security-relevant, treat every free as a lifetime boundary you must respect.
The core UB rules for this topic:
free(p), the value of p becomes indeterminate per the C standard. Even reading the pointer value (not just dereferencing) is technically UB, so null it promptly.*p, p->f, p[i] — is UB whether you read or write.free.Defensive practices (do these by default):
p = NULL;), ideally via a helper that takes T**. This converts silent UAF into a deterministic NULL crash and neutralizes double-free.heap-use-after-free report as a release blocker.The security angle (defensive, lab-only): A UAF where the freed object contained a function pointer or vtable is dangerous because an attacker who can allocate controlled data into the reused block may redirect a later indirect call. You don't need to build exploits to defend against this — the mitigation is simply correct lifetime management plus ASan and modern hardened allocators. The lesson is that lifetime bugs are security bugs.
Concrete cases. Web browsers (rendering engines juggling thousands of short-lived DOM/JS objects), OS kernels (device drivers and reference-counted objects), media and document parsers, and language runtimes have all shipped high-severity UAF CVEs. That is precisely why Rust's ownership model, C++ smart pointers (unique_ptr/shared_ptr), and hardened allocators exist — they exist to make UAF hard or impossible by construction.
Professional best-practice habits:
| Habit | Beginner | Advanced |
|---|---|---|
| After free | Always p = NULL; |
Use a free_and_null(T**) helper everywhere |
| Ownership | One owner per allocation | Document ownership in headers; consider ref-counting with clear rules |
| Traversal | Capture next before free |
Use iterators/handles that outlive raw pointers safely |
| Detection | Run once under ASan | ASan + Valgrind in CI; fuzzing to trigger timing-dependent UAF |
| API design | Return owned buffers, caller frees | Prefer opaque handles/indices over raw pointers across module boundaries |
A senior habit worth adopting early: prefer handles or indices (e.g., an integer ID into a table) instead of raw pointers when an object's lifetime is managed elsewhere. A stale index can be validated ("is this slot still live?"); a stale pointer cannot.
Beginner 1 — Null-after-free discipline. Allocate an int on the heap, store 7, print it, then free it and set the pointer to NULL. Afterward, print whether the pointer is NULL. Requirements: check malloc; no dereference after free. Expected output includes value=7 then a line confirming the pointer is NULL. Concepts: free/ownership, null-after-free.
Beginner 2 — Spot the UAF. Given this snippet, rewrite it correctly:
char *p = malloc(8);
strcpy(p, "hi");
free(p);
printf("%s\n", p);
Objective: reorder so the read happens before the free, and null the pointer afterward. Explain in a comment why the original is undefined behaviour. Concepts: stale pointer, ordering of use vs free.
Intermediate 1 — Safe destroy helper. Write void buf_destroy(char **pp) that frees *pp and sets it to NULL. Then demonstrate that calling it twice on the same pointer is harmless. Requirements: handle pp == NULL; no double-free. Hint: rely on free(NULL) being a no-op. Concepts: pointer-to-pointer, double-free guard.
Intermediate 2 — Fix the traversal. You are given a singly linked list free routine that reads node->next after freeing node. Rewrite it to capture next before the free, freeing the whole list without any UAF. Input: a list of 5 nodes. Output: all nodes freed, program exits cleanly under ASan. Hint: one temporary pointer. Concepts: capture-before-free, aliasing.
Challenge — Handle table to defeat dangling pointers. Build a tiny object table: an array of slots, each holding a Session * and a generation counter. Hand callers a Handle { index, generation } instead of a raw pointer. Implement create, get(handle) (returns NULL if the slot's generation no longer matches — i.e., the object was destroyed and the slot reused), and destroy(handle) (frees, bumps the generation). Demonstrate that a stale handle to a destroyed-then-reused slot is safely rejected by get, where a raw pointer would have been a UAF. Constraints: check all allocations; build and run clean under -fsanitize=address. Concepts: handles vs pointers, generation counters, lifetime validation.
A use-after-free is reading or writing memory through a pointer after that memory has been passed to free. In C it is undefined behaviour: the pointer still holds the old address, but the block now belongs to the allocator and may be reused at any time by a later malloc. That reuse is what turns a UAF into silent corruption — or, when an attacker controls the reused bytes, into a control-flow hijack.
The most important habits: read values before freeing; set the pointer to NULL immediately after free (ideally via a T** helper) to convert a silent UAF into a deterministic NULL crash and to neutralise double-free; keep single, documented ownership of every allocation; capture ->next before freeing a list node; and never cache a raw heap pointer somewhere that outlives the allocation.
The most common mistakes are dereferencing after free, returning a pointer into a just-freed buffer, and freeing through one alias while still using another. Your primary safety net is AddressSanitizer (-fsanitize=address), which pinpoints the allocate/free/use trio and should gate every build in CI. Remember the core idea: free releases the memory, not the pointer — and it is your job to make sure the pointer is never used again.