C Part I · Module 9 · Memory and Object Lifetime
Use-After-Free and Double-Free
A leak keeps the block and loses the pointer. These two bugs do the opposite: they release the block and keep the pointer. The program now holds the address of storage that no longer belongs to it, and the first thing you will notice is that everything still works — the old values are usually still sitting in those bytes, untouched, so the reads return what you expect and the tests pass. That appearance of working is not a mild version of the bug. It is the bug's most dangerous property, because it means the defect ships. This chapter establishes why the appearance is so convincing, proves the underlying mechanism with a program that contains no undefined behaviour at all, and shows exactly how little your compiler is able to tell you.
Advanced21 min readCPart IFundamentalsMemory
Part I · Module 9 · Chapter 9.10 · Use-After-Free and Double-Free
1. The Two Bugs
int *p = malloc(sizeof *p);
if (p == NULL) { return 1; }
*p = 7;
free(p);
printf("%d\n", *p); /* USE-AFTER-FREE — undefined */
free(p); /* DOUBLE-FREE — undefined */Use-after-free is any access through a pointer whose block has been released — a read, a write, pointer arithmetic on it, even comparing it. Double-free is calling free a second time on the same block.
Both are undefined behaviour, and it is worth being exact about what that means here, because Chapter 9.1 §5 set the rule the whole module runs on:
When an object's lifetime has ended, the value of every pointer to it becomes indeterminate, and any use of that value is undefined. Not "returns garbage". Not "probably crashes". Undefined — the standard places no requirement on what the program does, so no observed behaviour is a result you may rely on or reason from.
That is a strong claim and this chapter will not weaken it by showing you what one such program printed. What it will do is prove the mechanism underneath with programs that are entirely well-defined, which turns out to be more convincing anyway.
2. Why free Does Not Change Your Pointer
Start with the thing most readers get wrong without noticing.
free(p);
/* what is p now? */p is exactly what it was. free receives a copy of the pointer value — Chapter 5.3's pass-by-value, which has not stopped applying — so it cannot change the caller's variable even in principle. free's prototype is void free(void *), not void free(void **).
So after the call, your variable still holds the same address, it still has type int *, and every operation you could perform on it before still compiles. Nothing in the program state has been marked, flagged, or zeroed. The only thing that changed is a fact the language knows and your program cannot see: the object those bytes held has ended.
This is why the bug is invisible. There is no observable difference between a good pointer and a stale one. Not at compile time, not at run time, not by inspection.
3. The Dangerous Part, Proved
Here is the premise everything else rests on: after you free a block, the allocator may hand that exact storage to the next caller who asks. If that were not true, a use-after-free would merely be reading your own abandoned data. Because it is true, a use-after-free is reading or writing someone else's live data.
We can prove it without going near undefined behaviour. The trick is to record the address as an integer while the block is still alive, and compare integers afterwards — so no pointer whose object has ended is ever read.
The integer type for that job is uintptr_t, from <stdint.h>: an unsigned integer type wide enough to hold a converted void * and convert it back unchanged. It is the one integer type C offers for this purpose, and it is used here purely so the comparison can happen on values rather than on pointers.
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int main(void)
{
/* Record the address as an INTEGER while the block is still alive. */
int *first = malloc(4 * sizeof *first);
if (first == NULL) { return 1; }
first[0] = 1111;
uintptr_t first_addr = (uintptr_t)(void *)first;
free(first);
first = NULL; /* the only pointer is retired */
/* A completely unrelated allocation of the same size. */
int *second = malloc(4 * sizeof *second);
if (second == NULL) { return 1; }
uintptr_t second_addr = (uintptr_t)(void *)second;
second[0] = 2222;
printf("first block was at 0x%lx\n", (unsigned long)first_addr);
printf("second block is at 0x%lx\n", (unsigned long)second_addr);
printf("same storage ? %d\n", first_addr == second_addr);
printf("second[0] = %d\n", second[0]);
free(second);
return 0;
}Output:
first block was at 0x104d1d9c0
second block is at 0x104d1d9c0
same storage ? 1
second[0] = 2222Identical across three runs. The second allocation received the same storage the first one had.
Nothing in that program is undefined. The conversion to uintptr_t happened while first was valid; after the free, only integers are compared; second is a live pointer to a live block. Every line is defined, and the conclusion is exactly the one we needed:
Freed storage is not abandoned. It goes back into circulation, and the next allocation anywhere in your program may receive it. The allocator is not required to behave this way — reuse is not promised by the standard, and a different allocator or a different request pattern may give you a fresh address — but reuse is how allocators earn their keep, so it is what you should expect.
Now put the two halves together. Your stale pointer still holds 0x104d1d9c0. Something else now owns the object at 0x104d1d9c0. A write through your stale pointer lands in the other owner's data.
4. Why It Appears to Work
Everything about the appearance now follows from §3's measurement, without needing to run anything undefined.
free does not erase the bytes. It is not required to and it generally does not, because writing over a block you are giving back is work with no benefit to a correct program. So immediately after the free, the old values are usually still there.
Reuse takes time to happen. The storage goes back into circulation, but nothing uses it until the program's next allocation of a compatible size. A small test program may make no further allocations at all, in which case the old bytes sit undisturbed for the rest of the run.
Therefore the window in which the bug is invisible is exactly the window your tests run in. A unit test allocates, frees, reads the stale pointer, sees the old value, and passes. The same code inside a real program — with a logger, a parser, a request handler all allocating between your free and your stale read — reads whatever the new owner has written there.
The appearance of working is a property of low allocation traffic, not a property of the code. This is why a use-after-free is often reported as "it only breaks under load", "only in production", or "only when logging is enabled". All three are the same sentence: something else started allocating.
And the three timings matter in opposite directions:
| When the stale use happens | What you see |
|---|---|
| Before anything else allocates | the old values — looks perfect |
| After something else took the storage | their data, in your variables |
| After the allocator returned the pages to the OS | often a crash, with a clean stack trace |
The third row is the lucky case. It is the only one that tells you anything.
5. Double-Free Is Worse Than It Looks
A second free on the same block is not a redundant no-op. It is a message to the allocator about memory the allocator has already re-recorded, and the damage is to the allocator's own bookkeeping rather than to your data.
Chain the two bugs together and the shape becomes clear:
free(p); /* block returns to circulation */
/* ... other code allocates, and receives that block ... */
free(p); /* releases a block that is now someone else's */The second free is, from the allocator's point of view, a perfectly ordinary release of a live block — and now two parts of the program believe they own it. Which is Chapter 9.8 §6.4's two-owner design error, arriving at its destination.
Two consequences are worth naming.
free(NULL) is not a double-free. It is defined and does nothing (Chapter 9.6 §5). This is exactly why p = NULL; after a free is a useful habit — it converts a second free(p) from undefined behaviour into a defined no-op.
A conditional second free is still a double free. if (p) free(p); protects nothing if p was not set to NULL, because a stale non-null pointer passes the test.
6. What the Tools Can See
This section is the measurement that should change how you work.
The compiler, at the curriculum's standard level, sees nothing. The program in §1 — a use-after-free read and a double free on consecutive lines, with the malloc and the free three lines above, in one short main — compiled like this:
$ cc -std=c17 -Wall -Wextra -Wpedantic -O2 -c uaf.c
$No output. Not one warning. (And -Wuse-after-free, which you may see suggested, is a GCC option; clang rejects it as unknown, so the flag is not a portable answer either.)
A static analyser sees some of it.
$ clang --analyze -std=c17 uaf.c
uaf.c:9:20: warning: Use of memory after it is freed [unix.Malloc]
1 warning generated.It found the use-after-free, by line. Note the count: one warning, for a file containing two defects. The analyser traces a path and stops at the first thing wrong on it, so the double free on the next line was never reached. A clean-up pass that fixes only what the analyser reported would leave the second bug in place.
A run-time sanitizer sees all of it, precisely. Building with -fsanitize=address and running:
==1244==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 4 at 0x6020000000d0 thread T0
freed by thread T0 here: ...
previously allocated by thread T0 here: ...
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:9 in mainA write through the stale pointer reports the same way, with WRITE of size 4. The double free reports as:
==1260==ERROR: AddressSanitizer: attempting double-free on 0x6020000000d0 in thread T0
SUMMARY: AddressSanitizer: double-free uaf.c:7 in mainThree things make that report valuable: it names the access, it names the line, and — the part that saves hours — it also names where the block was freed and where it was allocated. Those are precisely the two facts that a crash in unrelated code does not give you.
7. Prevention
Detection is a backstop. These four habits are what actually keep the bugs out, and they are deliberately ordered from weakest to strongest.
1. Set the pointer to NULL immediately after the free.
free(p);
p = NULL;This is the smallest possible improvement and it is real: a later free(p) becomes a defined no-op, and a later *p becomes a null dereference, which is still a bug but is overwhelmingly more likely to be noticed than a silent write into someone else's object. Its limit is equally real — it fixes this variable only, and does nothing about any other pointer that held the same address. Chapter 9.11 takes that limitation apart.
2. Free in exactly one place. Chapter 9.8 §8's single cleanup label. If there is one free for a block, there cannot be two.
3. Keep the pointer's scope as small as the block's lifetime. If the pointer variable does not exist after the free, nothing can use it. A block allocated and freed inside one function, with the pointer declared in that function, is structurally incapable of this bug — which is Chapter 5.6's scope discipline paying off in a place you would not have predicted.
4. Have one owner — Chapter 9.8's whole subject. This is the only one of the four that addresses the cause. Both bugs in this chapter require two pieces of code to disagree about who owns a block; settle that question and neither bug has anywhere to come from.
Notice the direction of the list: habit 1 makes the symptom louder, habits 2 and 3 make the mistake harder to write, and habit 4 removes the possibility. Reach for them in reverse order when you have a choice.
8. What You Should Be Able to Say Now
- Use-after-free is any access through a pointer whose block has been released; double-free is a second
freeof the same block. Both are undefined behaviour. freecannot change your pointer — pass-by-value — so a stale pointer is indistinguishable from a good one.- Verified without any undefined behaviour: freed storage is reused. The next allocation of the same size received the identical address, deterministically.
- Therefore a stale write lands in another part of the program's live data, and the symptom appears there.
- It appears to work because
freedoes not erase bytes and because reuse needs other allocations to happen — a property of low allocation traffic, not of the code. - "Only under load", "only in production", "only with logging on" all mean something else started allocating.
- A double-free damages the allocator's bookkeeping, and after reuse it releases a block someone else owns.
free(NULL)is defined;if (p) free(p);protects nothing unlesspwas set toNULL.- Measured:
-Wall -Wextra -Wpedantic -O2gave zero warnings on a file containing both bugs;clang --analyzefound one of the two; ASan found both, with the allocation and free sites. - A sanitizer report is a diagnostic, not a result — it changes the program so the access is detectable.
9. Exercises
Exercise 1 — name the bug. For each line, say whether it is a use-after-free, a double free, both, or neither.
free(p); printf("%d", *p);
free(p); free(p);
free(p); p = NULL; free(p);
free(p); q = p + 1;
free(p); if (p != NULL) free(p);
free(NULL);Exercise 2 — explain the appearance. A colleague says the stale read "obviously works, I ran it and it printed the right number." Give the two-sentence reply that uses §3's measurement and §4's reasoning, and name the one change to their program that would most likely break it.
Exercise 3 — trace the corruption. Using Figure 1's four stages, describe what happens when stage 4 is a write of 0 through the stale pointer and stage 3's allocation is a buffer holding a filename. Where will the bug be reported, and why is that the wrong place to look?
Exercise 4 — rank the habits. §7 lists four preventions. Rank them by how much they actually prevent, then say what each one fails to cover.
Exercise 5 — the protected double free. Explain precisely why if (p != NULL) free(p); does not prevent a double free, and what one extra line makes it correct.
Exercise 6 — two pointers, one block. This has one bug. Name it, say which habit from §7 would have prevented it, and explain why setting a = NULL after the first free would not be enough.
int *a = malloc(16);
int *b = a;
free(a);
free(b);Exercise 7 — read the report. An ASan report names three source locations: the access, the free, and the allocation. Say which of the three you would read first when debugging, and why the allocation site is more useful than it first appears.
Exercise 8 — scope as prevention. Write a short function that allocates a block, uses it, frees it, and is structurally incapable of a use-after-free. State the property of your function that guarantees it, in one sentence.
10. Summary
- Use-after-free and double-free release the block and keep the pointer. Both are undefined behaviour.
freecannot alter your pointer — a stale pointer looks exactly like a valid one.- Proved with fully defined code: freed storage is handed to the next caller — identical address, deterministic.
- So a stale access reads or writes someone else's live object, and the symptom surfaces in their code.
- It appears to work because bytes are not erased and reuse needs traffic — hence "only under load".
free(NULL)is defined; a non-null stale pointer defeatsif (p) free(p);.- Zero warnings at
-Wall -Wextra -Wpedantic -O2; the static analyser found one of two; ASan found both with full provenance. - A sanitizer report is a diagnostic, not a result.
- Prevention, strongest first: one owner · small scope · one
free·p = NULL.
There is a limitation in §7's first habit that deserves more than a mention. Setting p = NULL protects p, and only p. If any other variable, any array element, or any pointer passed to another function held that same address, it is still holding it, and nothing you do to p reaches it. That observation generalises well past this chapter: a freed block is only one of the ways a pointer stops being valid, and you have now met all of the others too — a pointer to a local that has returned, from Chapter 9.2, and a pointer that was never given a value at all. The next chapter puts all three sources in one place, which is the first time it has been possible to do so, and explains the property they share: the symptom almost never appears where the mistake is.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
