中阶ROP链
上一篇从:
ret2text
ret2shellcode
ret2syscall
开始,已经把最基础的 ROP 跑了一遍。
到这里,ROP 最核心的东西其实已经比较清楚:
控制栈
↓
控制 return address
↓
寻找 gadget
↓
控制程序执行流程
但是前面的几个例子都有一个共同点:
条件比较理想。
想要 pop eax ; ret,程序里面正好有;想找 /bin/sh,也正好能找到;程序文件就在手里,可以直接扔进 IDA、GDB 和 ROPgadget。
真正做题的时候不会每次都这么舒服。
尤其到了64位以后,函数参数开始大量通过寄存器传递。有时候偏偏就找不到想要的 gadget;再极端一点,甚至连程序文件都没有,只有一个远程服务。
这个时候,ROP 就开始从“找几个 gadget 接起来”,往更复杂的方向走了。
ret2csu
先看64位。
32位程序调用函数时,参数主要通过栈传递。
但是 Linux x86-64 下,前几个参数通常放在:
第1个参数 -> rdi
第2个参数 -> rsi
第3个参数 -> rdx
第4个参数 -> rcx
第5个参数 -> r8
第6个参数 -> r9
例如想调用:
write(1, buf, 8);
最直接当然是找:
pop rdi
ret
pop rsi
ret
pop rdx
ret
然后分别控制三个寄存器。
问题是 gadget 不一定听话。
pop rdi ; ret 一般还比较常见,但 pop rdx ; ret 就不一定刚好存在。
这时候可以看看:
__libc_csu_init
附近的代码。
很多使用 GCC 编译出来的64位 ELF 中,这里经常能找到一组很好用的指令。
例如一段:
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
一次可以控制:
rbx
rbp
r12
r13
r14
r15
附近还可能有另一段:
mov rdx, r15
mov rsi, r14
mov edi, r13d
call qword ptr [r12 + rbx*8]
这样事情就变得有意思了。
虽然没有:
pop rdx
ret
但是我可以先控制:
r13
r14
r15
然后再利用:
mov rdx, r15
mov rsi, r14
mov edi, r13d
把它们搬到真正的参数寄存器里面。
也就是:
r13 -> rdi
r14 -> rsi
r15 -> rdx
所以 ret2csu 的重点不在于:
找到一个神奇 gadget。
而是:
利用程序初始化代码里面本来就存在的一整段寄存器控制和间接调用逻辑。
用ret2csu调用read
假设程序存在栈溢出,现在想调用:
read(0, bss_addr, 8);
把:
/bin/sh\x00
写进 .bss。
目标参数:
rdi = 0
rsi = bss_addr
rdx = 8
那么前面可以先放:
r13 = 0
r14 = bss_addr
r15 = 8
经过:
mov rdx, r15
mov rsi, r14
mov edi, r13d
以后就得到:
rdi = 0
rsi = bss_addr
rdx = 8
然后还需要让:
r12
指向 read 对应的函数指针位置。
因为后面会执行:
call qword ptr [r12 + rbx*8]
这里还要注意 rbx 和 rbp。
这段代码一般来自一个循环,后面可能有:
add rbx, 1
cmp rbp, rbx
jne ...
所以如果想让它只执行一次,可以考虑:
rbx = 0
rbp = 1
执行:
add rbx, 1
以后:
rbx = 1
rbp = 1
循环结束。
所以一组很常见的参数关系就是:
rbx = 0
rbp = 1
r12 = function pointer
r13 = arg1
r14 = arg2
r15 = arg3
如果已经找到两段 gadget,可以自己包装一个函数:
def csu_call(func, arg1, arg2, arg3, next_addr):
payload = p64(csu_pop)
payload += p64(0) # rbx
payload += p64(1) # rbp
payload += p64(func) # r12
payload += p64(arg1) # r13
payload += p64(arg2) # r14
payload += p64(arg3) # r15
payload += p64(csu_call_gadget)
payload += p64(0) * 7
payload += p64(next_addr)
return payload
例如:
payload += csu_call(
read_got,
0,
bss_addr,
8,
main_addr
)
然后再发送:
sh.send(b'/bin/sh\x00')
当然这里的:
pop 顺序
call 位置
尾部要补多少个数据
都要看实际程序的反汇编。
不能把上面这一套直接当模板硬套。
真正应该记住的是:
先控制一批寄存器
↓
再把值搬到参数寄存器
↓
完成一次间接 call
↓
处理循环
↓
恢复栈
这个才是 ret2csu。
ret2csu和libc泄露
ret2csu 很多时候并不是单独使用。
更常见的思路是先拿它来泄露地址。
例如:
栈溢出
↓
ret2csu
↓
调用 write / puts
↓
泄露 GOT
↓
得到 libc 中函数真实地址
↓
计算 libc_base
↓
算 system 和 /bin/sh
↓
第二阶段 ROP
比如想泄露 puts。
可以设法调用:
puts(puts_got);
得到真正运行时的:
puts_addr
然后:
libc_base = puts_addr - libc.symbols['puts']
接着:
system_addr = libc_base + libc.symbols['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00'))
第二阶段就可以:
padding
+
pop rdi ; ret
+
binsh_addr
+
system_addr
最后:
system("/bin/sh");
这里和最开始 ret2text 已经有明显区别了。
以前是:
我知道地址
↓
直接跳过去
现在变成:
先泄露一个地址
↓
利用已知偏移反推出 libc_base
↓
再动态计算真正需要的地址
↓
构造第二阶段 payload
地址已经不再是写死的东西,而开始依赖程序实际运行时的状态。
ret2reg
ret2reg 的想法要简单很多。
有时候发生栈溢出以后,某个寄存器里面正好还保存着一个指向我们输入数据的地址。
例如:
rax
rdi
rsp
或者其他寄存器。
如果正好有:
jmp rax
或者:
call rax
那么就不一定还要知道输入缓冲区的绝对地址。
可以变成:
输入 shellcode
↓
某个寄存器仍然指向输入
↓
覆盖 return address
↓
jmp reg / call reg
↓
执行 shellcode
比如:
jmp rax
如果此时:
rax -> shellcode
那么:
ret
↓
jmp rax
↓
shellcode
就够了。
做这种题的时候,先在 GDB 里面看函数返回前寄存器的状态,看看有没有哪个寄存器刚好还指向输入。
然后再找:
jmp reg
call reg
当然,如果最终还是要直接执行 shellcode,那么对应内存仍然要有执行权限。
NX 开着的话,这种最直接的玩法还是会被卡住。
ret2reg 给我的感觉是:
寄存器里面当前已经存在的值,本身也是可以利用的东西。
不是每次都一定要从零开始重新控制所有状态。
JOP和COP
ROP 里面一直在利用:
ret
因为:
ret
会不断从栈上取下一个地址。
但是仔细想一下,代码复用也不一定非要靠 ret。
如果有:
jmp reg
或者:
jmp [reg]
同样可以完成间接控制流转移。
这就是:
JOP
Jump-Oriented Programming
它的大概思路仍然是:
控制数据
↓
控制寄存器
↓
间接跳转
↓
进入下一个 gadget
只是负责把 gadget 串起来的东西从:
ret
变成:
jmp
类似的还有:
COP
Call-Oriented Programming
主要围绕:
call
来组织代码片段。
所以可以很粗地记成:
ROP -> ret
JOP -> jmp
COP -> call
当然真正实现肯定没有这么简单。
但是这个分类至少说明一件事:
代码复用并不等于只能一直
ret。
真正重要的是能不能持续控制间接跳转的目标。
BROP
下面这个就更怪了:
BROP
Blind Return-Oriented Programming
盲 ROP。
前面不管怎么玩,其实都有一个默认条件:
程序文件在手里。
所以可以:
IDA
GDB
objdump
ROPgadget
checksec
直接分析。
但是如果现在只有:
IP
+
port
和一个存在漏洞的远程程序呢?
没有 binary。
那就没法直接:
打开 IDA
↓
找函数
↓
找 gadget
BROP 就是在这种情况下尝试继续做 ROP。
没有程序怎么分析
虽然没有程序文件,但是远程服务还是会对输入产生反应。
例如:
正常返回
crash
连接断开
保持连接
产生输出
这些现象本身就可以当成信息。
所以可以不断发送不同输入,然后观察:
它死没死?
它有没有返回?
它有没有继续运行?
有没有输出?
然后慢慢猜程序内部到底发生了什么。
也就是说,以前是:
看二进制
↓
知道程序内部结构
现在变成:
不断试探
↓
观察外部行为
↓
反推程序内部结构
这就是“Blind”比较有意思的地方。
先找稳定行为
如果随便猜一个地址:
candidate
然后程序 crash 了,其实不能直接说明:
candidate 不对
因为也有可能:
candidate 本身能执行
只是执行完以后又跑到了一个垃圾地址,最后还是 crash。
所以需要先想办法找到一个比较稳定的位置。
BROP 里面会提到:
stop gadget
也就是某个执行以后能够让程序表现出稳定、可观察行为的位置。
这样以后测试其他地址时就可以:
candidate
↓
stop
如果 candidate 能正常执行到 stop,外部表现就和:
candidate 直接非法
↓
crash
不一样。
于是:
程序有没有活着
本身就变成一种判断。
再慢慢找gadget
接下来还是要找到一些能控制寄存器或者栈的东西。
64位程序启动代码里面经常会有:
一次 pop 很多个寄存器
的结构。
这和前面的 ret2csu 有点像。
区别只是:
ret2csu
↓
有 binary
↓
直接反汇编看
BROP
↓
没 binary
↓
只能不断试地址
↓
根据程序行为判断
例如不断改变栈上跟着的数据数量,然后看:
什么时候 crash
什么时候能够正常落到 stop
可以慢慢推测某段代码到底 pop 了多少东西。
这就已经不是传统意义上的“看汇编”了。
最关键的是获得输出
光能控制程序跳来跳去还是不够。
如果一直只能知道:
活着
或
死了
能够得到的信息还是非常有限。
真正重要的一步是找到输出能力。
比如找到一个类似:
write
puts
的调用方式。
只要能够让程序输出:
指定地址附近的内存内容
情况就完全不一样了。
因为这意味着第一次拥有了:
读取远程进程内存
的能力。
之后就可以慢慢尝试读:
ELF Header
代码
PLT
GOT
甚至把程序本身一点一点 dump 回来。
整个过程就会变成:
一开始没有 binary
↓
利用 crash / 正常返回判断地址
↓
恢复部分 gadget
↓
找到输出函数
↓
读取远程内存
↓
恢复更多程序内容
↓
拿回本地继续分析
这个思路挺离谱的。
最开始明明什么都没有,最后居然可以利用程序自己一点一点把内部结构吐出来。
BROP利用的到底是什么
所以 BROP 真正利用的其实不只是某一个漏洞。
它利用的是:
程序面对不同输入时表现出来的差异。
例如:
crash
正常返回
保持连接
输出数据
这些原本只是程序运行结果。
但是如果不断重复试探,它们就可以慢慢变成信息。
所以整个过程大概是:
只有远程服务
↓
找到溢出位置
↓
找到稳定行为
↓
恢复部分控制流
↓
找到输出能力
↓
泄露程序内存
↓
恢复程序结构
↓
继续做正常 ROP
这已经和最开始:
IDA 找地址
↓
ROPgadget 找 gadget
↓
写 payload
完全不是一种感觉了。
从初阶到中阶
如果说初阶 ROP 主要在解决:
已经知道有哪些 gadget,怎么把它们拼起来?
那这一阶段开始考虑的就是:
如果没有我想要的条件怎么办?
例如没有好用的:
pop rdx
ret
那就看看程序启动代码里面有没有一整段能利用:
ret2csu
如果不知道输入缓冲区的绝对地址,但是寄存器刚好还指着它:
ret2reg
如果不一定非要使用:
ret
那还可以继续看:
JOP
COP
最后如果连 binary 都没有:
BROP
所以这一阶段让我感觉,ROP 已经不只是:
搜 gadget
+
拼 gadget
而是在不断问:
这个程序现在还给我留下了什么可以利用的东西?
可能是:
编译器留下的初始化代码
寄存器里面残留的指针
间接 jmp / call
程序 crash 或正常返回的区别
程序本身的输出能力
这些全部都可以成为利用的一部分。
总结
这一阶段大概可以整理成:
ret2csu
│
├── 利用初始化代码
├── 控制多个寄存器
└── 完成多参数函数调用
ret2reg
│
├── 利用当前寄存器状态
└── 跳到寄存器指向的数据
JOP / COP
│
├── jmp
└── call
BROP
│
├── 没有 binary
├── 利用程序外部行为
├── 恢复 gadget
├── 寻找输出能力
└── 尝试恢复程序本身
最开始学 ROP 的时候,我理解的是:
控制 return address
然后变成:
控制一串 return address
现在再往后看,好像真正应该关心的是:
程序当前有什么状态可以继续利用?
以及:
怎样把这些状态继续转换成更大的控制能力?
再往后还有:
ret2dlresolve
ret2VDSO
SROP
这些东西。
到了那里,利用的应该就不只是程序里面几个现成 gadget 这么简单了,而会继续碰到动态链接、虚拟地址空间和操作系统保存程序状态的一些机制。
先到这里。

















这一切,似未曾拥有