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 1
Understand the vulnerabilityObservationI 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.
Step 2
Find the offset that controls EIPObservationI 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.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 0x61616174pythonpython3 -c "from pwn import *; print(cyclic_find(0x61616174))"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 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.
Step 3
Get the win() function addressObservationI 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.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 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.Step 4
Craft the exploit and send it to the remote serverObservationI 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>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 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\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_...}