Use After Free and Controlled Reuse
Using something after giving it back
Section titled “Using something after giving it back”Calling free returns an allocation to the allocator, but it doesn’t go through the program and
erase every pointer to it. A card index can discard a card while retaining its old address, leaving
the read and write commands with a way back into storage they no longer own. Using one of those
stale pointers after the allocation has been returned is a use after free.
At first that address may contain allocator bookkeeping and leftover data. If a matching request reuses the chunk, it holds a new object instead. We’ll follow both states, because reading a freed card and changing a live replacement are different consequences of the same stale pointer.
A card index
Section titled “A card index”Create the working directory if needed and enter it in the Ubuntu terminal:
mkdir -p ~/underdunn-course/heap ~/underdunn-course/notescd ~/underdunn-course/heapldd --versionThe recorded allocator is glibc 2.39 on x86-64 Ubuntu, with ASLR enabled. Check your version when comparing results. Save the complete program below as index.c and use it only inside the isolated course VM. Keep to
the shown slot numbers and allocation sizes; in particular, dump reads 32 bytes and needs a card
at least that large before it is discarded.
/* * Card index. Cards are written, read, and discarded. A discarded card * keeps its place in the index so that it can be restored, which means * the office is still holding the address after the card has gone. */#include <stdio.h>#include <stdlib.h>#include <string.h>#include <malloc.h>
#define CARDS 8
static char *card[CARDS];static size_t size_of[CARDS];static int discarded[CARDS];
static void new_card(int n, size_t bytes){ card[n] = malloc(bytes); size_of[n] = bytes; discarded[n] = 0; memset(card[n], 0, bytes); printf(" card %d opened at %p, %zu bytes\n", n, (void *)card[n], bytes);}
static void write_card(int n, const char *text){ if (!card[n]) { puts(" no such card"); return; } snprintf(card[n], size_of[n], "%s", text); printf(" card %d written\n", n);}
static void read_card(int n){ if (!card[n]) { puts(" no such card"); return; } printf(" card %d%s: %.*s\n", n, discarded[n] ? " (discarded)" : "", (int)size_of[n], card[n]);}
/* The card goes back, but the index keeps the address. */static void discard(int n){ free(card[n]); discarded[n] = 1; printf(" card %d discarded, index still points at %p\n", n, (void *)card[n]);}
static void dump(int n){ size_t *p = (size_t *)card[n]; int i;
if (!p) { puts(" no such card"); return; } printf(" card %d at %p:\n", n, (void *)p); for (i = 0; i < 4; i++) printf(" +0x%02x 0x%016zx\n", i * 8, p[i]);}
int main(void){ char line[256];
setvbuf(stdout, NULL, _IONBF, 0); puts("Card index."); puts("Commands: new <n> <bytes>, write <n> <text>, read <n>,"); puts(" discard <n>, dump <n>, quit");
memset(line, 0, sizeof line); while (fgets(line, sizeof line, stdin)) { line[strcspn(line, "\n")] = '\0'; if (strncmp(line, "new ", 4) == 0) { int n = atoi(line + 4); const char *sp = strchr(line + 4, ' '); new_card(n % CARDS, sp ? (size_t)atol(sp + 1) : 64); } else if (strncmp(line, "write ", 6) == 0) { int n = atoi(line + 6); const char *sp = strchr(line + 6, ' '); write_card(n % CARDS, sp ? sp + 1 : ""); } else if (strncmp(line, "read ", 5) == 0) { read_card(atoi(line + 5) % CARDS); } else if (strncmp(line, "discard ", 8) == 0) { discard(atoi(line + 8) % CARDS); } else if (strncmp(line, "dump ", 5) == 0) { dump(atoi(line + 5) % CARDS); } else if (strcmp(line, "quit") == 0) { break; } else if (line[0] != '\0') { puts(" unknown command"); } memset(line, 0, sizeof line); } return 0;}discard frees the card and sets a flag, but read_card and write_card only test whether the
pointer is null. Because card[n] still holds the old address, both checks succeed after a discard.
So the index knows the card was discarded, but the commands that use it never consult that flag.
gcc -g -O0 -fno-omit-frame-pointer -Wall -Wextra index.c -o indexNo warnings, and that’s worth a moment. GCC does diagnose this bug in its simplest form. These are the relevant lines from a separate compiler-warning example, not another file you need to build for the card-index exercise:
char *p = malloc(64);free(p);printf("%s\n", p);direct.c:6:5: warning: pointer ‘p’ used after ‘free’ [-Wuse-after-free]The card index gets no such warning, because the free happens in one function and the use in another with an array subscript in between. This build’s warning doesn’t follow that path through the command interface, so a quiet compilation doesn’t establish that the lifetime checks are sound.
What the allocator does on the way out
Section titled “What the allocator does on the way out”Fill a card with a recognisable pattern, look at it, discard it, and look again. Which bytes changed?
Run ./index and enter:
new 0 64write 0 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdump 0discard 0dump 0quit card 0 at 0x5e27604f72b0: +0x00 0x4141414141414141 +0x08 0x4141414141414141 +0x10 0x4141414141414141 +0x18 0x4141414141414141 card 0 discarded, index still points at 0x5e27604f72b0 card 0 at 0x5e27604f72b0: +0x00 0x00000005e27604f7 +0x08 0xdab86a67224d0048 +0x10 0x4141414141414141 +0x18 0x4141414141414141Here’s what we observed in the two dumps:
- The first two words now hold values written by the allocator. A tcache entry uses those sixteen bytes for its link and key.
- The words at
0x10and0x18still hold our pattern. Entering the tcache didn’t scrub the rest of the allocation, so old data can remain until something overwrites it.
This is the tcache layout from our plain build. Other bins and allocator settings can write different bookkeeping, but none of them make the discarded card valid to use again.
The first word even resembles the address printed above it:
address: 0x5e27604f72b0 +0x00: 0x00000005e27604f7We’ll explain that relationship in the safe-linking lesson. For now, the dump shows that the card’s old text has partly been replaced by allocator bookkeeping.
Reading a card that is not there
Section titled “Reading a card that is not there”Run ./index and enter:
new 0 64write 0 relief crew arrives at 0400read 0discard 0read 0quit card 0: relief crew arrives at 0400 card 0 discarded, index still points at 0x5ccee1cee2b0 card 0 (discarded): ���The original prefix has been overwritten. The program printed allocator bookkeeping as if it were a string and carried on without complaint.
The tail of the old text can survive beyond offset 16, but read_card starts at offset zero and
stops at the first null byte, so it isn’t a reliable way to
inspect that tail. That’s why the word dump is useful here: it shows the surviving bytes even when
the string display stops before reaching them.
What the card becomes
Section titled “What the card becomes”Now use Lesson 3. Discard a card and immediately open another of the same size.
Run ./index and enter:
new 0 64write 0 card-zero-originaldiscard 0new 1 64write 1 belongs-to-card-oneread 0read 1quit card 0 discarded, index still points at 0x59ad901e72b0 card 1 opened at 0x59ad901e72b0, 64 bytes card 1 written card 0 (discarded): belongs-to-card-one card 1: belongs-to-card-oneCard 1 opened at exactly the address card 0 had. Reading card 0 now reads card 1, because there is only one piece of memory and two names for it.
new_card cleared the replacement’s requested bytes, including the old link and key, before
write 1 supplied the new text. The first sixteen bytes are therefore available as replacement data
again.
The old pointer hasn’t become valid C access to the freed object, but in this build it now reaches the storage occupied by card 1.
Writing through the stale pointer
Section titled “Writing through the stale pointer”The same address alias works in the other direction. Leave card 1 live and send a write to the
discarded entry, then read card 1 again. Run ./index and enter:
new 0 64discard 0new 1 64write 1 belongs-to-card-oneread 1write 0 WRITTEN-THROUGH-A-DISCARDED-CARDread 1quit card 1: belongs-to-card-one card 0 written card 1: WRITTEN-THROUGH-A-DISCARDED-CARDA write addressed to a card that no longer exists changed the contents of a card that does. Nothing in the program connects those two operations, and nothing in its output suggests anything went wrong.
The primitive is a write through a stale pointer into replacement storage. Here the size class and
allocation order put card 1 there, while size_of[0] bounds how many bytes the stale write can copy.
That combination, rather than the size alone, determines what the write can change.
Two stale reads, different diagnostics
Section titled “Two stale reads, different diagnostics”The index has two ways to read the freed bytes:
dumpperforms loads directly in the instrumented program.read_cardpasses the stale pointer toprintf("%.*s", ...), so the read happens in a library call.
Let’s compare what the tools report for each one.
Build with AddressSanitizer and first read immediately after freeing, without allocating a replacement:
gcc -g -O0 -fsanitize=address index.c -o index-asanRun ./index-asan and enter:
new 0 64write 0 hellodiscard 0dump 0quit==34412==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000000020READ of size 8 at 0x506000000020 thread T0SUMMARY: AddressSanitizer: heap-use-after-free index.c:66 in dumpCaught immediately, with the source line. A write through the stale pointer is caught the same way.
Now run the read path instead of dump. Run ./index-asan and enter:
new 0 64write 0 hellodiscard 0read 0quit card 0 (discarded):This time the recorded GCC 13.3.0 sanitizer run printed an empty value and exited zero without a diagnostic. The card was still freed before the read, but this access went through the library rather than the direct loads we just tested.
Here are the results from that runtime and Valgrind 3.22.0:
| How the freed bytes are read | AddressSanitizer | Valgrind |
|---|---|---|
read 0, through a precision-limited string conversion |
no diagnostic, exit 0 | invalid reads reported |
dump 0, through direct word loads |
heap-use-after-free reported | invalid reads reported |
You can compare the two paths under Valgrind using the plain build:
printf 'new 0 64\nwrite 0 hello\ndiscard 0\ndump 0\nquit\n' | valgrind --quiet ./indexprintf 'new 0 64\nwrite 0 hello\ndiscard 0\nread 0\nquit\n' | valgrind --quiet ./indexThe difference comes from how the tools check accesses:
- AddressSanitizer marks freed regions as unavailable in its shadow metadata, a process often called poisoning, and checks the accesses the compiler emitted, plus library calls its runtime intercepts. Coverage of the library call depends on that interception.
- Valgrind instruments the executed machine code, including library code. It reported both reads in the recorded runs.
Keep your tool versions beside the results. A different sanitizer runtime may report the library read too, without changing the underlying lifetime error. The ASan documentation on external code explains the role of interception.
These commands test reads before reuse. ASan also delays reuse of freed allocations, so don’t expect the plain build’s immediate address match when running the replacement example under it.
Before and after reuse
Section titled “Before and after reuse”The address stayed the same throughout the example, but what lived there changed:
- Before reuse, a stale read sees whatever bookkeeping and old data remain. Its format and length determine how much of that it exposes.
- After reuse, the address reaches replacement data, including the first sixteen bytes that previously held the link and key.
The copy still has its own limits. In this example snprintf stops copying at a null terminator
and uses the old card’s size as its bound.
You haven’t gained an arbitrary address to write to. You have one stale address, and influence over which object occupies it depends on the intervening allocations.
If the new object is smaller than the old size bound, the stale write can also overrun it, so check both lifetimes’ sizes rather than assuming reuse preserves the original boundary.
A heap overflow takes a different route: it begins in one live allocation and crosses a chunk header on the way to its neighbour. This stale pointer reaches the replacement object squarely, with no metadata in the way, but only if the program allocates the right size at the right moment.
Change the replacement’s size
Section titled “Change the replacement’s size”Repeat the plain-build read-and-write example with card 0 still requesting 64 bytes, but card 1 requesting 72. Predict whether the address will match, then check both addresses and the text read through each entry.
Repeat in a fresh process with card 1 requesting 73, and work out why the result changes. Use the same short marker, not a longer write into the freed chunk.
Compare the lifetimes and sizes
Requests of 64 and 72 both need a 0x50 chunk, so the 72-byte replacement can reuse card 0’s storage.
The stale write still uses the old 64-byte bound. A request of 73 needs a 0x60 chunk and doesn’t
take that cached entry; writing through card 0 then damages freed storage rather than changing card 1.
Exit after observing the result instead of continuing to allocate from that corrupted bin.
Knowledge check
Question 1 of 3
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
When the results don’t match
Section titled “When the results don’t match”- If the discarded string looks different from the transcript, use
dump 0to inspect the words. Address bytes and the first null byte affect the display; string length alone doesn’t explain it. - If the new card does not land on the old one, compare their chunk-size classes and repeat in a fresh plain process. An intervening matching allocation or a replacement allocator can change reuse.
- If the first two words don’t change as shown, repeat the single-card sequence in a fresh plain process and check the tunables. One word’s value alone doesn’t prove which bin received a chunk.
- If the sanitizer result differs from the table, compare the command and compiler version with the recorded run. Keep the diagnostic or its absence beside the access path you tested.
To recover a known state, quit and start the program again. The index keeps nothing between runs.
Check your understanding
Section titled “Check your understanding”This program keeps the pointer after freeing. Setting it to NULL in discard would end the bug.
Why is that not a general answer to use-after-free?
Compare your reasoning
In this program, the read, write, and dump commands check the stored pointer, so clearing it makes those commands reject the discarded entry. Another program may have copied the address into several objects before freeing it.
Clearing one slot doesn’t clear those copies, and fixing their lifetime requires finding who can still use them. A null assignment only closes the routes that actually consult the pointer you changed.
You have now followed one address through two different objects and changed the live replacement through a stale pointer. Claims Desk gives you a fresh program with the same underlying problem and no walkthrough choosing the operations for you.