Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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.

Azvya Education Pvt. Ltd.VLSI Mentor
fully defined: no dead pointer is ever used
#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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
first  block was at 0x104d1d9c0
second block is  at 0x104d1d9c0
same storage ? 1
second[0] = 2222

Identical 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.

A freed block over four stages, with the pointer variable on the middle row and the storage on the bottom row. Stage one, malloc: the pointer is a valid handle and the storage is yours, with one owner. Stage two, free: the pointer variable's bits are unchanged and it is now stale, while the storage belongs to the allocator and is free to hand out. Stage three, a malloc elsewhere in the program: the pointer still holds the same bits because nothing told it anything, but the storage now has a second owner. Stage four, a write through the stale pointer: it writes anyway because no check exists, and the storage is corrupted while the other owner gets the blame.1 · mallocthe block begins2 · free(p)the block ends3 · mallocelsewhereunrelated code asks4 · write via pthe stale useA valid handlep addresses the blockBits unchangedp is now staleStill the samebitsnothing told panythingWrites anywayno check existsYoursone ownerThe allocator'sfree to hand outSomeone else'sa second owner nowCorruptedand they get theblame12
Figure 1 — the same storage over four stages, with your pointer variable on the middle row and the storage itself on the bottom row. Stage 2 is the one with no visible effect: free changes the storage's ownership and changes nothing at all about your variable. Stage 3 is where the real damage becomes possible, and note that it happens in code that has no connection to yours — it just asked for memory. By stage 4 the write is landing in a live object belonging to someone else, which is why the symptom appears in their code and the cause is in yours.

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 happensWhat you see
Before anything else allocatesthe old values — looks perfect
After something else took the storagetheir data, in your variables
After the allocator returned the pages to the OSoften 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
==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 main

A write through the stale pointer reports the same way, with WRITE of size 4. The double free reports as:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
==1260==ERROR: AddressSanitizer: attempting double-free on 0x6020000000d0 in thread T0
SUMMARY: AddressSanitizer: double-free uaf.c:7 in main

Three 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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

  1. Use-after-free is any access through a pointer whose block has been released; double-free is a second free of the same block. Both are undefined behaviour.
  2. free cannot change your pointer — pass-by-value — so a stale pointer is indistinguishable from a good one.
  3. Verified without any undefined behaviour: freed storage is reused. The next allocation of the same size received the identical address, deterministically.
  4. Therefore a stale write lands in another part of the program's live data, and the symptom appears there.
  5. It appears to work because free does not erase bytes and because reuse needs other allocations to happen — a property of low allocation traffic, not of the code.
  6. "Only under load", "only in production", "only with logging on" all mean something else started allocating.
  7. A double-free damages the allocator's bookkeeping, and after reuse it releases a block someone else owns.
  8. free(NULL) is defined; if (p) free(p); protects nothing unless p was set to NULL.
  9. Measured: -Wall -Wextra -Wpedantic -O2 gave zero warnings on a file containing both bugs; clang --analyze found one of the two; ASan found both, with the allocation and free sites.
  10. 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.
  • free cannot 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 defeats if (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.