The glibc Heap Explained: Chunks, Bins, Arenas, and tcache

The glibc Heap Explained: Chunks, Bins, Arenas, and tcache - article cover image RE & Pwn
Time it takes to read this article 8 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Almost every serious Linux userland exploit written today eventually touches the heap, and on a mainstream Linux system the heap *is* glibc’s ptmalloc2 allocator. Before you can poison a tcache free-list, run an unsorted bin attack, or pull off a House-of-something, you have to be fluent in the data structures ptmalloc manipulates: the chunk header, the family of bins, the arena, and the per-thread cache. This article is the map you keep open in the other monitor — a precise, version-aware tour of how glibc lays out heap memory and how it decides which chunk to hand back from malloc.

Everything here is descriptive rather than offensive, but it is the substrate the offensive techniques stand on. The reason a corrupted 8-byte pointer turns into an arbitrary write is that the allocator stores its bookkeeping *inside the chunks themselves*, interleaved with your data, and trusts that bookkeeping on the next call. Understand the layout and the trust boundaries, and the attacks stop being magic incantations and become obvious consequences of the design. We target modern glibc (2.26 through 2.35) and note where behaviour changed across versions.

Attack Prerequisites

This is foundational material, so the only real prerequisite is a controlled environment in which to observe the allocator. To follow the walkthrough you want:

  • A Linux box (or container) running a known glibc version — check with ldd --version or by inspecting the libc.so.6 the binary is linked against.
  • gdb with a heap plugin — pwndbg or GEF, which add heap, bins, vis_heap_chunks, and arena commands that decode the raw structures for you.
  • A small C program you compile yourself so you can place breakpoints around malloc/free and watch the bins mutate.
  • The glibc source (malloc/malloc.c) for the exact version — the struct definitions and the inline checks are the ground truth, not any blog summary.

How It Works

The fundamental unit is the chunk. A malloc(n) request is rounded up and serviced as a malloc_chunk whose in-memory layout is a small header followed by the user region. The header is two words: prev_size (only meaningful — and reused as user data — when the previous chunk is in use) and size. The size field is where three low bits are stolen as flags, because chunk sizes are always 16-byte aligned on x86-64: PREV_INUSE (0x1), IS_MMAPPED (0x2), and NON_MAIN_ARENA (0x4). When a chunk is free, the first two user words are repurposed as the fd and bk pointers that thread it into a doubly-linked bin; large-bin chunks additionally use fd_nextsize/bk_nextsize. That overlap — user data when allocated, list pointers when free — is the single most important fact in heap exploitation.

Requests are normalised by request2size: the usable size is at least MINSIZE (0x20 on 64-bit) and the returned size includes the 8-byte size field of the *next* chunk, which a live chunk borrows for user data thanks to the PREV_INUSE trick. This is why a malloc(24) and a malloc(0x18) both land in the 0x20 chunk class: 24 bytes of request + 8 bytes of size word = 32 = 0x20. Getting comfortable translating request sizes into chunk sizes (and therefore into bin indices) is essential, because every technique is phrased in chunk sizes.

Free chunks are sorted into bins, and there are several species. Fastbins hold small chunks (default up to 0x80 on 64-bit, governed by global_max_fast) in single-linked LIFO lists and never coalesce eagerly. The unsorted bin is a single doubly-linked staging list that everything non-fast passes through on free. Small bins are exact-size doubly-linked lists (one size class each, 16 bytes apart). Large bins cover size ranges and keep their chunks sorted in descending order using the fd_nextsize/bk_nextsize skip pointers. Layered on top since glibc 2.26 is the tcache, a per-thread array of 64 single-linked bins holding up to 7 chunks each for sizes up to 0x410 — checked first on both malloc and free, which is why it dominates modern technique choice.

All of this lives inside an arena. The main_arena is a static malloc_state in libc’s data section; it owns the top chunk (the wilderness at the end of the heap that requests are carved from when no bin can satisfy them), the fastbin array, and the bins array of 254 list heads. Threads that contend for the main arena get additional arenas allocated via mmap, each with its own top and bins. Because main_arena sits at a fixed offset inside libc, a freed chunk that lands in a libc bin ends up with fd/bk pointing back into libc — the mechanism behind nearly every libc leak.

Vulnerable Code / Setup

There is no bug to exhibit here — the point is to *observe* the allocator. A tiny driver that allocates a few sizes and frees them in a controlled order is enough to make every bin visible under gdb:

// gcc -O0 -g -no-pie -fno-stack-protector heaptour.c -o heaptour
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char *a = malloc(0x18);   // -> 0x20 chunk (tcache bin idx 0)
    char *b = malloc(0x28);   // -> 0x30 chunk
    char *big = malloc(0x420);// > 0x410: too large for tcache
    char *guard = malloc(0x18);// keeps 'big' off the top chunk on free

    strcpy(a, "AAAAAAAA");
    memset(b, 'B', 0x28);

    free(a);                  // -> tcache[0x20]
    free(b);                  // -> tcache[0x30]
    free(big);                // -> unsorted bin (then a small/large bin)
    (void)guard;
    return 0;
}
C

Two deliberate choices make the demo legible. The 0x420 allocation is larger than the tcache ceiling (0x410), so freeing it exercises the unsorted bin path rather than the tcache — that is how you see a libc-pointing fd/bk. The guard chunk prevents big from being adjacent to the top chunk, which would otherwise cause free to consolidate it straight into the wilderness instead of binning it. Build state matters for reasoning about primitives, so confirm it:

$ checksec --file=./heaptour
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
$ ldd --version | head -1
ldd (Ubuntu GLIBC 2.35-0ubuntu3) 2.35
Bash

Walkthrough / Exploitation

The ‘exploitation’ here is reconnaissance: driving the program under pwndbg and reading the structures directly, because that is the muscle memory every later technique assumes. Break after the allocations and dump the heap:

gdb -q ./heaptour
pwndbg> break free
pwndbg> run
pwndbg> heap            # walk every chunk: addr, size, flags (prev_inuse)
pwndbg> vis_heap_chunks # colourised layout; fd/bk shown inline once freed
Bash

Step past the three free calls and inspect the bins after each. The tcache bins fill first; the large allocation lands in the unsorted bin and only gets sorted into a small/large bin on the next malloc that scans it:

pwndbg> continue        # let free(a), free(b) run
pwndbg> bins
tcachebins
0x20 [  1]: 0x5555...2a0 <- (safe-linked next)
0x30 [  1]: 0x5555...2c0
pwndbg> continue        # free(big)
pwndbg> bins
unsortedbin
all: 0x5555...300 -> 0x7ffff7...b20 (main_arena+96) <- 0x5555...300
Bash

That main_arena+96 value is the whole reason libc leaks exist: the freed chunk’s fd/bk now point at the unsorted bin head, which lives inside libc. Read that pointer, subtract the known offset, and you have the libc base. To see the arena and top chunk explicitly, and to inspect the tcache management structure that itself lives on the heap:

pwndbg> arena           # main_arena: top, last_remainder, fastbins, bins
pwndbg> tcache          # decodes tcache_perthread_struct: counts[] + entries[]
pwndbg> p main_arena    # raw malloc_state; note 'top' and the bins array
pwndbg> x/4gx a-0x10    # the raw chunk header of 'a': prev_size, size|flags
Bash

Finally, prove the size-to-class arithmetic to yourself: malloc(0x18) produced a chunk whose size field reads 0x21 — that is 0x20 for the chunk plus the PREV_INUSE bit set in the low nibble. Reading raw size fields and mentally stripping the low three flag bits is the single most repeated operation in heap work, and doing it by hand a few times here pays for itself many times over.

Note: The tcache is allocated lazily on the *first* malloc of a thread: glibc carves a tcache_perthread_struct out of the new heap, so it is usually the very first chunk on the heap. It contains a counts[64] array (one byte per bin in older layouts, extended in newer ones) and an entries[64] array of list heads. Because it is a normal heap object, an overflow early on the heap can corrupt the counts/entries directly — a powerful primitive covered in the tcache-poisoning deep dive.

Opsec: Numbers like the offset from a leaked unsorted-bin pointer to the libc base, or main_arena+96, are glibc-version and build specific. Never hardcode them across targets: derive the libc base from the leak at runtime, and fingerprint the exact libc (e.g. via a build-id or the leaked bytes against a libc database) before trusting any offset.

Detection and Defense

This article documents normal allocator behaviour, so ‘defense’ means the hardening that ships in the allocator itself plus the tooling that catches misuse of it:

  • Keep glibc current — each release since 2.26 added integrity checks (unsorted-bin corruption check in 2.29, safe-linking and tcache alignment checks in 2.32, hook removal in 2.34) that raise the cost of every technique below.
  • Build with Full RELRO and PIE so the GOT is read-only and libc/heap addresses are randomised, forcing an attacker to leak before they can aim.
  • Run sanitizers in CI — AddressSanitizer and Valgrind/Memcheck detect the underlying lifetime and bounds bugs deterministically, long before they become allocator corruption.
  • Watch for glibc abort strings — messages like malloc(): corrupted top size, free(): invalid pointer, and malloc(): unaligned tcache chunk detected in logs or cores are high-signal indicators of heap manipulation.

Real-World Impact

ptmalloc is the default allocator for the overwhelming majority of Linux processes, so its internals are directly load-bearing for the security of browsers, language runtimes, network daemons, and the CTF ecosystem that trains the researchers who audit them. Heap corruption bug classes — use-after-free (CWE-416), heap overflow (CWE-122), and double-free (CWE-415) — are perennial entries in the CWE Top 25, and the reason they remain exploitable rather than merely crashy is precisely the interleaving of metadata and data described here. The visible arc of glibc hardening from 2.26 onward is the maintainers responding, structure by structure, to the techniques this series explores.

Conclusion

The glibc heap is a set of intrusive free-lists layered over size-classed chunks, arbitrated by an arena, and fronted by a fast, lightly-checked per-thread cache. Chunks carry their own metadata next to your data; free chunks lend their user region to fd/bk list pointers; libc bins leak libc addresses; and the tcache’s speed-first design is why it is the first thing an attacker reaches for. Internalise the chunk header, the bin taxonomy, and the main_arena layout, and every technique in the rest of this series reads as a natural exploitation of a structure you can now decode by hand under gdb.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL