ROP攻击的前提条件
要完成一个成功的ROP攻击,需要有很多前提条件,这里列举几个最重要的:
- 首先必须要有一个buffer overflow的漏洞(当然这个前提基本上所有攻击都必须得有);
- 攻击者需要事先决定好完成攻击过程的所有gadgets。对于上面提到的赋值操作,总共只需要3个gadgets,每个gadget最长两条指令,但是如果需要进行一些复杂的操作,就有可能需要有很多gadgets;除了gadgets的数量多之外,单个gadget的指令数也需要考虑;
- 攻击者需要在被攻击程序所在的进程地址空间中找到所有的这些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)
在这四步过程中,第三步是关键,即如何使得用户态的signal handler执行完成之后能够顺利返回内核态。在类UNIX的各种不同的系统中,这个过程有些许的区别,但是大致过程是一样的。这里以Linux为例:
在第二步的时候,内核会帮用户进程将其上下文保存在该进程的栈上,然后在栈顶填上一个地址rt_sigreturn
,这个地址指向一段代码,在这段代码中会调用sigreturn
系统调用。因此,当signal handler执行完之后,栈指针(stack pointer)就指向rt_sigreturn
,所以,signal handler函数的最后一条ret
指令会使得执行流跳转到这段sigreturn代码,被动地进行sigreturn
系统调用。下图显示了栈上保存的用户进程上下文、signal相关信息,以及rt_sigreturn
:
我们将这段内存称为一个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
,如下图所示:
在这个伪造的Signal Frame
中,将rax
设置成59(即execve
系统调用号),将rdi
设置成字符串/bin/sh
的地址(该字符串可以是攻击者写在栈上的),将rip
设置成系统调用指令syscall
的内存地址,最后,将rt_sigreturn
手动设置成sigreturn
系统调用的内存地址。那么,当这个伪造的sigreturn
系统调用返回之后,相应的寄存器就被设置成了攻击者可以控制的值,在这个例子中,一旦sigreturn
返回,就会去执行execve
系统调用,打开一个shell。
这是一个最简单的攻击。在这个攻击中,有4个前提条件:
- 攻击者可以通过stack overflow等漏洞控制栈上的内容;
- 需要知道栈的地址(比如需要知道自己构造的字符串
/bin/sh
的地址); - 需要知道
syscall
指令在内存中的地址; - 需要知道
sigreturn
系统调用的内存地址。
利用SROP构造系统调用串(System call chains)
通过再额外添加一个对栈指针rsp
的控制,可以实现连续的系统调用:
另外需要把原来单纯的syscall
gadget换成syscall; ret
gadget。在这个过程中,每次syscall
返回之后,栈指针都会指向下一个Signal Frame
,因此,在这个时候执行ret
指令,就会再次调用sigreturn
系统调用。这样就可以通过操作栈来达到连续进行系统调用的效果。
两个重要的gadgets
sigreturn
sigreturn
这个系统调用和别的系统调用有一个不同的地方,即一般的应用程序不会主动调用它,而是像之前介绍的,由内核将相应地址填到栈上,使得应用进程被动地调用。因此在系统中一般会有一段代码专门用来调用sigreturn
,在不同的类UNIX系统中,这段代码会出现在不同的位置
其中在Linux < 3.11 ARM
(也就是大部分现在Android所使用的内核),以及FreeBSB 9.2 x86_64
,都可以在固定的内存地址中找到这个gadget,而在其它系统中,一般被保存在libc
库的内存中,如果有ASLR保护的话似乎没有那么容易找到。
如果将sigreturn
当做一个系统调用来看待的话,那么其实这个单独的gadget并不是必须的。因为我们可以将rax
寄存器设置成15(sigreturn的系统调用号),然后调用一个syscall
,效果和调用一个sigreturn
是一样的。
syscall;ret
如果是Linux < 3.3 x86_64
(在Debian 7.0, Ubuntu Long Term Support, CentOS 6系统中默认内核),则可以直接在固定地址[vsyscall]
中找到这段代码片段。其中vsyscall
是用来加速time()
,gettimeofday()
和getcpu()
这三个系统调用的机制,虽然现在已经被vsyscall-emulate
和vdso
机制所代替,但在稍微比较早一点的内核中依然是被默认支持的。
Linux下的SROP
可以直接调用pwntools的SigreturnFrame来快速生成SROP帧,需要注意的是,pwntools中的SigreturnFrame中并不需要填写rt_sigreturn的地址,我们只需要确保执行rt_sigreturn的时候栈顶是SigreturnFrame就行,因此我们可以通过syscall指令调用rt_sigreturn而不必特意去寻找这个调用的完整实现。此外,由于32位分为原生的i386(32位系统)和i386 on amd64(64位系统添加32位应用程序支持)两种情况,这两种情况的段寄存器设置有所不同
1 | # 原生i386 |
SROP实例1
例,~/pwnable.kr-unexploitable/unexploitable
栈溢出,got表中没有system,也没有write,puts之类的输出函数,没办法泄露libc,使用ROPgadget也搜不到syscall。不过通过ropper可以搜索到一个syscall(ROPgadget无法把单独的syscall识别成一个gadget)
不过找不到给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
0x60116c实际上为一个可写的地址,用作之后的读写操作。
1 | syscall_addr = 0x400560 |
因为第二次的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 | context.arch = 'amd64' |
SROP实例2
例,~/360ichunqiu 2017-smallest/smallest
程序很简单,只有几句简单的汇编指令
通过汇编指令可以看到,实际上就只是一个read函数的功能调用,而且buf为rsp即栈指针所指向的栈顶,所以这个程序只要输入,就会造成溢出。
由于开启了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 | context.update(os="linux",arch="amd64") |
程序执行后再一次返回到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 | frame_read = SigreturnFrame() # 设置read的SROP帧 |
1 | # 通过向泄露的栈地址写入'/bin/sh\x00',然后执行sys_execv('/bin/sh\x00',0,0)getshell |
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.