高阶ROP链
前面两篇已经介绍了基本 ROP 和一些更加复杂的代码复用方式。
从最开始的:
ret2text
ret2shellcode
ret2syscall
到后来的:
ret2csu
ret2reg
BROP
本质上一直没有离开:
控制程序执行流
↓
寻找可以利用的现有代码
↓
通过栈重新组织执行顺序
但是再往后,很多利用技术已经不只是单纯寻找几个 gadget。
我们开始利用:
ELF 动态链接
VDSO
Signal Frame
这些更底层的机制。
所以所谓“高阶 ROP”,并不一定意味着 ROP Chain 变得特别长,而是:
开始利用操作系统、动态链接器以及进程上下文恢复机制本身。
ret2dlresolve
先从:
ret2dlresolve
开始。
它利用的是 Linux ELF 的:
动态链接
Lazy Binding
机制。
为什么需要 ret2dlresolve
假设一个程序存在栈溢出。
正常来说,如果想执行:
system("/bin/sh");
最简单的方法可能是:
泄露 libc 地址
↓
确定 libc 版本
↓
计算 libc_base
↓
计算 system
↓
寻找 /bin/sh
↓
ret2libc
但这里存在一个问题:
如果我们:
不知道远程 libc
或者:
无法稳定泄露 libc
怎么办?
有没有可能直接让动态链接器替我们解析:
system
?
答案就是:
ret2dlresolve
动态链接
一个动态链接 ELF 程序调用:
puts();
read();
printf();
这些 libc 函数时,并不是在编译阶段就把它们的绝对地址写死。
程序中会存在:
PLT
GOT
以及几个与动态链接相关的重要表:
.dynsym
.dynstr
.rel.plt / .rela.plt
.dynamic
可以简单理解成:
.dynstr
↓
保存符号名称
.dynsym
↓
保存符号描述
.rel.plt / .rela.plt
↓
保存重定位信息
GOT
↓
最终保存函数真实地址
第一次调用一个尚未解析的动态函数时,会进入动态链接器。
大致过程可以理解成:
function@plt
↓
PLT0
↓
_dl_runtime_resolve
↓
读取 relocation
↓
读取 symbol
↓
读取 symbol name
↓
在 libc 中查找函数
↓
得到真实地址
↓
写入 GOT
↓
执行函数
正常情况下,这些数据当然都是程序自己提供的。
但是如果:
我们能够伪造动态链接器准备读取的数据呢?
事情就变得不一样了。
ret2dlresolve 的核心
动态链接器并不知道:
“这条 relocation 一定是编译器生成的。”
它只是按照 ELF 结构读取数据。
所以我们可以在:
.bss
或者其他可写内存区域中人为构造:
fake relocation
fake symbol
fake string table entry
让动态链接器最终认为:
需要解析的函数名称 = "system"
然后调用:
_dl_runtime_resolve
于是:
动态链接器
↓
查找 system
↓
得到 system 实际地址
↓
跳转执行
这样就不需要提前知道:
libc_base
system offset
三张重要的表
理解 ret2dlresolve,主要就是理解三个结构。
.dynstr
动态字符串表。
里面保存:
puts
read
printf
system
这类符号名称。
例如某个符号结构中保存:
st_name = 0x123
并不是直接保存字符串。
它表示:
.dynstr + 0x123
的位置存放真正的函数名。
所以如果可以伪造:
st_name
就可以让动态链接器去读取我们自己放进去的:
"system\x00"
.dynsym
动态符号表。
其中每一个:
Elf32_Sym
或:
Elf64_Sym
用于描述一个动态符号。
对于 ret2dlresolve 来说,其中最重要的就是:
st_name
因为它最终决定:
动态链接器到底要解析哪个函数。
.rel.plt / .rela.plt
最后是重定位表。
32 位常见:
Elf32_Rel
64 位常见:
Elf64_Rela
这里包含:
r_offset
r_info
等内容。
其中:
r_info
又会告诉动态链接器:
应该去
.dynsym的哪一项读取符号。
于是整个关系可以表示成:
fake relocation
↓
r_info
↓
fake Elf_Sym
↓
st_name
↓
"system\x00"
↓
动态链接器
↓
resolve system
这就是 ret2dlresolve 的骨架。
利用过程
假设程序存在:
read()
并且存在栈溢出。
我们首先寻找一个足够大的可写区域:
bss = elf.bss()
第一阶段 ROP 可以让程序执行:
read(0, bss, 0x400);
把后面需要的数据写入:
.bss
其中包括:
/bin/sh\x00
system\x00
fake relocation
fake symbol
然后第二阶段让程序进入:
PLT0
并向动态解析器提供我们构造好的 relocation 参数。
最终:
_dl_runtime_resolve
↓
system
↓
system("/bin/sh")
使用 pwntools
手工构造:
Elf32_Rel
Elf32_Sym
alignment
reloc_index
symbol_index
很适合理解原理,但是写起来比较麻烦。
pwntools 已经提供:
Ret2dlresolvePayload
可以自动完成大部分结构计算。
一个基本结构可以写成:
from pwn import *
context.binary = elf = ELF('./pwn')
p = process('./pwn')
rop = ROP(elf)
dlresolve = Ret2dlresolvePayload(
elf,
symbol='system',
args=['/bin/sh']
)
然后找到一个可以再次读入数据的位置:
rop.read(
0,
dlresolve.data_addr,
len(dlresolve.payload)
)
接着:
rop.ret2dlresolve(dlresolve)
最终发送:
payload = flat({
offset: rop.chain()
})
p.sendline(payload)
p.send(dlresolve.payload)
p.interactive()
从利用逻辑上看其实就是:
第一次输入
↓
栈溢出
↓
ROP 调用 read
↓
把伪造 ELF 数据写入 .bss
↓
进入 PLT0
↓
动态链接器读取伪造数据
↓
解析 system
↓
system("/bin/sh")
为什么它很有意思
ret2dlresolve 的关键并不是:
找到 system 地址
而是:
欺骗本来负责寻找 system 地址的动态链接器,让它替我们完成寻找工作。
所以它和普通 ret2libc 的区别很明显。
ret2libc:
攻击者自己找到 libc
↓
自己计算 system
↓
直接跳 system
ret2dlresolve:
伪造动态链接结构
↓
调用 dynamic linker
↓
让 linker 查找 system
↓
linker 替我们跳过去
利用的是 ELF 动态链接机制本身。
ret2VDSO
接下来是:
ret2VDSO
VDSO 全称:
Virtual Dynamically-linked Shared Object
即:
虚拟动态链接共享对象
VDSO 是什么
正常情况下,程序调用系统调用需要:
用户态
↓
内核态
↓
处理系统调用
↓
用户态
用户态和内核态频繁切换是存在开销的。
一些系统调用使用频率特别高,例如:
gettimeofday
clock_gettime
time
如果每一次都真正陷入内核,就比较浪费。
因此 Linux 会把一个特殊的小型共享对象映射到每一个进程的用户空间:
linux-vdso.so.1
可以运行:
ldd /bin/sh
观察。
一般能看到:
linux-vdso.so.1
但是如果尝试:
find / -name linux-vdso.so.1
却找不到一个正常对应的文件。
原因就是:
VDSO 不是普通磁盘上的动态库,而是由内核在进程启动时映射进去的。
查看 VDSO
可以观察:
cat /proc/self/maps
其中一般会出现:
[vdso]
除此之外还可能看到:
[vvar]
[vsyscall]
不同内核版本、架构对应的实现并不完全相同。
VDSO 与利用
既然:
VDSO
本身也是:
用户空间中的可执行代码
那么理论上它自然也可以成为:
ROP gadget 来源
或者提供某些:
syscall
sysenter
指令。
于是:
ret2VDSO
本质上就是:
将 VDSO 中的代码作为代码复用攻击的一部分。
例如目标程序本身非常小:
.text 段 gadget 很少
但是 VDSO 中可能存在我们需要的指令。
那么就可以:
程序自身 gadget
↓
VDSO gadget
↓
system call
问题:VDSO 地址
事情当然没有这么简单。
现代系统存在:
ASLR
VDSO 的基址通常也会发生随机化。
所以 ret2VDSO 往往需要首先获得:
VDSO base
可能的方法包括:
信息泄露
栈上残留指针
辅助向量
其他地址泄露
拿到基址以后,再根据:
offset
确定里面具体 gadget 的实际地址。
所以:
VDSO address
=
VDSO base
+
gadget offset
ret2VDSO 的价值
它最值得理解的并不是:
“以后做题一定用 VDSO。”
而是:
一个进程中可供 ROP 使用的代码,不只有目标 ELF 和 libc。
还包括:
目标程序
libc
ld.so
VDSO
其他共享库
所以在 gadget 很缺的时候,不应该只盯着:
ROPgadget --binary ./pwn
程序运行时的整个虚拟地址空间,都可能提供可以利用的代码。
SROP
最后一个是:
SROP
全称:
Sigreturn-Oriented Programming
即:
面向 Sigreturn 的编程
相比传统 ROP,它利用的是 Linux:
Signal
机制。
SROP 是 Erik Bosman 等人在 2014 年提出的一种代码复用攻击技术。
Signal
Linux 进程运行的时候可能收到:
signal
例如:
SIGSEGV
SIGINT
SIGALRM
SIGCHLD
当信号到来的时候,CPU 原来正在:
正常执行程序
现在却需要先去执行:
signal handler
那么问题就出现了:
信号处理完以后,怎样恢复之前的程序状态?
内核必须保存原来的:
寄存器
栈指针
指令地址
flags
等上下文。
这些数据会被组织成一个:
Signal Frame
放在用户空间的栈中。
大致可以理解成:
signal 到达
↓
内核保存进程上下文
↓
在用户栈构造 Signal Frame
↓
执行 signal handler
↓
sigreturn
↓
从 Signal Frame 恢复寄存器
↓
回到原程序
这里出现了一个非常有意思的问题。
如果 Signal Frame 是假的呢
Signal Frame 位于:
用户空间
因此如果攻击者已经能够:
控制栈
就可能自己构造一个假的:
Signal Frame
然后主动触发:
sigreturn
内核会认为:
“信号处理结束了,现在按照这个 frame 恢复进程状态。”
于是从伪造 frame 中恢复:
rax
rbx
rcx
rdx
rdi
rsi
rsp
rip
...
也就是说:
[
\boxed{\text{一次 sigreturn 可以同时控制大量寄存器}}
]
这就是 SROP 的核心。
与普通 ROP 的区别
普通 ROP 想设置:
rdi = 1
rsi = buf
rdx = 0x100
rax = 1
可能需要:
pop rdi ; ret
value
pop rsi ; ret
value
pop rdx ; ret
value
pop rax ; ret
value
但 SROP 可以直接伪造:
Signal Frame
让内核一次性恢复:
rax
rdi
rsi
rdx
rsp
rip
所以可以理解成:
普通 ROP
=
一个 gadget 一个 gadget 控寄存器
SROP
=
让内核一次性替我们恢复整套寄存器
触发 sigreturn
在 x86-64 Linux 中:
rt_sigreturn
对应系统调用号:
15
也就是:
rax = 15
syscall
如果能够让程序执行:
syscall
并保证:
rax = 15
就会进入:
rt_sigreturn
然后内核开始从当前栈读取:
Signal Frame
使用 pwntools 构造 Signal Frame
手动构造完整的 Signal Frame 非常麻烦。
pwntools 中可以直接使用:
SigreturnFrame()
例如我们希望构造:
execve("/bin/sh", 0, 0);
在 x86-64 下:
rax = 59
rdi = binsh
rsi = 0
rdx = 0
rip = syscall
可以写:
from pwn import *
context.arch = 'amd64'
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = binsh_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_addr
然后将:
bytes(frame)
放入 payload。
一个典型的 SROP 思路
假设存在:
syscall ; ret
并且已经可以控制栈。
payload 可以大致构造成:
padding
+
用于设置 rax=15 的执行流程
+
syscall
+
fake Signal Frame
执行:
rax = 15
↓
syscall
↓
rt_sigreturn
↓
内核读取 fake frame
↓
恢复寄存器
而我们在 frame 中提前指定:
rax = 59
rdi = /bin/sh
rsi = 0
rdx = 0
rip = syscall
于是 sigreturn 完成后:
rip = syscall
再次执行:
syscall
这一次:
rax = 59
因此真正执行:
execve("/bin/sh", NULL, NULL);
流程就是:
stack overflow
↓
rax = 15
↓
syscall
↓
sigreturn
↓
fake Signal Frame
↓
恢复 rax/rdi/rsi/rdx/rip
↓
syscall
↓
execve("/bin/sh", 0, 0)
smallest
SROP 中很经典的一类程序结构甚至可能只有几条汇编。
例如:
xor rax, rax
mov edx, 0x400
mov rsi, rsp
mov rdi, rax
syscall
ret
因为:
rax = 0
所以第一次:
syscall
实际上就是:
read(0, rsp, 0x400);
直接向栈中读取:
0x400
字节。
程序本身虽然极其简单,但却同时提供:
任意长度栈写入
+
syscall
+
ret
这些条件。
于是可以通过多阶段输入不断重新利用:
read
最终构造 SROP。
这也是 SROP 很有意思的地方:
gadget 数量非常少的程序,有时候反而特别适合使用 Signal Frame。
SROP 不只是 getshell
既然 Signal Frame 可以控制:
几乎全部寄存器
那么当然不只有:
execve
一种用途。
例如可以构造:
read
让程序把第二阶段 ROP 写入 .bss。
也可以构造:
write
泄露内存。
例如:
rax = SYS_write
rdi = 1
rsi = leak_addr
rdx = 8
rip = syscall
就相当于:
write(1, leak_addr, 8);
甚至可以修改:
rsp
完成:
stack pivot
因此 SROP 更准确的理解应该是:
利用 sigreturn 获得一个功能极强的“批量寄存器控制器”。
两阶段 SROP
实际利用中经常不会直接:
第一次 SROP
↓
getshell
因为可能还不知道:
/bin/sh
地址,或者没有合适的可写数据。
那么可以使用两阶段:
第一次 SROP
↓
read
↓
向 .bss 写入第二阶段数据
↓
stack pivot 到 .bss
↓
第二次 SROP
↓
execve
第一阶段 frame:
rax = SYS_read
rdi = 0
rsi = bss
rdx = 0x400
rsp = bss
rip = syscall
其作用就是:
read(0, bss, 0x400);
然后:
rsp = bss
把新的栈也迁移过去。
第二次就可以继续布置:
/bin/sh
+
sigreturn frame
最终执行:
execve("/bin/sh", 0, 0);
高阶 ROP 到底“高”在哪里
到了这里再回头看:
ret2dlresolve
ret2VDSO
SROP
会发现它们已经和最早的:
pop rdi ; ret
不是一个层面的思维方式了。
ret2dlresolve 利用:
ELF 动态链接器
ret2VDSO 利用:
内核映射到进程中的特殊共享对象
SROP 利用:
Linux Signal 上下文恢复
也就是说:
基础 ROP
↓
利用程序里的代码
中阶 ROP
↓
利用编译器、程序状态和远程行为
高阶 ROP
↓
利用加载器、ELF、内核接口和进程机制
这才是高级 ROP 真正有意思的地方。
三种技术比较
ret2dlresolve
解决:
不知道 libc
不知道 system 地址
方法:
伪造动态链接结构
↓
欺骗 dynamic linker
↓
让它替我们解析 system
核心:
ELF
PLT
GOT
.dynsym
.dynstr
.rel.plt / .rela.plt
_dl_runtime_resolve
ret2VDSO
解决:
程序自身 gadget 不足
需要额外可执行代码
方法:
利用进程中的 VDSO
核心:
VDSO
ASLR
syscall
sysenter
进程虚拟地址空间
SROP
解决:
gadget 极少
难以逐个控制寄存器
方法:
伪造 Signal Frame
↓
触发 sigreturn
↓
让内核恢复我们指定的寄存器
核心:
signal
Signal Frame
sigreturn
syscall
寄存器上下文
从栈溢出到高阶 ROP
到这里,可以把前面的几篇串起来。
最开始是:
栈溢出
我们只学会:
覆盖 return address
然后:
初阶 ROP
学习:
ret2text
ret2shellcode
ret2syscall
开始真正控制程序执行流。
再到:
中阶 ROP
学习:
ret2csu
ret2reg
JOP
COP
BROP
开始处理 gadget 不足、程序文件缺失等问题。
最后:
高阶 ROP
进入:
ret2dlresolve
ret2VDSO
SROP
整个过程实际上就是:
覆盖一个地址
↓
控制多个返回地址
↓
组合 gadget
↓
利用编译器留下的结构
↓
利用 ELF 动态链接
↓
利用操作系统机制
越往后,真正重要的已经不是:
“记住这个 payload 怎么写。”
而是:
“理解程序为什么会按照这个机制运行。”
总结
ROP 学到后面,最需要避免的一件事就是:
背模板
比如记住:
ret2csu 就这样填
ret2dlresolve 就这样写
SROP 就复制这个 frame
在某一道题上也许可以成功,但是环境稍微改变:
架构变了
编译器变了
libc 变了
ELF 结构变了
gadget 变了
模板就可能完全失效。
真正需要理解的应该是:
CPU 怎样执行 ret
↓
函数参数怎样传递
↓
ELF 怎样加载
↓
动态链接器怎样解析符号
↓
内核怎样处理 signal
↓
程序状态怎样被恢复
ROP 只是这些机制被重新组合之后的一种结果。
从这个角度来说:
学 ROP 最终学的其实不是 ROP,而是程序到底是怎样运行起来的。















这一切,似未曾拥有