The doubly-linked bins (unsorted, small, large) splice chunks out with the
unlink macro. It's four pointer operations, and two of them are writes through
attacker-reachable fields. Forge a chunk's fd/bk, get it unlinked, and you
turn a heap overflow into a write — no hook, no leak of a code pointer, just the
list eating itself.
- arch
- amd64-64-little
- libc
- 2.27
- technique
- backward consolidation -> unlink() -> overwrite a known global
- requires
- a global/known pointer that holds the chunk's own address
the four operations#
This is unlink, stripped to the moves that matter:
Control P->fd and P->bk and those last two lines are two arbitrary-ish writes:
*(fd + 0x18) = bk and *(bk + 0x10) = fd. The original (pre-2004) attack just
pointed fd/bk at a GOT entry and overwrote it. Easy — and long dead.
the check that killed it#
glibc has guarded unlink for two decades with:
To unlink P, its forward neighbour must point back at it (FD->bk == P) and its
backward neighbour must point forward at it (BK->fd == P). You can't just aim
fd/bk at the GOT anymore — those addresses don't point back at P.
the bypass: point the writes at a pointer to yourself#
The escape hatch is when the program keeps a global pointer holding the chunk's
own address — which most note/heap challenges do. Call it ptr. Build a fake chunk
header inside the chunk ptr refers to, and set:
Now walk the check. FD->bk reads *(\&ptr - 0x18 + 0x18) = *(\&ptr) = ptr, and
BK->fd reads *(\&ptr - 0x10 + 0x10) = *(\&ptr) = ptr. Since ptr holds the
fake chunk's address, both equal P — the check passes. The fake header in a
0x90 chunk:
00000000 00 00 00 00 00 00 00 00 91 00 00 00 00 00 00 00 |................| 00000010 e8 5f 60 55 55 55 00 00 f0 5f 60 55 55 55 00 00 |._`UUU..._`UUU..|
(fd = &ptr-0x18, bk = &ptr-0x10, both pointing just below the global.)
trigger it with a backward consolidation#
You need unlink to fire on the fake chunk. The cleanest trigger is freeing the
next physical chunk after forging its prev_size and clearing its PREV_INUSE,
so glibc thinks the previous chunk (your fake) is free and consolidates backward
into it:
|
what you get#
After the unlink, the two writes leave ptr = &ptr - 0x18. The global now points
just below itself, so writing through it overwrites itself — the textbook
"write-what-where bootstrap":
From here it's the usual end-game: point ptr at a GOT entry, __free_hook, or a
saved return address and write your gadget.
the win
Overwrite the GOT entry for free (Partial RELRO) with system, then free a
chunk holding /bin/sh:
segfault{unl1nk_th3_l1nk3d_l1st}
the offsets are merciless
0x10 and 0x18 here are offsetof(fd) and offsetof(bk); the 0x90s must
match your real chunk size; and &ptr is a static/global address (so a
no-PIE binary, or you need a leak). One byte off and you get
corrupted double-linked list or a SIGSEGV. Single-step free(c1) in
pwndbg and watch ptr change — don't compute it blind.
modern status#
The unlink check is permanent, so the bypass (this post) is the real
technique now — and it still works wherever a program hands you a known pointer to
your own chunk and an overflow into the adjacent chunk's size. On tcache-heavy
modern glibc you'll more often reach for tcache poisoning,
but unlink is the one to understand cold: it's the clearest demonstration that
a free chunk is just a node, and the allocator trusts its links. Next, Part 5,
those links write a libc pointer for you — the unsorted bin attack.