Security Engineering · Binary Exploitation

Heap Chunk Metadata and Why tcache Changed the Exploit Shape

How glibc lays out an allocated chunk, what the free-list pointers in a freed chunk mean, and how a per-thread cache with no consolidation turned a single overflow into an arbitrary allocation.

The general-purpose allocator in glibc is a boundary-tag allocator with segregated free lists. Every allocation carries a header describing its size and the state of its neighbours, and every freed allocation is threaded onto a list through pointers stored inside its own payload. Those two facts — metadata adjacent to user data, and pointers stored in memory the program still has a reference to — are the source of nearly every heap exploit. The 2017 addition of a per-thread cache changed the cost of exploiting them by removing the consistency checks that the older paths performed.

The chunk and its header

An allocated chunk begins with a 16-byte header on 64-bit systems. The first eight bytes hold the size of the previous chunk when that chunk is free, and the second eight hold the size of this chunk in the low bits plus three flags. PREV_INUSE records whether the preceding chunk is allocated, IS_MMAPPED marks a chunk obtained directly from the kernel, and NON_MAIN_ARENA marks a chunk belonging to a thread arena rather than the main one. The size field is always a multiple of 16, so its low four bits are free for flags.

The usable payload starts immediately after the header, which means the pointer returned to the caller is chunk + 16. The chunk’s total size includes the header, so a request for 24 bytes is satisfied by a chunk of 32 total bytes: 16 header, 16 usable. The alignment guarantee is 16 bytes on 64-bit, which is what makes the low bits available.

struct malloc_chunk {
  size_t mchunk_prev_size;   /* size of previous chunk, when it is free */
  size_t mchunk_size;        /* this chunk's size | PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA */
  struct malloc_chunk *fd;   /* payload begins here; next chunk when free */
  struct malloc_chunk *bk;   /* previous chunk, on the doubly linked lists */
};

The declaration is the allocator’s own internal view: the fd and bk fields occupy the first 16 bytes of the payload, which is why a freed chunk’s first two words are pointers rather than user data.

The next chunk’s prev_size field is used differently depending on state. When the current chunk is free, the next chunk’s prev_size holds this chunk’s size, which is how backward consolidation finds the predecessor without a separate pointer. When the current chunk is allocated, that field is available for the previous chunk’s payload, a space-saving overlap that means an allocation’s usable size can extend into the following chunk’s header region by eight bytes. malloc_usable_size reports the larger figure, and code that writes to it is writing outside the requested size but inside the chunk.

The free lists and what they store

Freed chunks are linked into bins. Small chunks go to fastbins, which are singly linked, unsorted, and do not consolidate; their fd pointer at offset 16 of the payload points to the next free chunk. Larger chunks go to the unsorted bin, then to small and large bins, which are doubly linked and store both fd and bk.

The pointers are stored in the payload, at the start of the region the caller was using. That is the crux: a use-after-free reads a pointer the allocator wrote, and a heap overflow into a freed chunk overwrites a pointer the allocator will follow on the next allocation. Neither requires any knowledge of the allocator’s own data structures, because those structures are the payload.

Safe-linking, introduced in glibc 2.32, changes the encoding of the fastbin and tcache fd pointers. The stored value is the address of the storage location shifted right by twelve, XORed with the next pointer. The encoding is reversible by anyone who knows the storage address, and its purpose is to make a blind overwrite of the pointer ineffective: writing a chosen value into the slot no longer places that value on the list, because the allocator will decode it relative to an address the attacker may not know.

tcache and the checks it does not perform

The thread-local cache holds up to 64 bins, one per size class from 32 to 1040 bytes in 16-byte steps, with a default maximum of seven chunks per bin. A free that finds room in the corresponding tcache bin pushes the chunk onto a singly linked list and returns without touching the arena at all. A malloc that finds a non-empty tcache bin pops the head and returns it.

The significance is what is skipped. The older fastbin and arena paths check that the chunk being freed is not already at the head of a list, that its size field is consistent, and that the next chunk’s prev_size agrees. The tcache path checks the size field against the bin index and, in current versions, that the chunk is aligned and that its key field does not already name the thread’s cache. The key check catches a chunk freed twice in immediate succession; it does not catch a chunk that is freed, reallocated, and then freed again through a second reference, because the reallocation clears the key.

Two allocations returning the same address is the primitive the whole technique rests on. The program believes it has two independent objects, and a write through one modifies the other. If one of them is a structure with a length and a pointer, the attacker can set the length through the first reference and read or write out of bounds through the second.

The count field in the tcache entry, a two-byte value stored alongside the head pointer, tracks how many chunks the bin holds. It is part of the per-thread structure, not of the chunks, so it is not reachable by a heap overflow unless the attacker can reach the thread-local area, which requires a different primitive.

Poisoning the list

The classic attack on a singly linked free list is to overwrite the fd pointer of a freed chunk with the address the attacker wants returned. The next allocation pops that chunk and, when the following allocation reads the new head, it reads from the attacker’s address. The second allocation then returns a pointer to attacker-chosen memory, and a write through it lands wherever the attacker aimed.

Against tcache the sequence is: free a chunk, overflow into it to overwrite its fd, allocate once to consume the chunk, allocate again to receive the poisoned address. Under safe-linking the overwritten value must be encoded as target ^ (storage_address >> 12), so the attacker needs to know both the target and the address at which the fd slot lives. The storage address is a heap address, which a heap leak supplies; the target is chosen by the attacker. The encoding therefore converts a blind overwrite into one that requires knowing where the fd slot lives, which is the same leak the target address already requires. That is why safe-linking is a mitigation and not a fix.

The check is applied to the returned pointer, so a target that is not aligned faults before the allocation is handed out. Choosing a target that is already aligned is a constraint on what can be overwritten, and it is why the useful targets are structures whose fields happen to sit at aligned offsets from a known base. The address to aim at is itself unknown until the randomised heap base has been disclosed, so poisoning is normally the second step of a two-stage attack.

Why the allocator’s checks are where they are

Every consistency check costs a branch and a memory access on a path that is already the hottest in the allocator. The arena paths check because they were written when the threat model included heap corruption and because the checks are amortised over the slower path. The tcache path was added for speed, and its checks are correspondingly minimal: the size field must match the bin, the pointer must be aligned, and a per-bin free count prevents the same chunk being freed twice in a row within one bin.

The design trade is visible in the numbers. A tcache allocation is a load of the bin head, a decode, an update of the head, and a decrement of the count, with no locking because the cache is per-thread. An arena allocation takes a lock, searches a bin, and may split a chunk. The cache exists to remove that cost from the common case, and the security cost is the checks it omits.

Version differences matter here more than in most areas. Safe-linking arrived in glibc 2.32. The tcache double-free check that scans the bin arrived later. A per-process random key stored in freed chunks, used to detect reuse across threads, arrived in a later release again. Exploit techniques written against one version frequently fail on the next, not because the bug changed but because the metadata layout or the checks did. The library version is therefore part of the target description in a way that the executable’s version is not.

Where a heap primitive leads

A heap overflow that reaches only chunk metadata gives the attacker control over what a subsequent allocation returns. The value of that is entirely in what the program does with the returned memory. A structure containing a function pointer, a structure containing a length and a buffer pointer, or a structure that will later be passed to free are the useful shapes. The first yields control flow, the second yields an arbitrary read or write, and the third yields another arbitrary allocation.

The route from there usually needs one more step to become control flow. An allocation placed over a structure whose pointer is later called, or over a FILE structure that the standard library will use in a fclose or a fflush, converts a heap primitive into a call. That last shape is why the standard I/O structures are a recurring target: they contain function pointers and are allocated by the library itself, so a poisoned allocation can be aimed at one without the program cooperating.

The same destinations that make PLT and GOT slots attractive apply equally here, since a heap write can reach any address once the allocator returns it. What distinguishes heap exploitation is that the primitive is built out of the allocator’s own bookkeeping rather than out of a stack layout, and that the bookkeeping changes between library versions.

References

  1. glibc malloc Internals — glibc project
  2. glibc source: malloc/malloc.c — glibc project
  3. Once upon a free() — anonymous, Phrack 57, 2001