$ / 4 min read/pwn
Saint Vespers and the Copper Choir
A struct with a name buffer sitting directly under a called function pointer. Overwrite what you write to, on glibc 2.35 with the hooks gone.
- section
- CSAW26
- event
- ctf.csaw.io ↗
- category
- pwn
- status
- ● solved
- provided
- vespers, vespers.c, libc.so_3.6 (glibc 2.35), ld-2.35.so
The object
typedef struct chorister {
char name[0x38]; // 0x00
void (*sing)(struct chorister *self); // 0x38 <- called as c->sing(c)
char *transcript; // 0x40
size_t transcript_len; // 0x48
int active; // 0x50
} chorister_t; // sizeof 0x58 -> chunk 0x60
Vulnerability
op_retire() frees c->transcript and c, then writes c->active = 0 into
the freed chunk, and never clears choir[idx]:
if (c->transcript) free(c->transcript);
free(c);
c->active = 0; // UAF write
op_restore() sets c->active = 1 on that same freed object, so every other
menu op now operates on a dangling chorister_t.
Critically, tcache only clobbers the first 0x10 bytes of a freed chunk (fd +
key), so sing, transcript, transcript_len and active all survive intact.
Target
glibc 2.35, Full RELRO, PIE, NX, canary. Hooks were removed in 2.34, so there is
no __free_hook — but none is needed: the program hands over an indirect call
with a controlled argument, and name[] is at offset 0, so the argument is a
pointer to attacker text.
Exploit
- Layout — recruit seat 0 with a
0x500transcript and seat 1 as a guard, so the big transcript is not adjacent to the top chunk. - Dangle — retire + restore seat 0. The
0x510transcript is too big for tcache, so it lands in the unsorted bin withfd = bk = main_arena+96, and the object is dangling-but-active. - Leak —
op_recite(0)doeswrite(1, c->transcript, c->transcript_len)on the freed buffer, dumping the arena pointer.main_arena+96sits atlibc+0x21ace0in this build. - Groom — retire seat 1 so
tcache[0x60] = [C1, C0]. - Reclaim — recruit seat 2 with a
0x58transcript. The choristermallocpopsC1; the transcriptmallocpopsC0, the danglingchoir[0], andread_exactwrites0x58raw attacker bytes straight over the struct:
forged = b"/bin/sh\x00".ljust(0x38, b"\x00") # name[] — also the argument
forged += p64(libc.sym.system) # sing
forged += p64(0) + p64(0) + p32(1) # transcript, len, active
recruit(b"C", 0, 0x58, forged.ljust(0x58, b"\x00"))
- Fire —
op_perform()iterates active choristers and callschoir[0]->sing(choir[0])→system("/bin/sh").
Gotchas
main_arenais stripped from the shipped libc. Calibrate the offset empirically against/proc/<pid>/mapsrather than guessing.setvbuf(stdout, _IOFBF)means nothing is flushed beforesystem()executes. Waiting on the “Performance begins” banner deadlocks forever — talk straight to the shell.systembegins withendbr64, so the binary’s IBT marking is not an obstacle.
Root cause
A single missing assignment. Clearing choir[idx] on free closes every
primitive. The active flag is a red herring as a guard — it lives inside the
allocation it is supposed to protect.
Solver: ~/ctf/work/vespers/local/x.py
## challenge files
21 files · 3.0 MB- vespers
- vespers.c
- libc.so_3.6
- ld-2.35.so
- solve/
flags redacted; flag images and local flag.txt files removed