ROP的第一次实战
1. ROP 介绍
介绍ROP
之前,也先介绍下DEP
ROP
,Retrun-oriented Programmming(面向返回的编程),是一种可绕过DEP
的方法。技术原理就是通过构造特定的溢出内容,使程序通过RET
指令跳转到构造的内容去执行相应的指令,而构造的内容则是程序自身模块中的代码段的地址,这样程序去执行时则不会触发DEP。一般构造的ROP链是构造VirtualProtect
函数去把栈空间修改为可读可写可执行,然后再跳转到栈空间去执行ShellCode
。
ROP的原理大概懂了,但一直没有机会进行实践,某一天,@老刘发来一个程序,说程序存在溢出漏洞,叫找到溢出点并利用,于是开整!
实验环境:Windows7 32位
2. 寻找程序溢出点,尝试利用
2.1 程序分析
首先分析程序,看看程序做了什么。从下面可以看到,该程序就是简单的读取文件内容并输出,很简单的一个小程序。在这种程序中,存在很明显的栈溢出。首先读取文件时先获取了文件大小,然后根据大小读取了文件内容并存放在栈空间,但栈空间空间不够大,只要构造足够大的文件,这个程序必然会崩溃,发生溢出。
2.2 构造文件,使其溢出
构造的文件如下:
运行程序,可以看到程序的确产生了溢出
2.3 寻找 JMP ESP,尝试利用
这里只是先尝试是否可利用,所以我这里在kernel32.dll
中随便找了个JMP ESP
的指令地址0x767CF7F7
,然后构造文件并尝试利用可以看到执行栈中的数据时,程序报异常了,说明此程序开启了DEP
,需要通过ROP
绕过才行。
3.寻找未开启 ASLR 的模块
在构造ROP
链之前,需要从程序的加载模块中找到未开启ASLR(随机基址)
的模块,这样才能保证exp
的通用性。
使用Mona查找未开启随机基址的模块
1 2 3 4
> # windbg命令 > .load pykd.pyd # 加载python > !py mona # 执行mona,这里是为了下载符号 > !py mona noaslr # 查找未开启aslr的模块
结果如下:
可以看到未开启随机基址的模块只有一个,那就是程序本身,那么接下来的
ROP
链只能在程序本模块找了这里可以在Mona里尝试自动查找
ROP
链:1 2
> # windbg命令 > !py mona rop
结果如下:
可以看到通过Mona查找是找不到完整的
ROP
链的,还是得自己手动查找
4. 构造 ROP 链的思路
我这里的思路还是构造VirtualProtect
,修改栈空间的内存属性,然后跳转到栈空间执行ShellCode
。
4.1 寻找 VirtualProtect 的地址
首先需要找到能获取VirtualProtect
地址的方法,因为VirtualProtect
是kernel32.dll
中的地址,而kernel32.dll
又是开启随机基址的,所以VirtualProtect
的地址肯定不能写成硬编码。
思路1:通过本程序的导入表获取
VirtualProtect
的地址,因为本程序的没有随机基址的,只要导入表中有VirtualProtect
的地址,那么就将此IAT的地址写成硬编码就能获取VirtualProtect
的地址,那么先找到程序的导入表看看:天公不作美,可以看到导入表中并没有导入VirtualProtect
,需要另想办法。思路2:通过程序代码段中的
mov xxx,fs:[0x30]
这种指令,获取kernel32.dll
的首地址,然后通过偏移获取VirtualProtect
的地址:
这个就更没有了,整个程序中也就只有上图中两种对FS
寄存器操作的指令,所以这条路也不行了。思路3:经过 @梦轩老哥的提醒,找到这样一种方法:
- 首先获取导入表中
kernel32.dll
的其他API地址
,求出VirtualProtect
地址距离这个API地址
的偏移 - 然后在程序中找到
相加
或相减
的指令地址,将其构造成ROP
链,这样就能获取到VirtualProtect
的地址了 - 例如下图这样:
这个思路的确能很准确的获取到
VirtualProtect
的地址,但仅限于当前测试的系统版本上。因为其他版本的操作系统kernel32.dll
中的函数地址偏移会发生改变,那么这种方式也就失效了。不过至少在这个版本的操作系统上,这种方式是通杀的,所以这里就采用这种方式获取VirtualProtect
的地址。- 首先获取导入表中
4.2 VirtualProtect 的参数
执行VirtualProtect
,需要传递参数。VirtualProtect
的原型是这样的:
|
|
在这里参数2
和参数3
可以确定,而参数1
和参数4
则需要填写栈的地址,如果程序开启了随机基址,则不能写成硬编码,需要动态获取。当然这里程序没有随机基址,那么这两个参数可以随意找一个栈地址,写成硬编码就行了。
4.3 JMP 指令
执行VirtualProtect
之后,需要跳转到存放ShellCode
的栈地址去执行,所以执行VirtualProtect
代码的位置后面必须有JMP xxx
或RET
等这样的指令,这样才能跳转过去。而这个栈地址因为模块未开启随机基址的原因,所以可以测试之后将这个地址写成硬编码。
5. 尝试构造 ROP 链
这里采用上面的思路3构造ROP
链,大概分为以下几步:
- 获取
VirtualProtect
地址 - 构造
VirtualProtect
参数 - 调用
VirtualProtect
- 跳转到
ShellCode
地址并执行
5.1 相关问题分析
首先需要构造一个存放API地址
的导入表地址,该API
必须与VirtualProtect
同处于一个模块,也就是kernel32
模块。
我这里使用导入表中第一个API
地址,从前面的图中可以看到,当前程序的导入表第一个API
为kernel32.IsDebuggerPresent
函数,那么需要构造的是存放这个函数的导入表地址,也就是0x402000
,而不是函数的实际地址0x7675B02B
。
kernel32
模块肯定是开启了随机基址的,每台机器的同一个函数的实际地址都不一致,如何获取到函数的实际地址,那就只能通过导入表来获取,这也是导入表存在的意义。要是能直接构造,那直接构造VirtualProtect
的地址就完事了,何必还要费这么大功夫。构造之前,需要考虑两个问题:
使用哪个寄存器?
这里使用哪个寄存器都行,只要能精准控制寄存器就可以,但是必须要保证能通过这个寄存器取到所在内存的值才行。因为如果这里使用
EAX
,那么后面取API地址
的时候必然会用到MOV xxx,[EAX]
这样的指令,但可能程序中不存在这样的指令,所以就需要多次测试,找到可用的寄存器。如何得到
VirtualProtect
的地址?这里使用偏移来得到
VirtualProtect
的地址,那么就得使用相加或相减的语句。当然如果刚好程序中有相关的语句最好,但是大部分都没有这么好的事。例如kernel32.IsDebuggerPresent
函数距离VirtualProtect
函数为0x50,但就是没有ADD EAX,0x50
这样的语句存在,那么就需要改变思路,用多条语句相加减实现。上面两个问题的举例语句:
1 2 3 4 5 6 7 8 9 10 11
; 寄存器取值 MOV ECX, [EAX + 8] MOV ECX, [EBP - 8] ; 多条语句相加减 ADD EAX,EDX ADD EAX, 0x10 SUB EAX, EDX SUB EAX, 0x10 INC EAX DEC EAX
5.2 获取 VirtualProtect 地址
- 给
EAX
赋值,需要与后面匹配
ROP值 | 说明 |
---|---|
7F154000 | 溢出后跳转的第一个地址,给EAX赋值 |
F81F4000 | 给EAX赋的值 |
00000000 | 填充物 |
|
|
- 给
EDI
和ESI
赋值,后面会用到
ROP值 | 说明 |
---|---|
84164000 | 第二个RET地址,赋值偏移给ESI |
00000000 | 给EDI赋值为0,后面会比较EDI |
80A0FFFF | 给ESI赋值,将ESI设置为一个负数偏移,后面通过加法获取到VirtualProtect的地址(这个偏移是计算得来的) |
|
|
- 通过
EAX
获取导入表中函数的地址,因为没有MOV xxx,[EAX]
这样的指令,这里使用的是mov ecx, dword ptr [eax + 8]
ROP值 | 说明 |
---|---|
9B134000 | 第三个RET地址,获取 VirtualProtect 地址 |
00000000 | 填充物 |
00000000 | 填充物 |
00000000 | 填充物 |
00000000 | 填充物 |
|
|
这里的ESI
为偏移,而这个偏移需要根据选择的函数和系统的不同而改变的
5.3 转换 ECX 到 EAX
通过上面的方法,ECX
已经为VirtualProtect
的地址了,但这里没有CALL ECX
这样的指令,所以还需要将ECX
转换到其他寄存器上去。因为这里有CALL EAX
,所以我这里选择的是MOV EAX, ECX
ROP值 | 说明 |
---|---|
59144000 | 第四个RET地址,将 VirtualProtect 地址赋值给EAX |
|
|
5.3 处理后续
现在EAX
已经是VirtualProtect
的地址了,已经可以CALL EAX
了。但这里寻找的CALL EAX
指令执行后还执行了其他的指令,会影响RET
的位置,所以需要将后续处理好
ROP值 | 说明 |
---|---|
84164000 | 第五个RET地址,处理后续 |
00000000 | 给EDI赋值为0 |
00000000 | 给ESI赋值为0 |
|
|
5.5 执行 VirtualProtect
开始执行VirtualProtect
,需要传递参数,所以构造的内容还包括参数
ROP值 | 说明 |
---|---|
7B164000 | 第六个RET地址,执行 VirtualProtect |
FCFF1200 | VirtualProtect参数1:当前栈地址 |
01000000 | VirtualProtect参数2:大小 |
40000000 | VirtualProtect参数3:修改的属性 |
FCFF1200 | VirtualProtect参数4:随意的栈地址 |
00000000 | 填充物 |
00000000 | 填充物 |
|
|
这里需要说明一点的是,VirtualProtect
的参数问题,参数1
和参数4
需要根据自己系统上的栈空间来构造
5.6 跳转到栈空间
执行了VirtualProtect
之后,就需要跳转到栈空间去执行ShellCode
了,构造了这个栈地址之后ROP
链也就到这就结束了,其余的就是ShellCode
了。而这个栈空间地址也需要根据自己构造的ROP
链和系统来决定
ROP值 | 说明 |
---|---|
A8FF1200 | 最后一个RET地址,执行ShellCode的地址 |
6. 最终的 Poc
|
|
7. 执行效果
这个Poc
在当前系统版本上是通杀的,无论重启还是怎么样,都能实现精准溢出并执行
8. END
这次实验,是把以前看过ROP
资料给实际运用起来了,而且是手动寻找的ROP
链,算是理论+实践结合了,收获很大。
这次实践中,还是遇到了老问题,思路不够开阔。获取VirtualProtect
地址时,当没有导入表
和FS寄存器
的时候,我的确找不到其他办法突破了,要是没有 @梦轩老哥 的帮助,可能也构造不出版本通杀的Poc
,非常感谢 @梦轩老哥 的帮助。