algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

reading _int_free: a source-code review of the checks

2026-06-066 min readheap glibc internals

The heap series is a catalogue of techniques; this is the other half — the defence they're all negotiating with. _int_free is glibc's free path, and over the years it has accumulated a sequence of integrity checks, each one a tombstone for some technique. Read them in order and the whole map of "what still works on libc 2.X" falls into place.

targetglibc/malloc/malloc.c
function
_int_free (and unlink_chunk)
arch
amd64-64-little
lens
every malloc_printerr, in the order free() hits it

the entry gates: pointer and size#

Before anything else, two sanity checks on the chunk you handed back:

malloc.c — _int_free, topc
1
2
3
4
5
6
7
8
9
p    = mem2chunk(mem);
size = chunksize(p);

if (__builtin_expect((uintptr_t) p > (uintptr_t) -size, 0)
    || __builtin_expect(misaligned_chunk(p), 0))
    malloc_printerr("free(): invalid pointer");

if (__glibc_unlikely(size < MINSIZE || !aligned_OK(size)))
    malloc_printerr("free(): invalid size");

free(): invalid pointer rejects a pointer near the top of the address space or not 16-byte aligned — it stops you from free()-ing a wild or mid-chunk address. free(): invalid size demands the size field be >= MINSIZE and aligned. Together they mean a forged chunk needs a plausible, aligned header before it can even enter the allocator's bins — the baseline tax on every fake-chunk technique (House of Spirit pays it first).

the tcache: fast, and historically wide open#

If the size fits a tcache bin, free returns almost immediately — which is why tcache attacks are so easy, and why the only guards here are recent:

the tcache pathc
if (tcache != NULL && tc_idx < mp_.tcache_bins) {
    tcache_entry *e = (tcache_entry *) chunk2mem(p);
    /* added in 2.29: catch a chunk freed twice into the same bin */
    if (__glibc_unlikely(e->key == tcache))
        for (tcache_entry *t = tcache->entries[tc_idx]; t; t = t->next)
            if (t == e) malloc_printerr("free(): double free detected in tcache 2");
    if (tcache->counts[tc_idx] < mp_.tcache_count) {
        tcache_put(p, tc_idx);
        return;                       /* <- no consolidation, no neighbour checks */
    }
}

Pre-2.29 there was nothing here — tcache dup was a free, free, done. The e->key == tcache test is the fix: tcache_put stamps a freed chunk's key field, so a second free of the same chunk is caught by the scan. The two famous escapes: the bin only holds 7, so park 7 chunks first and your real double-free falls through to the fastbin; or corrupt the key field via a UAF write so the scan never triggers. Note what's absent — no size-of- next check, no consolidation — which is precisely why tcache poisoning (overwrite next, allocate anywhere) is the cleanest primitive in the modern heap.

the fastbin: a double-free guard one chunk deep#

the fastbin pathc
if ((unsigned long) (size) <= (unsigned long) (get_max_fast())) {
    /* next chunk's size must be sane */
    if (__builtin_expect(chunksize_nomask(chunk_at_offset(p, size)) <= CHUNK_HDR_SZ, 0)
        || __builtin_expect(chunksize(chunk_at_offset(p, size)) >= av->system_mem, 0))
        malloc_printerr("free(): invalid next size (fast)");
    ...
    old = *fb;
    /* the chunk at the TOP of the fastbin must not be the one we're freeing */
    if (__builtin_expect(old == p, 0))
        malloc_printerr("double free or corruption (fasttop)");
    ...
    if (old != NULL && __builtin_expect(fastbin_index(chunksize(old)) != idx, 0))
        malloc_printerr("invalid fastbin entry (free)");
}

double free or corruption (fasttop) only compares against the current head of the fastbin — so it stops free(a); free(a) but not free(a); free(b);free(a). That one-deep blind spot is the entire fastbin dup. invalid fastbin entry (free) then insists a linked chunk's size maps back to the same bin index — the check a fastbin-dup-into-fake-chunk has to satisfy by lining up a valid size at the target.

Larger chunks take the slow path, and it's the most heavily guarded — four checks before any list surgery:

the unsorted/consolidation pathc
if (__glibc_unlikely(p == av->top))
    malloc_printerr("double free or corruption (top)");

if (__builtin_expect(contiguous(av)
        && (char *) nextchunk >= ((char *) av->top + chunksize(av->top)), 0))
    malloc_printerr("double free or corruption (out)");

if (__glibc_unlikely(!prev_inuse(nextchunk)))
    malloc_printerr("double free or corruption (!prev)");

if (__builtin_expect(chunksize_nomask(nextchunk) <= CHUNK_HDR_SZ, 0)
    || __builtin_expect(nextsize >= av->system_mem, 0))
    malloc_printerr("free(): invalid next size (normal)");

(top) blocks freeing the top chunk; (out) blocks a chunk whose neighbour falls outside the arena; (!prev) checks the next chunk's PREV_INUSE bit is set — a clear-it-via-overflow double free is caught here, and it's exactly the bit the poison null byte and House of Einherjar manipulate. Then backward consolidation calls unlink_chunk, which carries the checks that ended the classic unsafe unlink:

unlink_chunk — the two checks that killed classic unlinkc
1
2
3
4
if (__builtin_expect(chunksize(p) != prev_size(next_chunk(p)), 0))
    malloc_printerr("corrupted size vs. prev_size");
if (__builtin_expect(fd->bk != p || bk->fd != p, 0))
    malloc_printerr("corrupted double linked list");

fd->bk == p && bk->fd == p is the integrity invariant of a doubly-linked list; the old *target = &target - 0x18 write can't satisfy it, which is why unsafe unlink now needs a pointer to a controlled pointer (the self-referencing trick). corrupted size vs. prev_size ties prev_size to the real chunk size — the check the poison null byte must thread on 2.29+.

reading the map#

Walk the function once and the modern heap meta is just a reading of these gates:

  • tcache poisoning survives because the tcache path has no next integrity check and returns before consolidation — the path of least resistance.
  • fastbin dup survives because (fasttop) is one chunk deep.
  • classic unlink is dead because unlink_chunk verifies fd->bk/bk->fd.
  • null-byte / Einherjar are constrained by (!prev) and corrupted size vs.prev_size, not forbidden — you satisfy them with a matching prev_size.

The single most useful exercise in heap exploitation isn't memorising houses — it's being able to recite this function. Every technique is a statement about which of these malloc_printerr lines it doesn't trip, and every new glibc release is a diff that adds one more. Read the source, and you stop guessing whether a technique works on a target: you can see the gate it has to pass.