Skip to content
Courses

Use After Free and Controlled Reuse

The HeapLesson 4 of 675 minutesLessonNo lab next

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.

Create the working directory if needed and enter it in the Ubuntu terminal:

Terminal window
mkdir -p ~/underdunn-course/heap ~/underdunn-course/notes
cd ~/underdunn-course/heap
ldd --version

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

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -Wall -Wextra index.c -o index

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

Fill a card with a recognisable pattern, look at it, discard it, and look again. Which bytes changed?

Run ./index and enter:

new 0 64
write 0 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
dump 0
discard 0
dump 0
quit
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 0x4141414141414141

Here’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 0x10 and 0x18 still 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: 0x00000005e27604f7

We’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.

Run ./index and enter:

new 0 64
write 0 relief crew arrives at 0400
read 0
discard 0
read 0
quit
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.

Now use Lesson 3. Discard a card and immediately open another of the same size.

Run ./index and enter:

new 0 64
write 0 card-zero-original
discard 0
new 1 64
write 1 belongs-to-card-one
read 0
read 1
quit
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-one

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

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 64
discard 0
new 1 64
write 1 belongs-to-card-one
read 1
write 0 WRITTEN-THROUGH-A-DISCARDED-CARD
read 1
quit
card 1: belongs-to-card-one
card 0 written
card 1: WRITTEN-THROUGH-A-DISCARDED-CARD

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

The index has two ways to read the freed bytes:

  • dump performs loads directly in the instrumented program.
  • read_card passes the stale pointer to printf("%.*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:

Terminal window
gcc -g -O0 -fsanitize=address index.c -o index-asan

Run ./index-asan and enter:

new 0 64
write 0 hello
discard 0
dump 0
quit
==34412==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000000020
READ of size 8 at 0x506000000020 thread T0
SUMMARY: AddressSanitizer: heap-use-after-free index.c:66 in dump

Caught 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 64
write 0 hello
discard 0
read 0
quit
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:

Terminal window
printf 'new 0 64\nwrite 0 hello\ndiscard 0\ndump 0\nquit\n' | valgrind --quiet ./index
printf 'new 0 64\nwrite 0 hello\ndiscard 0\nread 0\nquit\n' | valgrind --quiet ./index

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

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.

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

In this fresh tcache run, a 64-byte card is freed. What occupies its first sixteen bytes before any replacement allocation?

  • If the discarded string looks different from the transcript, use dump 0 to 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.

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.