Overview
Three pwn challenges from CSAW CTF 2026 Quals, bundled because they are structurally the same problem.
| glibc | bug | target | |
|---|---|---|---|
| Diamond Dogs | 2.31 | freed pointers never cleared — objects and buffers both | function pointer inside the object |
| Hell’s Bells | 2.31 | edit path reachable through a freed chunk | tcache fd → __free_hook |
| Saint Vespers | 2.35 | fields written after the object is freed | sing on a reclaimed object |
The mitigations move in the same direction.
| RELRO | PIE | Canary | other | |
|---|---|---|---|---|
| Diamond Dogs | Partial | No PIE | none | |
| Hell’s Bells | Full | PIE | none | |
| Saint Vespers | Full | PIE | yes | SHSTK · IBT |
All three share one skeleton.
- Free a chunk too large for tcache so it lands in the unsorted bin
- Read through the stale pointer to recover a
main_arenaaddress - Free an object holding a function pointer, reclaim it through a different path that shares the same bin, and overwrite that pointer
- Call the object’s method
The leak and the control-flow hijack are two uses of the same bug. What changes per challenge is only which path fails to clear a pointer on free, and which path can write far enough to reach the interesting offset.
The libc version is where they diverge. Diamond Dogs and Hell’s Bells run on 2.31, so neither safe-linking (2.32) nor hook removal (2.34) applies. Hell’s Bells spends exactly that: it overwrites a tcache fd directly and lands on __free_hook. Saint Vespers runs on 2.35, where both of those are gone, so it goes after a function pointer inside an application object instead — and since that never touches the freelist, safe-linking becomes irrelevant.
Shared technique: leaking libc through the unsorted bin
A chunk that goes into tcache carries a tcache-struct address in its fd, which doesn’t hand you a libc base. A chunk past tcache’s capacity (chunk size above 0x410) goes to the unsorted bin instead, and its fd/bk point near main_arena.
On glibc 2.31 the unsorted bin sits at main_arena + 96. main_arena immediately follows __malloc_hook, so the offset is reachable even without arena symbols.
| |
That assert is not optional. A libc base is page-aligned, so a nonzero low 12 bits means either the offset arithmetic or the leak site is wrong. The one line catches “it printed something, and all of it is garbage” immediately.
From 2.34 on there is no __malloc_hook symbol, so this expression doesn’t work. Measure the unsorted-bin offset in that libc and hardcode it.
The guard chunk
A chunk adjacent to the top chunk is absorbed into top on free rather than entering the unsorted bin, and then there is no pointer to leak at all.
So allocate the large chunk first, allocate a small one behind it to separate it from top, and only then free the large one.
| |
The 0x20 in slot 1 is never used again. It exists to block consolidation into top.
Diamond Dogs
Layout
| |
Symbols survive, so the globals are visible directly.
| |
There are two managed things. dogs is an array of object pointers, notes an array of byte-buffer pointers, eight slots each (0-7). Every menu handler is inlined into main, so there are no separate functions to read.
A dog object comes from malloc(0x20) with a function pointer at the end (+0x18).
| |
And the call looks like this.
| |
The bug
There are two free paths, and neither clears its pointer.
| |
The note-side dangling pointer drives the leak; the dog-side one drives the hijack.
Asymmetric write lengths
What this challenge actually turns on is that the two allocation paths write different amounts.
adopt allocates 0x20 but reads only 0x18 bytes through read_n, then zeroes +0x17. The function pointer at +0x18 is deliberately out of reach.
file_note is different. It reads the full requested size.
| |
There is an occupancy check. But shred leaves the pointer in place, so a freed slot stays occupied and each new note needs an unused slot number.
malloc(0x20) and malloc(0x20) both give chunk size 0x30, so they share a tcache bin. Which means freeing a dog and then allocating a note of the same size hands that dog chunk back as a note, and this time +0x18 is writable.
The bug is that the guard is attached to only one of the two paths.
The leak
The note read path is a write syscall, not puts.
| |
It doesn’t stop at a NULL; it emits exactly the stored length. A freed chunk’s fd comes back verbatim.
The size limit is __size - 1 < 0x1000, so 0x500 is allowed. That makes a 0x510 chunk, which exceeds the 0x410 tcache ceiling and therefore goes to the unsorted bin.
| |
Hijacking control flow
Create a dog with /bin/sh in the name field. The calling convention passes the object pointer as the first argument, so the object’s own address is the argument.
| |
file_note reclaims the chunk that release pushed into tcache and overwrites the function pointer at +0x18 with system. command invokes it as (*fp)(obj), which is system("/bin/sh").
No one_gadget, no ROP. The calling convention fills in the argument.
Full flow
| |
Notes
- The leak and the hijack use different bins.
0x510is the unsorted bin,0x30is tcache. - Drop the guard chunk and the
0x510chunk is absorbed into top, so the leak step produces nothing. adoptzeroing+0x17is code protecting the function pointer. That the protection is attached to only one allocation path is the whole challenge.
Hell’s Bells
Layout
| |
The menu structure is nearly identical to Diamond Dogs. Sixteen slots, two globals.
| |
The difference is that there are no objects. Everything here is a plain byte buffer, so there is no function pointer to overwrite. read_long, read_n, the __size - 1 < 0x1000 limit, and the write-based inspect are all the same as before.
PIE and Full RELRO are on but don’t matter. The exploit below uses neither a binary address nor the GOT.
The bug
plant checks whether a slot is empty (== 0). But defuse doesn’t clear the pointer, so a freed slot stays “occupied.”
| |
And rewire writes to that slot again.
| |
It never checks whether the chunk is freed, and the length is the original allocation size. That means the head of a freed chunk can be set to anything, and that position is tcache’s next pointer.
Where Diamond Dogs needed “reclaim through another path sharing the bin,” here the freelist is edited in place with no reclaim at all.
tcache poisoning
glibc 2.31 has no safe-linking, so whatever goes into fd is used as the next chunk address verbatim. And __free_hook still exists.
The leak is the shared technique unchanged.
| |
The rest is the actual exploit.
| |
How the 0x30 tcache bin evolves.
| |
Slot 4 holds c3 containing /bin/sh; slot 5 is __free_hook itself containing system. Then defuse on slot 4 calls free(c3), and __free_hook is invoked with c3 as its argument, which is system("/bin/sh").
Why two chunks have to be freed
Overwriting fd works with a single freed chunk. The second allocation just doesn’t follow the poisoned address.
tcache tracks counts[idx] separately, and tcache_get is only called while counts > 0. With one chunk freed, counts is 1; the first allocation drops it to 0 and the second allocation never goes through tcache. However well fd is overwritten, nothing follows the link.
With two freed, counts is 2. The first allocation takes c3 and leaves 1, and the second still sees > 0, so it returns the entries[idx] that was overwritten. Two 0x20 chunks is the minimum.
Notes
- The only difference between these two challenges is where a writable dangling pointer comes from. Diamond Dogs reclaims a freed object through another path; Hell’s Bells gets one directly because the edit path never checks the freed state. The latter is the stronger primitive, which is why the freelist can be manipulated directly.
plantchecks occupancy with!= 0whiledefuseleaves the pointer, so a slot cannot be cycled through free-and-reuse. Each operation takes a fresh slot number.__free_hookis only available because this is glibc 2.31. From 2.34 on that landing site is gone — which is the next challenge.
Saint Vespers and the Copper Choir
Layout
This is the only one of the three shipped with its original C source. No decompilation needed, but more to read.
| |
The heaviest mitigations of the three, and none of them matter, because the final target is the entry point of a legitimate function. Swapping the sing field for system and letting choir[i]->sing(choir[i]) run means the indirect branch lands on a valid function start, and nothing touches the stack, so the canary and the shadow stack are never involved. What CET blocks is jumping into the middle of a function, ROP-style.
One struct is managed.
| |
sizeof(chorister_t) is 0x58, giving chunk size 0x60. The function pointer can only be chosen from a whitelist.
| |
Index validation is clean as well.
| |
Passing static buffers to setvbuf in main is deliberate too. stdio never touches the heap, so the chunk layout is exactly what the operations produce — the source comments say as much.
The bug
op_retire writes to a field after freeing.
| |
Three lines, three problems.
choir[idx]is never set to NULL, so the freed object stays a valid slot.c->transcriptis never set to NULL, so a pointer to the freed transcript survives.c->active = 0afterfree(c)is a write to freed memory.
That last write is at +0x50, which misses the next (+0x00) and key (+0x08) fields tcache uses. The freelist stays intact and the freed object quietly stays usable.
op_restore undoes the write.
| |
retire sets the flag to 0 and restore puts it back to 1, so the freed object passes every path that requires active again: inscribe, recite, rename, perform.
In source, c->active = 0 one line below free(c) is immediately visible. In a decompiler it would be one offset assignment into a global array.
The leak
A transcript length of 0x500 gives a 0x510 chunk, past the tcache ceiling. The second recruit is the guard — the same move as the 0x20 guard chunks in the other two, except here an object and a transcript stack up behind it, so consolidation into top is blocked more firmly.
| |
retire(0) frees both the transcript and the object; restore(0) revives active. op_recite uses c->transcript and c->transcript_len as they stand, so the contents of the freed 0x510 chunk come out through write.
| |
With no __malloc_hook, the offset was measured in this libc and hardcoded.
Same size class
This is the core of the challenge. The transcript length is mine to choose, anywhere from 1 to 0x1000. And sizeof(chorister_t) is 0x58.
So a transcript of length 0x58 is allocated from the same 0x60 bin as an object. The allocation order inside op_recruit gives the result.
| |
One recruit allocates twice from the 0x60 bin. The first becomes a new object; the second becomes a buffer whose entire contents I supply. Put two freed objects in the bin and the second allocation picks up another object that is still attached to a slot.
| |
The 0x60 bin and what each allocation returns.
| |
obj0 is still choir[0], and still active thanks to restore(0). The payload reconstructs that struct field by field.
| |
op_perform passes the object to itself per the calling convention.
| |
The struct’s start address is the address of name, so this is system("/bin/sh").
Notes
- The whitelisted function pointer is enforced only at creation time. The
sing_tablerange check lives insideop_recruit, and no path that reaches thesingfield afterward goes through it. NAME_SIZE 0x38and the transcript range1..0x1000overlap at0x58. A caller-sized buffer and a fixed-size struct share a size class.- Safe-linking is never touched. Instead of overwriting an fd, the exploit uses the normal allocation order to receive the chunk it wants.
op_rosterprints all0x38bytes ofnameregardless ofactive. Pointed at a freed object it yields a mangled tcachenext, but this solve didn’t need it.
Looking at it again
Re-reading these while writing the post turned up paths that went unused at the time. The following were not considered during the solve, and they are a re-read of the code rather than anything actually run.
Diamond Dogs — tcache poisoning was available
A dog chunk reclaimed through file_note is writable from offset 0, and that position is tcache’s next. The solve wrote /bin/sh there; writing an address instead gives the same poisoning Hell’s Bells used. Being 2.31, there is no safe-linking either.
No PIE compounds it. The globals sit at fixed addresses.
| |
Landing a chunk on note_sz and writing an arbitrary length extends read_note’s output by that much. Covering notes as well puts both the address and the length under control, which is an arbitrary read. Writing into a poisoned chunk with file_note is an arbitrary write.
The occupancy check does constrain it: each note needs a fresh slot number and there are only eight. Covering both globals means counting allocations up front.
Hell’s Bells — the large chunk was only read
rewire doesn’t check the freed state, so the 0x500 chunk is writable too. The solve only ever inspected it.
That means the fd/bk of a chunk sitting in the unsorted bin can be written, which is the precondition for an unsorted-bin or largebin attack. The tcache route was simpler, so that was the only one used.
rewire also covers key (+0x08) along with next (+0x00). Clearing key gets past 2.31’s tcache double-free check. There was no need for it, but it was there.
Saint Vespers — arbitrary read/write was skipped
The biggest miss. The final payload reconstructs the whole struct, and that includes transcript and transcript_len.
| |
Both were zero-filled. But those are exactly the two fields op_recite and op_inscribe use.
| |
An (address, length) pair there would have turned that slot into a reusable arbitrary-read and arbitrary-write handle — not once, but for as long as the slot lives. Strictly stronger than overwriting sing alone, for the same single write.
solve.py defines rename() and roster() helpers and never calls either. Those were the remaining paths.
op_rosterprints all0x38bytes ofnameregardless ofactive. Pointed at a freed object it yields a mangled tcachenext, and since 2.35 mangles with(chunk address >> 12) ^ pointer, the heap base falls out of it.op_renamewrites0x37bytes at offset 0 ofname. Pointed at a freed object it covers bothnextandkey.
Together they make tcache poisoning work on 2.35: get the heap base from roster, compute the mangling, and overwrite next with rename. What safe-linking blocks is poisoning without knowing the address; it doesn’t block anything when a leak path is open alongside it.
Summary
How each challenge obtains its UAF, and where it lands.
| how the dangling pointer is obtained | landing site | |
|---|---|---|
| Diamond Dogs | reclaim a freed object through a different allocation path | the object’s function pointer |
| Hell’s Bells | the edit path never checks the freed state | tcache fd → __free_hook |
| Saint Vespers | a post-free field assignment keeps the slot alive | function pointer on an object received from the same size class |
The leak is identical in all three. Free something past the 0x410 tcache ceiling into the unsorted bin, then read through the pointer that was never cleared.
What differs is the libc version.
| 2.31 | 2.35 | |
|---|---|---|
| tcache fd mangling | none | (chunk address >> 12) ^ pointer |
__malloc_hook / __free_hook | present | removed |
| unsorted-bin offset | __malloc_hook + 0x10 + 96 | no symbol, measure it |
| shortest route | poison the freelist → hook | function pointer on an application object |
On 2.31 the hooks exist, so one freelist manipulation finishes the job. On 2.35 that landing site is gone, and the target moves from libc to the program’s own objects. That route never touches the freelist, so safe-linking never comes into play.
Full scripts: ctf-writeups/2026-csaw-quals