stack cache picoCTF 2022 Solution

Published: July 20, 2023

Description

The name is the hint: the flag gets cached on the stack and never cleaned up. There is no format string and no ret2libc here. A buffer overflow lets you redirect execution so that win() loads the flag into a stack buffer (but never prints it), then UnderConstruction() prints uninitialized stack slots that still hold those leftover flag bytes.

Remote + binary

Download the binary and read it in Ghidra. Locate the input function, win(), and UnderConstruction() so you know where each one lives before starting the analysis.

bash
wget <url>/vuln
bash
chmod +x vuln
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 1
    See how the flag is left on the stack
    Observation
    I noticed the challenge name 'stack-cache' and that Ghidra showed win() reading the flag into a local buffer with no printf on it, which suggested the flag was being cached on the stack rather than printed, and that a second function with uninitialized locals would be needed to expose those bytes.
    win() opens the flag file and reads it into a local stack buffer, then returns. C does not zero a stack frame on return, so the flag bytes remain in that region of the stack until something overwrites them. win() deliberately never prints the flag, so reaching it is not enough on its own.
    bash
    # In Ghidra: win() -> fopen/fread into a local buffer, no printf of it.
    bash
    # UnderConstruction() -> printf("%p %p ...") on locals it never sets.
    What didn't work first

    Tried: Look for a format string vulnerability because UnderConstruction() calls printf with %p specifiers.

    The format string is hardcoded in the source - the attacker never controls it. Format string exploits require passing a user-supplied string directly to printf as the first argument. Here the vulnerability is purely a buffer overflow combined with uninitialized stack disclosure, so fmt-string techniques (writing %p/%x into the input) have no effect.

    Tried: Assume reaching win() alone is enough to get the flag printed.

    win() reads the flag into a local buffer with fread but never calls printf or puts on that buffer. Running win() silently loads the flag bytes onto the stack and returns nothing printable. You must chain a second function (UnderConstruction) that exposes those bytes as its own uninitialized locals.

    Learn more

    Why leftover stack memory leaks the flag. Local variables live in the function's stack frame, which is just a slice of the stack reused frame after frame. When win() returns, its frame is logically gone but the bytes are untouched. A later function whose frame overlaps that same region, and which prints its uninitialized locals, will print whatever win() left behind. This is classic uninitialized-memory disclosure, the "clean up your memory" lesson the flag spells out.

  2. Step 2
    Overflow to chain win() then UnderConstruction()
    Observation
    I noticed the input function wrote into a fixed-size buffer without bounds checking, which suggested a classic buffer overflow; and since win() never prints the flag but UnderConstruction() prints uninitialized stack slots, I needed to chain both return addresses to first seed the stack with flag bytes and then print them.
    Find the offset from the overflowable buffer to the saved return address with a cyclic pattern. Then build a payload that returns into win() and, as win()'s own return target, the address of UnderConstruction(). win() runs first and fills the stack with the flag; UnderConstruction() runs next and prints the now-flag-bearing uninitialized slots as hex.
    python
    python3 - <<'PY'
    from pwn import *
    
    e = ELF("./vuln")
    io = remote("saturn.picoctf.net", <PORT_FROM_INSTANCE>)
    
    OFF = 0      # offset to saved RIP from cyclic(); fill from your crash
    payload  = b"A" * OFF
    payload += p64(e.sym["win"])               # run win(): loads flag onto the stack
    payload += p64(e.sym["UnderConstruction"]) # then print uninitialized stack (=flag)
    
    io.sendline(payload)
    print(io.recvall(timeout=3))
    PY

    If the printed hex does not contain the flag, try chaining straight into UnderConstruction() with a ret for alignment, or adjust which function's frame overlaps win()'s buffer; the two frames must share the stack region win() wrote to.

    What didn't work first

    Tried: Put only win()'s address in the payload and check the output, expecting the flag to appear.

    win() has no printf call for the flag buffer, so all its output is silence. The terminal hangs or closes with no flag. You need a second return address in the payload pointing to UnderConstruction() so those leftover stack bytes get printed as hex.

    Tried: Use cyclic() offset guessing from a crash in GDB and apply that same offset against the remote service without verifying whether the binary is PIE.

    The binary is compiled without PIE, so the function addresses are fixed and taken directly from ELF symbols via e.sym. The common mistake is subtracting an extra 8 for the saved RBP when the cyclic pattern already accounts for it, landing the write 8 bytes too late and corrupting the frame instead of redirecting it.

    Learn more

    Why two chained returns. You cannot print the flag from win() (it does not), and UnderConstruction() prints garbage on its own (its locals are never set). The exploit is to run them back to back so the second function inherits the first's leftover bytes. Stacking return addresses after the overflow is the simplest way to call two functions in sequence without a full ROP chain.

  3. Step 3
    Reassemble the hex into the flag
    Observation
    I noticed UnderConstruction() printed each 8-byte word as a single hex integer rather than raw ASCII, which suggested the bytes were in little-endian order and would need to be repacked with p64 and concatenated to recover the original flag string.
    Collect the hex values UnderConstruction() prints, convert each back to 8 little-endian bytes (p64), concatenate in order, and strip to printable ASCII. The picoCTF{...} flag falls out of the reassembled buffer.
    python
    python3 - <<'PY'
    import re
    from pwn import p64
    leaked = io.recvall(timeout=3).decode(errors="ignore")
    vals = [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", leaked)]
    blob = b"".join(p64(v & (2**64 - 1)) for v in vals)
    print(blob)   # find picoCTF{...} in here
    PY

    Expected output

    picoCTF{Cle4N_uP_M3m0rY_...}
    What didn't work first

    Tried: Treat each printed hex value as big-endian and decode with bytes.fromhex() directly, without packing with p64.

    The values are 64-bit integers stored little-endian in the stack buffer. Decoding them as raw big-endian hex reverses the byte order of every 8-byte word, producing scrambled characters instead of readable ASCII. p64() re-packs each integer back into 8 little-endian bytes, restoring the original layout of win()'s buffer.

    Tried: Grep the raw output for 'picoCTF' before reassembling, assuming the flag appears as a readable string in the hex dump.

    UnderConstruction() prints each 8-byte chunk as a single integer (0x...), not as individual ASCII characters. The word 'picoCTF' is split across two or more of these integer fields, so a substring search on the raw output finds nothing. Reassembly with p64 is required to coalesce the words back into a contiguous string.

    Learn more

    Putting the bytes back together. Each leaked value is 8 bytes of the flag stored little-endian. Re-packing every value with p64 and joining them reconstructs the original byte order of win()'s buffer, where the flag string lives. See Pwntools for CTF for the packing helpers.

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{Cle4N_uP_M3m0rY_...}

No format string and no ret2libc. Overflow the return address to call win() (which reads the flag into a stack buffer but never prints it) and then UnderConstruction() (which prints uninitialized stack slots that still hold those bytes). Reassemble the leaked hex with p64 to read the flag.

Key takeaway

C does not zero stack frames on function return, so local variables including sensitive buffers persist in memory until something overwrites them; a function that reads a secret into a local buffer without printing it leaves those bytes recoverable by any subsequent function whose frame overlaps the same stack region. Chaining a read-but-no-print function with a print-uninitialized-locals function is a two-step uninitialized-memory disclosure that exploits exactly this property.

Related reading

Want more picoCTF 2022 writeups?

Tools used in this challenge

What to try next