Memory Systems · Memory Safety

How an Out-of-Bounds Write Reaches Allocator Metadata

Why the bytes just past a heap object belong to the allocator, how a single byte can turn a length field into an address, and which layouts keep bookkeeping out of the blast radius.

A write past the end of an array is only a memory-safety violation if something later trusts the bytes that were overwritten. In a stack frame the victim is usually the return address. In the heap the victim is less obvious: the bytes immediately after an allocation are frequently the allocator’s own bookkeeping, and the allocator is a program that interprets those bytes as sizes, offsets and links. An off-by-one that stores a zero into a length field is therefore not a one-byte error. It is an edit to the data structure that decides where every subsequent allocation goes.

The header sits immediately before the payload

The C standard says nothing about what surrounds a heap object. malloc returns a pointer to a block of at least the requested size, suitably aligned, and the implementation is free to keep its records anywhere. Most general-purpose allocators, including the dlmalloc lineage that glibc descends from, keep the record immediately before the payload, because the address arithmetic is then a fixed subtraction with no lookup table and no extra cache line.

On 64-bit glibc the layout of an in-use chunk is two machine words of header followed by the payload. The first word is the size of the previous chunk when that chunk is free, and otherwise holds the last eight bytes of the previous chunk’s payload. The second word is the chunk size. Both fields are eight bytes, chunk sizes are multiples of 16 to satisfy the alignment rule for max_align_t, and the smallest chunk is 32 bytes. Because the previous chunk’s last eight payload bytes double as the prev_size field, the usable size of an allocation is the chunk size minus eight, not minus sixteen.

/* Layout of one in-use chunk in the glibc allocator on x86-64. */
struct chunk {
    size_t prev_size;   /* overlaps the last 8 bytes of the previous payload */
    size_t size;        /* chunk length in bytes, low bits used as flags   */
    /* payload begins here, 16-byte aligned */
};

The low bits of the size field are not part of the length. Three of them are flags: whether the previous chunk is in use, whether this chunk came from mmap rather than the main heap, and whether it belongs to a non-main arena. Since a valid length is a multiple of 16, the bottom four bits are always zero in a size value, which is what makes the flags free to use. It also means the least significant byte of the size field is almost entirely flags: for a 32-byte chunk the byte is 0x21, for a 48-byte chunk 0x31.

That byte is the one an off-by-one reaches first. A loop that writes n + 1 bytes into an n-byte allocation clears the PREV_INUSE flag of the following chunk without changing its length. The allocator now believes the chunk before it is free. No error is reported at the moment of the write; the corruption is latent until the neighbouring chunk is freed.

A length field is an address in disguise

Once the allocator’s model of the heap disagrees with the heap, the arithmetic it performs on attacker-influenced values becomes a write primitive. Two paths dominate.

The first is consolidation. When a chunk is freed, the allocator looks at the flags in the next chunk’s size field and, if the next chunk is free, merges the two. Merging requires removing the neighbour from its free list, and a doubly linked list removal is two stores through pointers read out of the block being removed: the forward pointer’s back link is set to the backward pointer, and the backward pointer’s forward link is set to the forward pointer. In an unsorted-bin removal that is literally fd->bk = bk and bk->fd = fd. If the block being unlinked is a live allocation misclassified as free, its “links” are whatever bytes sit at the start of its payload. A program that can place two chosen values there obtains two writes of chosen values to chosen addresses.

The second is size inflation. An overflow of two or three bytes into the size field can enlarge a chunk, subject to the alignment rule and the flags in the low bits. A larger chunk overlaps its successor, so a later allocation of the inflated size returns a region that contains another live object. Both objects are then writable through independent pointers, and the aliasing is invisible to the type system: this is the same failure shape as a use-after-free, produced without any free.

Neither path requires a long overflow. The published exploitation techniques of the 2000s that targeted these structures were mostly built on one or two bytes of corruption plus a malloc and a free to trigger the arithmetic. Modern glibc hardens both paths. Unlink verifies that the links are consistent, that the forward pointer’s back link really points at the block being removed, and the per-thread cache mangles stored forward pointers by exclusive-oring them with the address of the slot divided by the page size, so a forged pointer has to know where it will be stored. The corruption still happens; what changes is that the allocator usually aborts instead of writing. A malloc consistency check that terminates the process is a denial of service, not a repair, and the allocation that was overwritten remains overwritten.

Where the bytes after an object go

The layout question is the design question: does the code that writes past an object land on allocator control data, on another object, or on nothing at all?

In-band metadata is the fastest option and the most exposed. The allocator finds a chunk’s size with one subtraction, and the header is usually in the same cache line as the first bytes of the payload, so the cost of a free is close to the cost of the stores it performs. The price is that every allocation has a control record within a few bytes of a buffer, and that linear overflows reach it before they reach anything else.

Out-of-band metadata trades that exposure for an indirection. Allocators that manage memory in large runs or extents can keep size classes, bitmaps and ownership records in separate structures keyed by the address of the run, so a chunk’s neighbours are other chunks’ payloads. A linear overflow then corrupts data rather than bookkeeping. The allocator still has to map an address to a run, which costs a lookup on the free path, and the metadata structures themselves become targets that are reachable only through that lookup, which is a different attack surface rather than the absence of one.

Redzones push the boundary further without changing its nature. A debug allocator places a known pattern of bytes between the payload and the next record, checks the pattern on free, and reports the overflow at the moment the object dies. The check is exact for overflows shorter than the redzone and blind to anything longer, since a sufficiently long write simply crosses the pattern and reaches the next header. Page-per-object allocators take the extreme position: each object ends at a page boundary followed by an inaccessible page, so any overflow at all faults on the first byte past the end. The cost is a 4096-byte page, plus kernel bookkeeping, for an object that may be eight bytes long, which restricts the technique to debugging builds and small test workloads.

Why the failure surfaces far from the bug

The distance between cause and symptom is what makes this class expensive to diagnose. The overflowing write is a store in one function; the allocator’s interpretation of the corrupted field happens on a later free or malloc, possibly after thousands of intervening operations, and the crash site is the allocator rather than the code that broke the invariant. Between the two events the corrupted chunk may have been split, merged, moved between bins, or handed to a different thread.

A single-byte overflow that clears PREV_INUSE produces no immediate symptom at all, because the flag is only consulted when the neighbouring chunk is released. A size inflation may be detected at the next free as an overlapping-chunk report, or may not be detected until two allocations alias and a later program writes through both. The stack trace at the abort typically contains the free path, the caller that freed the object, and nothing that identifies the overflowing buffer.

Tools that catch the write itself, rather than the consequence, are the only reliable answer. The shadow-memory instrumentation that checks every access against a per-byte validity map converts the delayed corruption into an immediate report at the offending store, which is why it changes debugging more than it changes the layout. Where instrumentation is unavailable, allocators that place guard pages or wide redzones around individual allocations narrow the window; neither restores the invariant.

The remaining mitigation is at the language boundary. Code that indexes through a descriptor carrying a length cannot produce this write in the first place, as the discussion of slice bounds checks shows. For code that still manipulates raw pointers, the practical rule is that the allocator’s header is part of the object’s blast radius, and a bounds computation that ignores the header is wrong by eight bytes on every 64-bit glibc allocation, which is the exact margin an off-by-one needs.

References

  1. ISO/IEC 9899:2018: Programming languages, C — ISO
  2. The Art of Software Security Assessment — Mark Dowd, John McDonald, Justin Schuh, Addison-Wesley
  3. GNU C Library Reference Manual: Memory Allocation — GNU Project
  4. Smashing The Stack For Fun And Profit — Aleph One, Phrack 49, 1996