JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Use-After-Free and Double-Free

Guide 3 hunted the block you forgot to free. This one hunts the opposite mistake: the block you freed too eagerly, then touched again. Use-after-free and double-free are among the most dangerous bugs in C — quiet, intermittent, and a favourite of attackers. Here is exactly how they happen and how to make them impossible.

The block is gone, but the pointer remembers

From guide 2 you carry one crisp fact: a pointer and the block it points to are two separate things with two separate lifetimes. When you call free() on a block, you end the block's lifetime — you tell the allocator "I am done, you may reuse these bytes". But free() does not touch your pointer. After `free(p)`, the variable `p` still holds the same address it held a moment ago; it just now points at memory the allocator considers free. That stale pointer has a name: a dangling pointer, the dangling pointer you met in passing already. The block is gone, but the pointer still remembers where it used to live.

Now the trap is set. If you dereference that dangling pointer — read `*p`, write through it, or pass it on as though it still owned live memory — you commit a use-after-free, the use-after-free this guide is named for. The frightening part is what happens immediately after you free a block: nothing. The bytes are usually still sitting there unchanged, so `printf("%d", *p)` may print exactly the value you expect. The bug is silent because the corpse looks alive. It only bites later, once the allocator hands those same bytes to the next malloc() and a different part of your program writes its own data on top of yours.

char *p = malloc(16);
strcpy(p, "hello");
free(p);                /* the 16 bytes go back to the allocator */

/* p is now DANGLING: same address, but the block is no longer yours */
printf("%s\n", p);      /* use-after-free: may print "hello"... for now */

char *q = malloc(16);   /* allocator likely hands back the SAME bytes */
strcpy(q, "world");     /* now p and q alias; reading *p sees "world" */
A use-after-free that hides in plain sight. Reading p after free() often "works" until a later malloc() reuses the block — then p silently sees someone else's data.

Double-free: handing back the same block twice

The sibling bug is the double-free: calling free() twice on the same block. Recall the contract from guide 1 — every malloc'd block must be freed exactly once. Freeing once is correct; freeing zero times is the leak of guide 3; freeing twice is the double-free, and it is undefined behaviour. Why is a second free() so bad? Because free() does not just mark bytes as available — it writes bookkeeping into the allocator's own data structures, threading the block onto an internal free list of reusable chunks. A second free() corrupts those structures, scribbling on metadata the allocator trusts.

Double-frees rarely come from writing `free(p); free(p);` on two adjacent lines — that you would catch. They come from two pointers to one block. A function frees a node and returns; the caller, holding its own copy of the same address, frees it again on cleanup. Or an error path frees a block, then falls through to a shared `goto cleanup:` that frees it once more. This is why guide 2 insisted ownership be single: when two pieces of code each believe they own the block, each does its duty and frees it, and the second free() detonates. A double-free is almost always an ownership confusion wearing a different mask.

Why these are not just "crashes": the UB and the exploit

It is tempting to file use-after-free and double-free under "it'll segfault, I'll see it". That is dangerously wrong, and being honest about why matters. Both are undefined behaviour — which does not mean "crash" and does not mean "platform-dependent". It means the C standard places no constraint on what happens, and the optimizer is allowed to assume your program never does it. So a use-after-free might print the right value at `-O0`, then at `-O2` the compiler — having proven the block was freed — may reorder or delete code around it, and the symptom shifts or vanishes. The absence of a crash is not evidence the bug is gone; it is evidence the bug is hiding.

There is a sharper reason to take these seriously: they are weaponised. A use-after-free is a gift to an attacker. If they can arrange for a fresh allocation to land in the bytes your dangling pointer still references, they get to choose what your program reads or writes through that pointer — including, in the worst case, a function pointer they can redirect to code of their choosing. Double-frees corrupt the allocator's metadata in ways that have been turned into reliable exploits for decades. These are not academic edge cases; they sit near the top of every real-world list of memory-safety vulnerabilities, which is the whole reason the rung after this one will weigh C against Rust's ownership guarantees.

Making the bug visible: the sanitizer

Because these bugs are silent by nature, the worst thing you can do is rely on eyeballing them. The right move is to make the machine catch them, and the sharpest tool for this exact class is AddressSanitizer, the AddressSanitizer you build into your program with `gcc -fsanitize=address -g main.c` (clang works the same way). It instruments every allocation and every memory access. When you free a block, it does not immediately recycle those bytes; it puts them in quarantine and poisons them, so that a later read or write through a dangling pointer is caught on the spot — turning a silent use-after-free into a loud, precise report with the line that freed the block and the line that touched it afterwards.

AddressSanitizer catches double-frees just as cleanly, printing `attempting double-free` with both free() call stacks. The trade is honest: it slows your program roughly two-fold and grows its memory use, so you run it during development and testing, not in production. Pair it with the valgrind memcheck from guide 3 — valgrind needs no recompilation and sometimes catches what the sanitizer misses, while the sanitizer is far faster and gives crisper reports. Neither is optional folklore: in serious C work you assume every test runs under one of them, because a use-after-free that escapes to production is exactly the bug you cannot reproduce by staring.

The discipline that closes the door

Tools find these bugs after you write them; discipline keeps you from writing them. Every habit below traces straight back to the lesson of guide 2 — keep ownership single and explicit — because that is the root these bugs grow from. None of this is heavy ceremony; it is a handful of small, mechanical reflexes that, kept consistently, make use-after-free and double-free nearly impossible to commit by accident.

  1. After every free(p), immediately write p = NULL. A later stray read becomes a clean null-deref crash you can find, and a later stray free() becomes the harmless free(NULL).
  2. Keep ownership single: when you pass or store a pointer, decide whether you are transferring ownership or just lending. Only the owner ever calls free(); a borrower must never free what it does not own.
  3. Free a block in exactly one place along each path. On error paths, let a single goto-cleanup label own the free() so two branches can never free the same block twice.
  4. Build and run your tests with -fsanitize=address (and valgrind) as a matter of routine, not as a last resort — these bugs are invisible until a tool makes them visible.

Hold these together with the leak from guide 3 and a clean picture emerges. Free too late or never, and you leak. Free too early and keep using, and you have a use-after-free. Free twice, and you have a double-free. All three are the same root failure — a block whose ownership is unclear — seen from three angles. Get ownership single and explicit, free exactly once at a known place, null the pointer after, and run a sanitizer, and you have shut every one of those doors. That same hard discipline is precisely what the ownership system of the next rung will hand to the compiler to enforce for you — a fitting place for this rung to lead.