C / DYNAMIC MEMORY
Use-after-free and double free
Explain what free() leaves behind, clear pointers so stale copies cannot be reused, and diagnose use-after-free and double free deliberately.
What you will learn
- Clear a pointer immediately after free() so a stale copy cannot be read or freed again
- Explain why free() cannot change your pointer variable and why free(NULL) does nothing
- Save node->next before free(node) when destroying a linked list
- Read a heap-use-after-free report and match its alloc, free and use stack traces
Understanding Use-after-free and double free
free() ends the lifetime of the block, not the value of your pointer. The call receives a copy of the address, so it has no way to touch your variable: when it returns, that variable still holds the same bits and still looks like a perfectly good pointer, which is why the compiler will not stop you from using it. What actually changed is ownership. The allocator may now write its own free-list bookkeeping into those bytes, or hand the block to the next malloc, at any moment and without any warning to you.
This is why a use-after-free read so often appears to work. Nothing is erased at free time, so the old contents linger until something reuses the block; as soon as an unrelated allocation lands there, the same line of code starts reporting another object's data, and a write through the stale pointer quietly corrupts it. A double free is worse, because the dead address is fed back into the allocator's own data structures: the same chunk can end up on a free list twice, and two later mallocs can then return it to two different callers. glibc catches some cases and aborts with a message such as "free(): double free detected in tcache 2", but whether it fires depends on the size class and on what was allocated in between.
The fix is mechanical rather than clever: make the pointer die at the same instant as the block. Assign NULL right after free, and because free(NULL) is defined to do nothing, a duplicated cleanup path becomes a silent no-op instead of a double free. The remaining danger is copies: an alias saved earlier, a pointer stored in a struct field, or a pointer already returned to a caller all keep pointing at the dead block, so decide which single variable owns it and clear or avoid the rest. Error paths are where duplicates hide, because that is exactly where one function frees a block the caller still believes it owns.
Building with -fsanitize=address turns both bugs from luck into a report: ASan quarantines freed blocks so reuse cannot mask a stale access, then prints where the block was allocated, where it was freed, and where the offending read, write or second free happened.
<stdio.h>
<stdlib.h>
<string.h>
static int real_frees;
/* Release the block and clear the caller's own variable in one step. */
static void free_and_clear(char **pp)
{
if (*pp != NULL) { /* free(NULL) is already a no-op; this keeps the count honest */
free(*pp);
real_frees++;
*pp = NULL;
}
}
int main(void)
{
char *owner = malloc(6); /* "hello" plus its terminator */
if (owner == NULL)
return 1;
strcpy(owner, "hello");
char *alias = owner; /* two variables, one block */
printf("owner holds: %s\n", owner);
free_and_clear(&owner); /* the owner releases the block */
alias = NULL; /* alias is dangling: clear it, never read it */
printf("owner==NULL:%d alias==NULL:%d frees:%d\n",
owner == NULL, alias == NULL, real_frees);
free_and_clear(&owner); /* a duplicated cleanup path, now harmless */
printf("after a second cleanup, frees:%d\n", real_frees);
return 0;
}free() ends the block's lifetime but leaves your pointer's bits untouched, so any later read, write or free through that pointer is a request about memory the allocator now owns.
Worked examples
Watching a freed block get handed out again
A two-slot allocator makes block reuse visible, so you can see a stale pointer start reading another object without invoking undefined behaviour.
<stdio.h>
<string.h>
/* A tiny allocator over static storage: the memory stays valid the whole time,
so the reuse that malloc/free does invisibly can be observed safely. */
static char slot[2][32];
static int taken[2];
static char *pool_alloc(void)
{
for (int i = 0; i < 2; i++)
if (!taken[i]) { taken[i] = 1; return slot[i]; }
return NULL;
}
static void pool_free(char *p)
{
for (int i = 0; i < 2; i++)
if (p == slot[i]) taken[i] = 0;
}
int main(void)
{
char *session = pool_alloc();
strcpy(session, "user=alice");
pool_free(session); /* session is stale from this point on */
char *fresh = pool_alloc(); /* the released slot is handed out again */
strcpy(fresh, "user=root");
printf("fresh reads: %s\n", fresh);
printf("session reads: %s\n", session);
printf("same address: %d\n", session == fresh);
return 0;
}Example explained
Line 1pool_free only flips taken[i]: the bytes and the address are left exactly as they were, which is why the stale pointer still looks usable.
Line 2The second pool_alloc picks the first free slot and so returns the very block session points at; real allocators prefer recently freed blocks too, for cache locality.
Line 3strcpy(fresh, ...) rewrites those bytes, so the read through session silently reports a different object's data instead of crashing.
Line 4With malloc and free the aliasing is identical, but the stale read is undefined behaviour and glibc may also have written free-list links over the first bytes of the block.
How a double free is created on an error path
A function publishes its buffer to the caller before validating it, so both the failure path and the caller's cleanup try to free the same block.
<stdio.h>
<stdlib.h>
static void *last_freed; /* one-entry log, standing in for a real checker */
static void checked_free(void *p)
{
if (p == NULL) {
printf("free: pointer is NULL, nothing to do\n");
return;
}
if (p == last_freed) {
printf("free: same address as the last free, blocked\n");
return;
}
last_freed = p;
free(p);
printf("free: released one block\n");
}
/* Hands the buffer to the caller before it knows the load will succeed. */
static int load(int **out)
{
int *buf = malloc(4 * sizeof *buf);
if (buf == NULL)
return -1;
*out = buf; /* the caller now holds this address too */
buf[0] = 7;
if (buf[0] != 42) { /* validation fails */
checked_free(buf); /* and this path releases the block */
return -1;
}
return 0;
}
int main(void)
{
int *data = NULL;
if (load(&data) != 0) {
printf("load failed\n");
checked_free(data); /* the bug: load() already freed this block */
data = NULL; /* the missing line, wanted in load() or here */
checked_free(data); /* the same cleanup, now a no-op */
}
return 0;
}Example explained
Line 1*out = buf; publishes the address before validation, so two variables own one block and both cleanup paths believe they must release it.
Line 2The failure branch frees the block but leaves the caller's data pointing at it, and that dangling copy is what turns the caller's cleanup into a double free.
Line 3checked_free compares against the previous freed address and refuses; a real second free() would instead push the same chunk onto a free list twice, so a later malloc could serve it to two callers.
Line 4Setting data to NULL makes the duplicated cleanup harmless, which is the whole reason the standard guarantees free(NULL) does nothing.
Important notes
realloc creates the same hazard: after q = realloc(p, n) succeeds, the block may have moved, so p must be neither read nor freed, only overwritten.
Detection is best-effort, and formally even comparing or copying a freed pointer's value is undefined, so overwrite the pointer instead of testing it and rely on a sanitizer rather than on a crash.
Common mistakes
Assuming free(p) sets p to NULL, then writing if (p != NULL) free(p); in a cleanup block: the test passes because the bits are unchanged, the block is freed twice, and glibc either aborts with "free(): double free detected in tcache 2" or corrupts the free list silently.
Freeing a list node and then reading node->next: the allocator commonly overwrites the first bytes of a freed chunk with its own link pointer, so the loop jumps to an address that was never a node and crashes far from the real bug.
Deciding a use-after-free read is harmless because printf showed the right text: the bytes only survive until reuse, so the identical code prints another object's data as soon as an unrelated malloc of a similar size runs.
Try it yourself
Change, predict, then run
Build a three-node singly linked list, then write destroy(Node **head) that copies n->next into a local before calling free(n) and sets *head to NULL at the end. Call destroy(&head) twice and confirm the second call frees nothing.
Open the C workspaceCheck your understanding
You free a 32-byte block and, on the very next line, print a string through the same pointer. It prints the correct text on every run of your test suite. What have you learned?
- The read is safe, because free() only marks the block reusable and never modifies its contents
- The block was too small to be returned to the operating system, so the pointer is still valid
- Nothing useful: the old bytes usually survive until the block is reused, so the read is still undefined behaviour
- The compiler cached the string in a register, so no heap memory was read at all
Show answer
free() transfers the block to the allocator, which may write free-list bookkeeping into it or hand it to the next malloc at any time; a correct print only shows that reuse has not happened yet on this run. The first option is the classic trap: glibc really does write into freed chunks, and even when the bytes survive, the next allocation of a similar size can give the block to unrelated code, after which the same "working" line prints that object's data.