Background
The two language-level ideas every temporal bug turns on, and the definition they add up to: where a variable can be named, how long its storage is guaranteed, and what the standard says about touching it after that.
Lifetime and scope of variables
Two ideas govern every temporal bug, and both are programming-language abstractions that the instruction set and the hardware know nothing about. The C standard defines each of them {{ cite.c_standard }}:
1 int z = 0;
2 int g(int x, int y) {
3 char* buf;
4 buf = malloc(50);
5 scanf("%s", buf);
6 free(buf);
7 { int w = 0; ... }
8 }
| Variable or object | Scope / storage | Valid lifetime |
|---|---|---|
| z | Global, lines 1–8 | Whole program runtime |
| x, y | Local, lines 2–8 | Execution of g |
| w | Local, line 7 | The block at line 7 only |
| buf | Local, lines 3–8 | Execution of g |
| *buf | Heap, no name of its own | Only lines 4–6 (malloc → free) |
| "%s" | String literal, static storage | Whole program runtime |
Notice the mismatch on *buf: buf stays in scope to the closing brace of g, but the memory it points at is only alive between malloc and free. That gap is where bugs live. More on lifetime in C ↗
What is a temporal memory error?
Read this carefully before answering the checkpoint. What does foo() return?
int foo() {
int *p = NULL;
{
int x = 5;
p = &x; // p points into the inner block
}
return *p; // ...but x is gone by here
}
{{ quizQ1.question }}
A temporal memory error occurs when a program accesses memory beyond its valid lifetime. In foo, x has automatic storage duration, so its lifetime runs from entry into the inner block until that block ends, and its storage may be reclaimed there. p is still in scope, but the object it points at is now referred to outside its lifetime, and the C11 standard says the behavior is undefined {{ cite.c_standard }}. The same paragraph settles what has become of p itself: the value of a pointer becomes indeterminate once the object it points to reaches the end of its lifetime. Undefined behavior means the implementation owes you nothing, so what you observe changes with the compiler, the optimization level and the machine.
Temporal Memory Vulnerabilities
The temporal memory errors themselves, code that reaches an object outside its lifetime: use-after-free, the allocator machinery that decides what it reaches instead, and double-free.
Use-after-free
The heap version of the same mistake: you free a chunk, but a dangling pointer keeps pointing at it, and later you dereference it. The allocator may have already handed that memory to some other object, so the dangling pointer now reads or writes a different object's data. Advisories file this as CWE-416 {{ cite.cwe416 }}, and it is among the most exploited bug classes there is: on MITRE's 2025 ranking of the weaknesses behind vulnerabilities known to have been exploited in the wild, use-after-free places second of ten {{ cite.cwe_kev25 }}. It is exploited often enough that defenses exist which do nothing but null out pointers as objects die {{ cite.dangnull }}.
↗ Lab, Program 1: four levels on a session that outlives itself
Here is an example of a bug in a web browser that parses a webpage into a common data structure called the DOM {{ cite.mdn_dom }}. A Document keeps a pointer to a Body object. Watch the Body stay live while it is linked, then disappear the moment it is freed, leaving doc->child dangling:
Why so dangerous? The attacker often controls what gets allocated into the freed slot next, a technique usually called heap grooming, and heap feng shui in the talk that first showed it done from a web page {{ cite.heap_feng_shui }}. If they can place an object they control where the dangling pointer expects a trusted one (e.g. a function-pointer table), the use-after-free becomes control-flow hijacking, just like a stack overflow.
Heap organization & unlinking
To see why double-free is exploitable, recall how glibc, the GNU C Library that Linux uses as its standard C library, manages memory {{ cite.glibc_malloc }}:
- Free chunks are threaded onto a linked list. Chunks in use are on no list at all: an allocated chunk needs only its size.
- A free chunk stores a forward and backward list pointer, in the same bytes that were the user's data a moment earlier. That reuse is the whole reason the attack below works.
- A previously freed chunk can be re-allocated. When all the memory it holds is in use, glibc asks the OS for more virtual pages, which are fixed-size blocks of address space.
- Which call does what matters more than it looks.
freeinserts a chunk into the list, which means writing the list's two pointers into it.mallocremoves one, which is the unlink. The attack below turns on that order.
Removing a chunk means unlinking it from its list, which is the two-line macro below. That macro is the operation attackers hijack, and it was taken apart for this allocator in Phrack in 2001 {{ cite.vudo }} {{ cite.phrack_heap }}. Step through the pointer rewrites:
{{ uCaption }}
That list is the allocator of 2001, and the unlink above is what the Phrack attack rewrote. Today’s glibc still has it, but a freed chunk rarely reaches it any more. Two faster structures sit in front: the tcache, a per-thread cache with one bin per size class, added in glibc 2.26, and the fastbins, which take chunks the tcache has no room for {{ cite.glibc_malloc }}. Both are singly linked, both keep their link inside the free chunk exactly as the list above does, and a request is served from the bin matching its size, so two allocations of different sizes are not served from the same place, however recently one of them was freed.
This matters for double-free because each of those structures checks for itself before trusting a chunk, and they do not all check equally hard. Which one a freed chunk lands in is therefore what decides whether the mistake is caught, and that is settled by how many chunks of that size the program happened to free earlier, which is a property of the program rather than of the bug. Lab Program 3 measures where the line falls.
Earlier we watched doc->child dangle after the Body was freed. So why is that dangling read dangerous, rather than merely wrong? Because Body is a C++ object, and its first word is a pointer to its vtable, the per-class table of function pointers that virtual calls dispatch through {{ cite.sekar_oop }}. The call doc->child->getAlign() is virtual: it is dispatched through that pointer.
If the attacker can get the freed chunk reallocated with bytes they control, the vtable pointer becomes theirs, and the innocent-looking method call turns into a call to attacker-chosen code. Step through it:
Exploiting use-after-free
The vulnerable program never contains a single line of attacker code. The hijack rides entirely on a stale pointer and the allocator’s willingness to reuse freed memory. That is the pattern behind a long line of browser exploits, in which a freed object is reclaimed by an attacker-shaped one before a dangling reference fires. Memory-safety bugs account for roughly 70% of the serious security bugs in Chrome {{ cite.chromium_memsafe }}, and Project Zero’s published analyses of 0-days caught in the wild carry one browser use-after-free after another {{ cite.p0_itw }}.
Two practical facts stand between that idea and a working hijack, and both belong to the machine rather than to the bug. First, the addresses move: ASLR places the heap and the executable somewhere different on every run {{ cite.aslr_sysctl }}, so a payload carrying a fixed address is wrong the moment it is written. An attack has to read the addresses it needs out of the running program and build its bytes from them, which is why the levels that hijack a call are driven by a script rather than typed.
Second, not every byte an object points at is equally reachable. A binary is divided into parts that the loader maps with different permissions, and the parts holding code and constants are not left writable. Some of them are writable only for as long as the loader needs them, because the linker can ask for a region to be made read-only again once the relocations are done {{ cite.ld_relro }}. You can read all of it without running anything: objdump lists the symbols in a binary and the part each one sits in, and readelf lists those parts and the permissions they are mapped with. A virtual call touches two things, the table and the pointer to it, and which of them an attacker can actually reach is a question the binary answers. The lab asks it there.
↗ Lab, Program 2: four levels ending in a virtual call you chose
Double-free Bug
Freeing the same chunk twice is another lifetime violation, and a spectacularly powerful one. The C standard is blunt about it, saying that if the space has already been deallocated by a call to free or realloc, the behavior is undefined {{ cite.c_standard }}. Advisories file it as CWE-415 {{ cite.cwe415 }}. Consider:
char *p, *q, *r, *b;
p = (char*) malloc(SIZE);
b = (char*) malloc(SIZE);
if (abrt) {
free(p); // on this path p is freed here...
}
...
free(b); // something else of the same size
free(p); // ...and p is freed again: the bug
q = (char*) malloc(SIZE); // q gets p's chunk
malloc(SIZE); // this one gets b's
r = (char*) malloc(SIZE); // and r gets p's chunk again: two owners
strncpy(q, ext_input, SIZE); // q writes, and r reads the same bytes
{{ dfCaption }}
Notice what the bug did and did not do. It never wrote anything anywhere. It broke a rule the allocator relies on everywhere else, that a chunk is either in use or free and never both, and everything after that is a consequence. What the attacker ends up holding is one chunk with two owners, and that is worth more than it sounds: neither pointer dangles, neither write leaves its allocation, and the two pieces of code need never have met. A lot of what hardens a heap watches boundaries, and nothing here crosses one, which is why the checks that do catch it are the ones written to look for this mistake in particular.
From there it goes one of two ways. Either the second owner is an object whose length, flag or function pointer the first one now sets, which is the shape the freed Body above ended in, reached by a different bug, and needs no allocator internals at all. Or one owner frees while the other can still write, so the attacker's bytes become the list's own pointers and the next allocation follows them, which is how a later malloc comes back with an address the attacker chose. The unlink in the recap above is a third thing. It wants a chunk sitting on a doubly linked list with both of its pointers already spoiled, which is what a write through a stale pointer or an overflow into a neighbour’s header arranges, and not what a double free hands you.
Every one of those roads has been narrowed since. Most double frees now end in an immediate abort rather than a foothold, because the tcache has refused a chunk it already holds since glibc 2.29 and the fastbin refuses one that is already at its head. The listing above is one of them. Compile it as it stands and glibc stops it with free(): double free detected in tcache 2, and raising SIZE past the cache only trades that for double free or corruption (!prev), because the first free already cleared the bit that says the chunk is in use. Getting it as far as the fastbin, where only the front is checked, comes down to how many chunks of that size the program freed earlier, and that count is what Lab Program 3 puts in your hands. Since glibc 2.3.4 the unlink refuses a chunk whose neighbours do not point back at it, and since glibc 2.32 the singly linked lists mask the pointers they store and check the alignment of what they hand back {{ cite.glibc_malloc }} {{ cite.safe_linking }}. What survived is the primitive rather than the road to it, and Lab Program 3 walks a road that still reaches it on the allocator the lab pins.
↗ Lab, Program 3: four levels from the check that stops this to the write it still reaches
Do it yourself
Three short programs and twelve tasks that climb from reading an address twice to making malloc hand back an address you chose. Each program prints its own addresses, so the numbers you need are on the screen rather than in a debugger. Everything quoted below came from running these exact sources on the lab machine.
Watch one number as you go. Lectures 1A and 1B were built on distances: 20 bytes to the flag, 36 to is_admin, 48 to the neighboring chunk. Every program here reports its reuse as +0. The same address, at a different time, is the whole of this lecture in one number, and level 3 is where you make it stop being zero and watch the attack fail with it.
Watch how each run ends, too, and keep a note of it. In the stack lab a crash was progress: it meant you had reached the return address. Here it is the opposite. The two levels where the takeover fully succeeds, 8 and 12, both exit with status 0 and print nothing unusual, while the levels that crash are the ones where a defense worked or the payload was clumsy. By the end you will have measured, rather than been told, the claim this lecture closes on. Every program and level below folds away once you are done with it. Hints and answers stay folded until you ask for them.
The program {{ p.codeLines }}
{{ p.codeNode }}
{{ p.buildNote }}
↗ Open this program in OnlineGDB with the source and build flags already set.
Level {{ l.n }} {{ l.title }} {{ l.runsLabel }} {{ l.stars }} {{ l.diffLabel }}
{{ l.taskNode }}
Hint
{{ l.hintNode }}
You have it when
{{ l.winNode }}
Go further
{{ l.moreNode }}