buffer overflow 1 picoCTF 2022 Solution

Published: July 20, 2023

Description

A classic 32-bit ret2win challenge. The binary has a vulnerable input function and a win() function that prints the flag but is never called normally.

Your goal: overflow the stack buffer, find the exact offset to the saved return address, and overwrite it with the address of win().

Download the binary and make it executable.

Install pwntools if needed: pip install pwntools.

Use cyclic patterns, objdump, and pwntools to build the exploit.

bash
wget https://artifacts.picoctf.net/c/188/vuln && chmod +x vuln
bash
pip install pwntools
bash
cyclic 100 | ./vuln
bash
objdump -d vuln | grep -A5 win

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
The Buffer Overflow and Binary Exploitation guide covers the ret2win technique used here: finding the win address, calculating the offset, and building the payload with pwntools.
  1. Step 1
    Find the offset with a cyclic pattern
    Observation
    I noticed the binary uses gets() with a fixed 32-byte buffer and no bounds check, which suggested the saved return address lies at a predictable offset from the start of our input, and a cyclic De Bruijn pattern would reveal that exact offset from the EIP value at the crash.
    Generate a De Bruijn cyclic pattern, send it as input, then read the value in EIP from the crash to find the exact offset.
    bash
    cyclic 100 | ./vuln
    bash
    cyclic -l <EIP_VALUE_FROM_CRASH>
    What didn't work first

    Tried: Guess a round-number offset like 32 or 40 instead of using the cyclic pattern.

    The buffer is declared as 32 bytes but the compiler may add alignment padding or extra local variables, pushing the saved return address further out. Guessing 32 lands inside the buffer, not on EIP, so the binary either continues running normally or crashes with a different instruction pointer value. The cyclic pattern removes the guessing entirely: the unique 4-byte substring at EIP encodes the exact offset without any layout assumptions.

    Tried: Run cyclic -l on the raw ASCII characters shown in the crash message rather than the hex EIP value.

    cyclic -l expects the 4-byte little-endian integer that the CPU loaded into EIP, not the ASCII string itself. Reading 'aaad' from the terminal and passing the string 'aaad' instead of 0x61616164 silently queries the wrong sequence position and returns an incorrect offset. Always pass the hex value printed under 'eip' in the register dump.

    Learn more

    A De Bruijn sequence (called a cyclic pattern in pwntools) is a string where every possible 4-byte substring appears exactly once. When it overwrites the saved return address, the value in EIP after the crash is a unique 4-byte substring, and you can look up exactly how far into the sequence that substring appears - giving you the precise offset from the start of your input to the saved return address.

    Stack frame layout (32-bit cdecl) when vuln() is about to ret:

    high addr  +-------------+
               | saved eip   |  <- payload[44:48]   = p32(win)
               +-------------+
               | saved ebp   |  <- payload[40:44]
               +-------------+
               | local vars  |  <- payload[32:40]   (e.g. 8-byte gap)
               +-------------+
               | char buf[32]|  <- payload[0:32]    "AAAA...AAAA"
    low addr   +-------------+ <- esp at gets()

    gets() writes from low to high address, no length check. After 32 bytes you spill into the local-var slots (8 bytes), then the 4-byte saved ebp (offset 40), then the 4-byte saved eip (offset 44). Bytes 44-47 of your input become the new return address. ret pops them into eip and jumps.

    cyclic 100 generates a 100-byte De Bruijn sequence. cyclic -l 0x61616164 (replace with your actual EIP value) tells you the offset. Alternatively, run under GDB (gdb ./vuln then run, paste the cyclic, and info registers after the crash).

    The typical offset for this challenge's 32-byte buffer is ~44 bytes (32-byte buffer + 8 bytes of padding + 4-byte saved EBP), but treat that as an estimate, not a fact. Compiler version, optimization level, and any added locals shift the layout. Always confirm with a local cyclic run before you start guessing remote.

  2. Step 2
    Find the address of win()
    Observation
    I noticed the challenge description states that win() is a real function in the binary that never gets called normally, which suggested its address is already present in the ELF symbol table and can be read directly with objdump or nm without any runtime leaking.
    Use objdump to disassemble the binary and locate the address of win(). This is the address you will overwrite EIP with.
    bash
    objdump -d vuln | grep '<win>'
    bash
    nm vuln | grep win

    Expected output

    picoCTF{addr3ss3s_ar3_3asy_c1...}
    What didn't work first

    Tried: Use readelf -s vuln to find the win address and copy the hex value as big-endian into the payload.

    readelf -s prints the correct address, but x86 stores multi-byte integers in little-endian byte order. Copying the address as a raw big-endian hex string and appending it directly to the payload means EIP gets loaded with the bytes reversed, jumping to a garbage address. Always pack the address with p32() in pwntools, which handles the byte reversal automatically.

    Tried: Grep for 'win' in strings vuln output to find the address.

    strings only extracts printable ASCII sequences embedded in the binary data. The address of win() is stored as a binary integer in the ELF symbol table, not as a readable string, so strings vuln will never print it. Use objdump -d or nm, which parse the ELF structure and decode symbol addresses correctly.

    Learn more

    objdump -d disassembles all executable sections of the binary. The output shows each function, its starting address, and the machine instructions. Looking for the symbol win gives you the target address directly.

    nm lists symbol names and their addresses from the symbol table. It's faster when you just need the address of a named function without the full disassembly.

    In 32-bit ELF binaries without PIE, addresses are fixed at link time, so elf.symbols['win'] from one run is valid for every run. checksec --file=vuln confirms PIE status (also shows NX, canary, RELRO).

    Why PIE matters here. If PIE were enabled, win()'s address would be randomized per process by ASLR; the address you read from objdump on your laptop would not match the address loaded on the challenge server. The exploit would fail silently - you'd crash, with no easy way to know it was an address mismatch versus an offset miscount. See ASLR & PIE Bypass for CTF for the leak-then-jump pattern when PIE is on.

  3. Step 3
    Write and run the pwntools exploit
    Observation
    I noticed we now have both the exact offset to EIP (from the cyclic pattern) and the fixed address of win() (from objdump on a non-PIE binary), which suggested building a pwntools script that pads to the offset and overwrites EIP with p32(win_addr) to redirect execution on the remote service.
    Pad 44 bytes, then append the little-endian packed address of win(). Send via pwntools to the remote service.
    python
    python3 -c "
    from pwn import *
    elf = ELF('./vuln')
    win_addr = elf.symbols['win']
    payload = b'A' * 44 + p32(win_addr)
    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: Test the payload locally with ./vuln and see it print the flag, then send the same payload to the remote and get no output.

    The local binary reads the flag from a file named flag.txt in the current directory. When win() runs on the remote server, it also reads a local flag.txt that only exists on that server. If your working directory has no flag.txt the local run silently prints nothing or errors, but the binary still returns normally. The exploit mechanics are correct; the discrepancy is just a missing local file, not an offset or address bug.

    Tried: Use p64() instead of p32() to pack the win address because the system is 64-bit.

    The vuln binary is a 32-bit ELF (confirmed by file vuln showing ELF 32-bit). Even on a 64-bit host, the process runs in 32-bit mode: registers are 32 bits wide, the saved return address occupies exactly 4 bytes, and the stack slot is 4 bytes. p64() packs 8 bytes, which overwrites 4 extra bytes beyond EIP - trashing memory past the return address and causing a crash before win() is ever reached.

    Learn more

    pwntools is the standard Python library for binary exploitation CTFs. ELF() parses the binary and lets you look up symbol addresses with elf.symbols['name']. p32(addr) packs the address as a 4-byte little-endian integer, which is how x86 stores multi-byte values in memory.

    Little-endian means the least-significant byte is stored at the lowest address. So the address 0x0804930f becomes the bytes \x0f\x93\x04\x08 in the payload. This matches how x86 reads values off the stack when it loads the return address into EIP.

    remote() opens a TCP connection. sendlineafter(prompt, data) blocks until prompt appears on the wire, then sends data + newline. recvall() reads until the peer closes - which is when win() finishes and the process exits.

    Watch out for prompt mismatches. If the binary's prompt string differs from b'Please enter your string:' by even one character, sendlineafter hangs forever. Verify the prompt locally first: run the binary by hand, copy the exact byte sequence (including punctuation and trailing space). When in doubt use strings vuln | grep -i enter or strace -e trace=write ./vuln to see what gets written to stdout. More tactics in pwntools for CTF.

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{addr3ss3s_ar3_3asy_c1...}

Overwrite the 32-bit saved return address (EIP) at offset 44 with the address of win(). pwntools automates packing and delivery.

Key takeaway

The ret2win technique works because the x86 stack stores the return address in a predictable location relative to a local buffer, and overwriting it redirects execution to any address in the binary. Without ASLR and PIE, function addresses are fixed at link time and readable directly from the symbol table, making the target trivially locatable. The same primitive powers real-world exploits against binaries compiled without modern mitigations, and understanding it is the foundation for advancing to ROP chains, GOT overwrites, and heap exploitation.

Related reading

Want more picoCTF 2022 writeups?

Tools used in this challenge

What to try next