Sigreturn oriented programming is a binary exploitation technique that turns one tiny piece of borrowed code into total control of the CPU. On Linux, when a signal is delivered, the kernel writes a snapshot of every register onto the user stack and trusts that snapshot completely when the handler returns. An attacker who can write to that stack forges the snapshot, triggers the return path, and the kernel obediently loads attacker chosen values into rax, rdi, rsp, rip, and the rest, all in a single step. Where a normal exploit hunts for a dozen scattered gadgets to set up one system call, this one needs almost nothing. This post walks the mechanism one step at a time: how a signal frame gets onto the stack, why the kernel never checks whether it is genuine, how a forged frame becomes a syscall chain that spawns a shell, and what actually stops it.
What sigreturn oriented programming actually exploits
The whole technique rests on one feature of how Unix systems deliver signals, and on one assumption the kernel makes about that feature. When a process receives a signal, the kernel does not just jump to the handler and forget where it was. It first saves the entire interrupted execution state so that, after the handler runs, the process can pick up exactly where it left off. That saved state is the signal frame, and it lives on the user stack, in memory the process can read and write like any other.
The frame is not a vague summary of the process. It is a full register dump. On x86-64 Linux the saved context, a structure the kernel calls a ucontext wrapping a sigcontext, holds the values of the general purpose registers, the stack pointer, the instruction pointer, and the flags. Every register that defines what the CPU will do next is sitting there in plain memory, written by the kernel, waiting to be put back. The technique was first described in full by Erik Bosman and Herbert Bos of Vrije Universiteit Amsterdam in their 2014 paper, which named the saved frame as the entire attack surface.
How signal delivery sets up the frame
Walk the normal, benign path first so the abuse is obvious later. A process is running. A signal arrives, say SIGALRM or SIGSEGV. The kernel pauses the process, builds the signal frame on the user stack, and arranges for the registered handler to run. The handler does its work. When it returns, it does not return like an ordinary function. Instead, control flows to a small trampoline that invokes a special system call named rt_sigreturn.
That syscall has exactly one job: take the signal frame currently on top of the stack and restore the process from it. The kernel reads the saved ucontext, copies every saved register back into the live CPU registers, restores the signal mask, and resumes execution at the saved instruction pointer. As the manual for sigreturn(2) puts it, the call restores the process context, meaning the processor flags and registers, including the stack pointer and the instruction pointer. After it runs, the process is bit for bit back where it was before the signal, and none the wiser.
Here is the load bearing detail. The kernel does not keep its own private, trusted copy of that frame. It put the frame on the user stack, and when rt_sigreturn runs, it reads the frame back from the user stack. It does not check a cookie. It does not verify that a signal was ever actually delivered. It does not confirm that the bytes it is about to load are the same bytes it wrote. It reads whatever is at the top of the stack, interprets those bytes as a saved register set, and loads them into the CPU. The kernel assumes that a frame on the stack is one the kernel itself placed there. That assumption is the whole game.
The forged frame
Now suppose an attacker has a stack write, the classic precondition for any return oriented attack: a buffer overflow, a format string write, or any primitive that lets them lay out bytes on the stack and steer the return address. Instead of building a long chain of return addresses the way classic return oriented programming does, they write something simpler. They write a fake signal frame.
The layout is fixed and public, so forging it is mechanical rather than clever. The attacker fills in the saved register slots with the exact values they want the CPU to hold: a chosen rip to control where execution goes, a chosen rsp to control the stack, a chosen rax to select a system call, and chosen argument registers rdi, rsi, and rdx to fill in that call’s parameters. They do not have to find a gadget that loads each register one at a time. They just write the value they want into the slot that the kernel will copy into that register. Tooling makes this trivial in practice. The pwntools exploitation library ships a SigreturnFrame class that builds the byte layout for you, so a practitioner writes frame.rdi = ... and frame.rip = ... rather than memorizing offsets.
With the fake frame in place, the attacker needs only to make the program execute rt_sigreturn. On x86-64 that means getting the syscall number 15, which is 0xf, into rax and then reaching a syscall instruction. The kernel sees the syscall, treats the top of the stack as a genuine signal frame, and loads every forged register at once. One step, and the entire CPU state belongs to the attacker.
The kernel does not ask whether the signal frame is real. It reads the stack, trusts the bytes, and loads them into every register the CPU has.
Why one gadget is enough
To appreciate why this technique matters, contrast it with the attack it descends from. Classic return oriented programming chains together short instruction sequences that already exist in the target binary, each ending in a ret, to assemble behavior the attacker wants without injecting any new code. To set up a single system call that way, you typically need a gadget to load rdi, another for rsi, another for rdx, another for rax, and then a syscall. If the binary is small or stripped down, some of those gadgets may simply not exist, and the whole approach stalls. Gadget availability is the limiting factor.
Sigreturn oriented programming collapses all of that into one move. It does not load registers one at a time from scattered gadgets. It loads the entire register set in a single rt_sigreturn, sourced from a frame the attacker fully controls. That has three consequences that make it unusually strong.
It is close to universal
The rt_sigreturn path is part of the kernel’s signal machinery, not a quirk of any one program. The two ingredients the attacker needs, a way to set rax to 15 and a syscall instruction, are minimal and turn up almost everywhere. Bosman and Bos titled their paper around portability for this reason: an exploit built on signal frames travels across different binaries with little or no change, because it leans on a syscall convention rather than on whatever odd gadgets a particular binary happens to contain. Where classic chains are bespoke to each target, a sigreturn payload is close to write once.
It barely cares about gadget scarcity
Because the register values come from the forged frame rather than from gadgets, a binary that is too lean for a normal return oriented chain can still fall to this one. You are no longer searching the binary’s instruction stream for a way to control rsi. You wrote rsi directly into the frame. The attack sidesteps the exact scarcity that defeats classic chains, which is why it is so often the answer when a target offers almost nothing to work with.
It hands you full register control
Setting registers precisely is the hard part of many exploits, and here it is free. One rt_sigreturn sets all of them to known values in one shot, which makes the next step, invoking a system call with carefully chosen arguments, completely deterministic.
Chaining syscalls into a shell
The payoff of full register control is the ability to make any system call you like with any arguments you like. The canonical goal is a shell, which on x86-64 means calling execve("/bin/sh", NULL, NULL). The syscall number for execve is 59, which is 0x3b.
The attacker forges a signal frame whose saved registers describe that call exactly. They set rax to 59 to select execve. They set rdi to the address of the string /bin/sh in memory. They set rsi and rdx to zero for the empty argument and environment pointers. Crucially, they set the saved rip to the address of a syscall instruction. When rt_sigreturn restores this frame, every one of those registers snaps into place and execution jumps straight to the syscall, which now runs execve with the attacker’s arguments. A shell pops.
Often a single sigreturn is not the end but a stage. A common pattern when there is nowhere known to put the /bin/sh string, or no executable place to land, is to chain frames. The first forged frame calls a syscall like read or mmap to write attacker data into a known, writable, executable location, and it sets the saved rsp so that when that syscall returns, the stack is positioned at the next forged frame, which performs the next step. Each rt_sigreturn both performs a syscall and repositions the stack for the one after it, so a series of frames becomes a syscall chain that does setup work and then spawns the shell.
This chaining is why the technique is so flexible in practice. The saved rsp in each frame is the thread that ties the stages together: it lets the attacker walk the stack pointer forward through a prepared sequence of frames without needing any gadget that adjusts the stack. A frame can call mprotect to make a writable region executable, then the next frame can jump into freshly written shellcode, then a final frame can clean up. The attacker is, in effect, scripting the kernel’s own restore path into a small virtual machine where each instruction is one forged frame and one syscall. That is a long way from the brittle, binary specific gadget hunting that classic chains demand.
Where the syscall and the string come from
The technique still needs two concrete addresses: somewhere to find a syscall instruction to put in the saved rip, and somewhere to find or place the /bin/sh string. This is where a known fixed location matters. Historically the vsyscall page on x86-64 Linux sat at the constant address 0xffffffffff600000 and was executable, which gave attackers a syscall gadget at a hardcoded spot regardless of address randomization. An mmap region created with a fixed address, or any leaked address that reveals where executable bytes and writable memory live, serves the same purpose. The sigreturn frame supplies the registers, but the attacker still has to point rip at real executable code, so a stable or leaked location is the other half of the recipe. This dependence on a known address is the same kind of memory layout problem you see in a kernel use after free, where control of where a stale object lives is what turns a dangling reference into a write primitive.
How it relates to and differs from classic ROP
It helps to be precise about the family relationship. Both classic return oriented programming and the sigreturn variant are code reuse attacks. Neither injects new executable code into the process, which is the point: they defeat the no execute protections that made plain shellcode on the stack stop working. Both rely on the attacker controlling the stack and the return address. So far they are siblings.
The difference is where the register values come from. Classic chains source each register value from a separate gadget already present in the binary, then string those gadgets together with return addresses, so the chain’s power is bounded by what gadgets the binary contains. The sigreturn variant sources every register value from a single forged data structure that the kernel itself will faithfully load, so its power is bounded only by the attacker’s ability to write a frame and trigger one syscall. One is a sequence of borrowed instructions. The other is a single borrowed kernel mechanism that hands over the whole CPU at once. In CTF and real exploitation practice the two are routinely combined: a short classic chain sets rax to 15 and reaches a syscall, and that single act detonates the forged frame.
Mitigations
Because the flaw is an assumption rather than a memory bug, the defenses are a layered set rather than a single patch. None of them is a silver bullet, and one common belief about defense is simply wrong.
Signal cookies, the direct fix
The most targeted defense is the one Bosman and Bos proposed in the original paper: a signal cookie, sometimes called a sigreturn cookie. The idea is to make the kernel able to tell its own frames from forged ones. When the kernel writes a real signal frame, it also stores a random value derived from a secret combined with the address where the frame sits, in effect a canary bound to that stack location. On rt_sigreturn the kernel recomputes and checks that value before trusting the frame. An attacker who forges a frame cannot produce the right cookie without knowing the secret, so the forged frame is rejected. This directly attacks the trusted bytes problem at its root, and variants of this mitigation have appeared in some kernels. The elegance of the approach is that it changes the trust model rather than the layout: the kernel stops assuming that a frame on the stack is its own and starts proving it, which is exactly the assumption the attack abused.
Vsyscall emulation and reduced fixed locations
The old executable vsyscall page at its constant address was a gift to attackers, so modern kernels emulate it rather than letting code execute there directly. Since Linux 3.3 an attempt to run instructions in that page traps instead of executing, which removes one reliable, ASLR proof source of a syscall gadget. This does not stop the technique, but it removes a convenient fixed foothold and forces the attacker to find an executable address some other way.
Control flow integrity and hardware shadow stacks
Control flow integrity aims to ensure that indirect control transfers only land at intended targets, which constrains the return oriented building blocks the attacker stitches together. At the hardware level, Intel’s Control flow Enforcement Technology adds a shadow stack: a protected copy of return addresses that the CPU checks, so a corrupted return address on the normal stack no longer redirects execution unnoticed. These raise the cost of the surrounding chain that gets you to the syscall in the first place, though they target control flow hijacking broadly rather than the signal frame trust specifically.
Full RELRO and hardening the rest of the path
Defenses that close off the primitives an attacker uses to reach rt_sigreturn matter too. Full RELRO maps the global offset table read only after startup, removing a popular write target that exploits use to hijack control flow, which makes the initial stack write or redirect harder to obtain. Hardening the overflow or write primitive that the attack depends on shrinks the opening before signal frames ever enter the picture.
The ASLR misconception
It is tempting to assume address space layout randomization stops this. It does not, not on its own. Randomization hides where code and the stack live, which raises the bar, but the sigreturn technique only needs the attacker to write a frame to a stack they already control and to point rip at one executable address. Once any information leak gives up a single address, the layout is known and randomization is spent. Treat ASLR as one delaying layer that a leak cancels, not as a defense against forged signal frames. This is the same trap as treating a leak resistant looking design as safe: the moment one address escapes, the assumption underneath collapses.
The assumption that breaks
Strip away the frame layouts and the syscall numbers and one assumption is left holding everything up. The kernel assumes that a signal frame sitting on the user stack is one the kernel itself put there. The rt_sigreturn path was designed as the kernel’s private return road, a way to undo a context switch it had performed moments earlier, and it was built on the premise that only the kernel ever lays a frame down. So it reads the bytes and loads them into every register without a second look. The attacker never breaks that mechanism. They simply place a frame of their own on the stack and let the kernel do exactly what it was always going to do.
The bug is not a corrupt syscall or a broken handler. The bug is a trust boundary drawn in the wrong place: the kernel trusted the contents of memory it had already handed to the process, and that memory is precisely what an attacker controls. That gap, between what a system assumes about who wrote some bytes and who actually can, is the kind of flaw you find by asking what each component trusts and why it still trusts it, rather than by scanning for a known bad pattern. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to surface. Verify the frames you restore, narrow the fixed locations an attacker can lean on, and remember that a leak turns randomization back into a known address. Learn more about that approach on our about page.
Frequently asked questions
What makes sigreturn oriented programming so powerful?
When a signal is delivered, the Linux kernel saves a full register snapshot, the signal frame, onto the user stack, and the rt_sigreturn syscall restores every register from it without checking that the frame is genuine. An attacker who controls the stack forges that frame and sets rax, rdi, rsp, and rip all at once with a single gadget, instead of hunting for one gadget per register the way classic chains do. The technique was introduced by Erik Bosman and Herbert Bos in Framing Signals: A Return to Portable Shellcode.
How does SROP differ from classic ROP?
Both are code reuse attacks that need a stack write and never inject new code, so they survive no execute protections. The difference is where register values come from. Classic return oriented programming sources each value from a separate gadget already in the binary, so it is limited by gadget availability. The sigreturn variant sources every value from one forged signal frame the kernel faithfully loads, so it works even on lean binaries. See the overview of sigreturn oriented programming for the comparison.
How does a forged frame spawn a shell?
The attacker builds a signal frame whose saved registers describe execve("/bin/sh", NULL, NULL): rax set to 59, rdi pointing at the /bin/sh string, rsi and rdx set to zero, and the saved rip aimed at a syscall instruction. Triggering rt_sigreturn with syscall number 15 loads the whole frame and runs the call. The SigreturnFrame helper in pwntools builds this byte layout automatically.
Does ASLR stop sigreturn oriented programming?
Not on its own. Address randomization hides where code and the stack live, but the attack only needs to write a frame to a stack the attacker already controls and to point rip at one executable address. A single information leak reveals that address and the layout is known. The real fix is a signal cookie that binds a secret to the frame’s stack location, the mitigation described in the sigreturn(2) manual and the original research, so the kernel can reject forged frames.
