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.
Setup
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.
sudo apt-get install wine32 gcc-mingw-w64-i686# Add a printf("win: %p\n", win); line near the top of main, then recompile:i686-w64-mingw32-gcc -m32 vuln.c -o vuln_local.exe -no-pieWINEARCH=win32 WINEPREFIX=~/.wine32 wine vuln_local.exeSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the vulnerability
ObservationThe 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.
Step 2Find the offset that controls EIP
ObservationCompiler 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.pythonpython3 -c "from pwn import *; sys.stdout.buffer.write(cyclic(150))" > pattern.binbashWINEARCH=win32 WINEPREFIX=~/.wine32 wine vuln.exe < pattern.binbash# Wine prints something like: Unhandled exception: page fault on read access to 0x6261616bpythonpython3 -c "from pwn import *; print(cyclic_find(0x6261616b))"bash# Output: 140Expected 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.
Step 3Get the win() function address
ObservationThe 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.bashobjdump -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.Step 4Craft the exploit and send it to the remote server
ObservationThe 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>bashbash# Or with pwntools:pythonpython3 -c "pythonfrom pwn import *bashp = remote('saturn.picoctf.net', <PORT>)bashwin_addr = 0x401530bashpayload = b'A' * 140 + p32(win_addr)bashp.sendline(payload)pythonprint(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\x00in 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.