Memory Systems · Memory Safety

Use-After-Free and the Reuse That Conceals It

What a dangling pointer actually refers to after the object dies, why an immediate reuse so often makes the bug invisible, and how quarantine and poisoning change the diagnosis.

The pointer value does not change when the object dies. free(p) releases the block that p addresses; it does not and cannot clear every copy of the address, and in a language without lifetime tracking there is no list of copies to clear. A dangling pointer is therefore not a special kind of value. It is an ordinary address that has stopped being an address of anything the program may use, and the hardware has no way to tell the difference.

What makes the class interesting is not that the read is undefined. It is that the read so often succeeds, returns plausible data, and lets the program continue. The object’s bytes remain in the same physical memory, the mapping remains in the page table, and the next allocation of the same size frequently lands in exactly the same place. A use-after-free on a freshly recycled block usually produces no symptom at all.

The block outlives the release

A heap allocator keeps freed blocks for reuse. Returning the memory to the kernel on every free would mean a system call and a page-table edit per deallocation, so general-purpose allocators retain released blocks in free lists, bins, or per-thread caches, and recycle them for later requests. The bytes are not scrubbed. Whatever the dead object last held remains until the next owner writes over it.

This is a deliberate performance decision with a specific consequence: the interval during which a dangling pointer still reads the old contents is exactly the interval before the block is reallocated. Immediately after free, a read through the stale pointer returns the dead object’s final state, which looks entirely valid. After the block is reused, the read returns the new owner’s data, which may be another object of the same type, an object of a different type, or allocator bookkeeping if the block was split.

struct session *s = session_open(user);
char *name = s->name;          /* borrow a field */
session_close(s);              /* frees the struct and the name buffer */
puts(name);                    /* reads freed bytes; often still prints */

The borrow of name is the bug, not the puts. In a language with manual memory management the lifetime of the buffer is not implied by the lifetime of the pointer, and the compiler inserts nothing between the two statements. If the read and the close are separated by an allocation of the same size, the printed text changes; if they are adjacent, the text is correct. A test that exercises only the adjacent case reports success.

Why reuse makes the defect intermittent

The same source line can be safe or unsafe depending on allocation history, which is why use-after-free reports cluster around particular input sequences. Three mechanisms drive the variation.

Size-class recycling is the first. Allocators serve requests from bins keyed by size, so a 48-byte block is reused for another 48-byte request, and only for a request whose size class matches. A dangling pointer to a 48-byte object is therefore not overwritten by a 4096-byte buffer; it is overwritten by the next allocation in that size class. Programs whose small allocations are dominated by one type will silently revive the old contents, because the new owner writes the same fields.

Per-thread caching is the second. To avoid locking on the fast path, allocators keep a small per-thread cache of recently freed blocks and serve new requests from it without touching the shared heap. The cache is a stack: the most recently freed block is the first one handed out. A free-then-allocate sequence in one thread therefore tends to return the same address immediately, which is the pattern that produces reliable-looking behaviour in single-threaded tests and the one that turns into an exploitable type confusion when the attacker controls the allocation that fills the hole.

Consolidation is the third. When a freed block is adjacent to another free block, the allocator may merge them into a larger one. A stale pointer into the merged region now points somewhere inside a free block whose header describes the region only from its new, lower start address. Reads through it return link pointers and size fields rather than object data, which is a strong hint in a debugger and a leak of heap addresses to anything that can observe the value.

Two pointers, one block

The double-free is the special case where the stale pointer is used as an argument to free rather than as an object. The second release operates on a block that is already on a free list, and the list insertion treats the block’s payload as link fields, so the operation writes the current list head into the freed block and installs the block as the new head. If the block was reallocated between the two frees, the second free releases memory that belongs to a live object, and the allocator’s list now contains a block that is simultaneously in use. The consequences, among them overlapping allocations and a later allocation of the same address twice, are covered in the free-list mechanics.

Use-after-free and double-free are the same underlying error viewed from two sides: a pointer used after its referent’s lifetime ended. The distinction matters for mitigation, because a second free can be detected by the allocator (it knows the block’s state) while a read through a stale pointer cannot.

Making the dead interval visible

The practical response is to lengthen the interval during which misuse is detectable, and to make the reused bytes obviously wrong.

Quarantine is the strongest allocator-level technique. Instead of returning a freed block to the bins, the allocator holds it in a FIFO of recently freed blocks and releases it to general reuse only after the queue exceeds a bound. Any stale access in the window hits a block that is still poisoned and still carries its old header, so the allocator’s own checks and the debug fill pattern both apply. The cost is direct: peak footprint rises by the size of the quarantine, and a program whose working set is dominated by short-lived allocations can see its resident set grow by the quarantine bound rather than by its live data. Debug allocators therefore expose the bound as a tunable and default it small.

Poisoning is the cheaper variant. On free, the allocator writes a fixed pattern over the payload; on allocation, it writes a different pattern. A stale read then returns the free pattern instead of plausible object data, and a stale write destroys the pattern, which the allocator notices when the block is handed out or checked. Patterns are a heuristic: a program that legitimately stores the pattern value defeats them, and a write through a stale pointer that happens to write the pattern value is not detected. They are also useless against reads that occur after the block has been reallocated and rewritten by its new owner.

Deferring the mapping is the operating-system version. If the allocator returns pages to the kernel and unmaps them, a stale access faults on a page that is no longer mapped. But munmap is a page-granular operation and heap blocks are not page-aligned, so this only works for large allocations that occupy whole pages, or for allocators that dedicate a page per object. The shadow-memory approach generalises the idea: keep the bytes mapped, mark their validity in a side table, and check the side table on every access, so the block can be quarantined without losing page granularity.

What a stale read can disclose

The security weight of the class comes from the direction of the misuse. A stale write corrupts the new owner’s state, and if the new owner is a different type, the write is a type confusion: the attacker’s bytes become fields of a structure the program will later interpret. A stale read leaks the new owner’s contents to wherever the value flows, whether a log, a response body, or an error message.

Because the block is recycled within its size class, the leaked bytes are the contents of an object of similar size, which is frequently another instance of the same structure with a different identity. That is how a use-after-free in a session or credential structure becomes a cross-principal disclosure: the address is not the vulnerability, the fact that the allocator reuses the slot is.

Lifetime discipline is the only structural fix. Ownership that is explicit in the type system, as in the ownership article, prevents the stale copy from existing. For code that cannot adopt such a language, the mitigations are all detection mechanisms: quarantine to widen the window, poisoning to make the window visible, and instrumentation to check every access. None of them repairs the program; all of them convert a silent wrong answer into a report.

References

  1. CWE-416: Use After Free — MITRE
  2. ISO/IEC 9899:2018: Programming languages, C — ISO
  3. GNU C Library Reference Manual: Memory Allocation — GNU Project
  4. Valgrind Memcheck manual — Valgrind project