高阶ROP链
高考刚结束,终于可以把之前断断续续看的东西重新捡起来。
前面两篇已经写过一些比较基础的 ROP。
最开始是:
ret2text
ret2shellcode
ret2syscall
后来又继续看到:
ret2csu
ret2reg
BROP
一路看下来,其实始终没有离开同一件事情:
控制程序执行流
↓
寻找可以利用的现有代码
↓
通过栈重新组织执行顺序
但是再继续往后,很多东西已经不只是:
找几个 gadget
然后拼起来
这么简单了。
开始需要碰:
ELF 动态链接
VDSO
Signal Frame
系统调用
进程上下文恢复
这些程序正常运行时本来就依赖的机制。
所以这里所谓的“高阶 ROP”,不一定代表 ROP Chain 特别长。
更多是开始利用:
程序之所以能够正常运行起来,背后的那些机制。
ret2dlresolve
先看:
ret2dlresolve
这个东西利用的是 ELF 动态链接。
假设程序存在栈溢出,现在想执行:
system("/bin/sh");
普通 ret2libc 的思路一般是:
泄露 libc 地址
↓
确定 libc
↓
计算 libc_base
↓
计算 system
↓
寻找 /bin/sh
↓
system("/bin/sh")
问题也很明显。
如果:
不知道远程 libc
或者:
不好泄露 libc 地址
就会卡住。
那我就在想:
system 的真实地址本来是谁帮程序找出来的?
答案其实就是动态链接器。
既然正常程序第一次调用动态函数的时候,本来就有一套机制负责去找真正的函数地址,那么能不能干脆让它替我找:
system
?
这就是 ret2dlresolve 最有意思的地方。
动态函数到底怎么被找到
动态链接程序里面的:
puts();
read();
printf();
这些函数,在编译阶段并不是简单把 libc 里面最终的绝对地址直接写死。
程序里面还会有一堆:
PLT
GOT
.dynsym
.dynstr
.rel.plt
.rela.plt
之类的结构。
可以先很粗地理解成:
.dynstr
↓
保存函数名
.dynsym
↓
保存符号信息
.rel.plt / .rela.plt
↓
保存重定位信息
GOT
↓
最后保存解析出来的真实地址
如果一个函数还没有被解析过,第一次调用的时候大概会经历:
function@plt
↓
PLT0
↓
动态链接器
↓
读取 relocation
↓
找到 symbol
↓
找到 symbol name
↓
查找真正函数
↓
得到地址
↓
写入 GOT
↓
继续执行
正常情况下,这些:
relocation
symbol
symbol name
当然都是编译器和链接器提前准备好的。
但动态链接器本身只是按照 ELF 结构去解释这些数据。
它并不会凭空知道:
“这一条一定是编译器生成的,所以绝对可信。”
于是事情就开始变得有意思了。
自己伪造动态链接数据
如果已经能够向:
.bss
或者其他可写内存中写数据,那么理论上可以自己构造:
fake relocation
fake symbol
fake string
然后想办法让动态链接器去解析它。
例如最终让:
symbol name
指向自己准备好的:
system\x00
整个关系大概是:
fake relocation
↓
r_info
↓
fake Elf_Sym
↓
st_name
↓
"system\x00"
↓
dynamic linker
↓
resolve system
也就是说:
我不需要提前知道 system 在哪里,只需要让本来就负责找 system 的东西替我找。
这和 ret2libc 的感觉已经完全不一样了。
dynstr、dynsym和relocation
这里几个东西最好还是分开看。
.dynstr
.dynstr 是动态字符串表。
里面放的是:
puts
read
printf
...
这些符号名称。
但是符号结构里面并不是直接保存完整字符串,而是通过偏移找到对应名字。
例如:
st_name
表示这个名字在动态字符串表里面的位置。
所以如果能够控制这个关系,就可以让动态链接器最后读到:
system
.dynsym
.dynsym 是动态符号表。
每一个符号都有对应的:
Elf32_Sym
或者:
Elf64_Sym
结构。
对 ret2dlresolve 来说,其中很重要的一部分就是:
st_name
因为它最后决定:
动态链接器到底去找哪个名字。
.rel.plt / .rela.plt
另外还有重定位表:
.rel.plt
或者:
.rela.plt
具体形式和架构有关。
其中:
r_info
会进一步关联到动态符号表中的某个 symbol。
所以整个链条实际上是:
relocation
↓
symbol
↓
symbol name
↓
dynamic linker
↓
真实函数地址
ret2dlresolve 干的事情,就是把这条正常工作流程里面的数据换成自己准备的东西。
一个大概的利用过程
假设程序存在栈溢出,而且还能调用:
read();
那么可以先找一块比较大的可写空间:
bss = elf.bss()
第一阶段先通过 ROP:
read(0, bss, 0x400);
把后面需要的数据送进去。
例如:
/bin/sh\x00
system\x00
fake relocation
fake symbol
然后再进入:
PLT0
让动态链接器读取这些伪造结构。
整个过程:
stack overflow
↓
ROP 调用 read
↓
把 fake ELF data 写到 .bss
↓
进入 PLT0
↓
dynamic resolver
↓
读取 fake relocation
↓
读取 fake symbol
↓
读取 "system"
↓
解析 system
↓
system("/bin/sh")
这样就绕开了:
提前知道 libc 地址
这个要求。
pwntools里面的ret2dlresolve
如果全部手动构造,需要自己算很多东西:
Elf32_Rel / Elf64_Rela
Elf32_Sym / Elf64_Sym
alignment
symbol index
relocation index
string offset
这些东西手算一遍当然很适合理解原理,但是每次都自己重新拼很麻烦。
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)
然后把:
ROP Chain
和:
伪造的动态链接数据
分别送进去。
但是我觉得这里还是不能只记:
Ret2dlresolvePayload()
因为如果只记这个函数,就和直接背模板没什么区别。
真正应该知道的是:
为什么动态链接器能被利用?
它到底读了什么?
symbol name 从哪里来?
relocation 又是怎么找到 symbol 的?
这些才是 ret2dlresolve 真正的东西。
ret2libc和ret2dlresolve
放在一起看会更清楚。
ret2libc:
攻击者泄露 libc
↓
自己算 libc_base
↓
自己算 system
↓
直接跳过去
ret2dlresolve:
攻击者伪造动态链接结构
↓
进入 resolver
↓
让动态链接器自己寻找 system
↓
得到真正地址
↓
继续执行
一个是:
我自己找
一个是:
我骗系统帮我找
后者利用的已经不只是几个 gadget。
而是 ELF 动态链接机制本身。
ret2VDSO
接下来是:
ret2VDSO
VDSO 全称:
Virtual Dynamically-linked Shared Object
也就是:
虚拟动态链接共享对象
Linux 程序运行的时候,不是所有可执行代码都来自:
目标 ELF
或者:
libc
内核还会给进程映射一些特殊区域。
例如:
[vdso]
[vvar]
[vsyscall]
具体有没有、长什么样,和架构、内核版本都有关系。
VDSO是干什么的
一些操作如果每次都完整陷入内核再返回,会有额外开销。
所以 Linux 会把一小部分可以在用户空间完成的辅助代码映射进进程。
例如某些时间相关操作:
gettimeofday
clock_gettime
time
就可能通过 VDSO 获得更快的实现。
程序里面可能能看到:
linux-vdso.so.1
但是它并不是普通意义上放在磁盘里的:
.so
文件。
它是:
由内核在进程启动时映射进虚拟地址空间的一块特殊 ELF 对象。
这就产生一个很直接的问题。
既然它本身也是:
可执行代码
那么为什么不能拿来做 ROP?
当然可以。
进程里的代码不只有目标程序
最开始做 ROP 的时候,很容易只盯着:
ROPgadget --binary ./pwn
然后在目标程序里面找 gadget。
如果程序本身很小,就会感觉:
什么都没有
但是实际上一个进程运行以后,地址空间里面可能还有:
目标 ELF
libc
ld.so
VDSO
其他动态库
这些区域里面都有可能存在可执行代码。
所以 gadget 的来源其实并不局限在:
./pwn
里面。
如果目标程序本身缺 gadget,而 VDSO 里面刚好存在:
syscall
或者其他有价值的指令,那么它当然也可以被放进利用链。
大概就是:
程序自身 gadget
↓
VDSO gadget
↓
syscall
还是绕不开地址
当然,问题马上又回来了。
VDSO 映射地址通常会受到:
ASLR
影响。
所以不能简单假设:
VDSO 永远在某个固定地址
实际利用还是可能需要:
信息泄露
栈上残留地址
auxiliary vector
其他地址信息
先得到:
VDSO base
然后:
VDSO gadget address
=
VDSO base
+
offset
再去使用。
所以 ret2VDSO 真正让我觉得有意思的地方并不是:
以后每道题都要去 VDSO 找 gadget。
而是它让我意识到:
ROP 可以利用的是整个进程地址空间,而不只是题目给我的那个 ELF 文件。
这个范围一下就大了很多。
SROP
最后一个是:
SROP
全称:
Sigreturn-Oriented Programming
它利用的是 Linux signal 的上下文恢复机制。
这个感觉就更不像传统 ROP 了。
signal处理完以后怎么回来
Linux 进程运行的时候可能收到各种 signal。
例如:
SIGSEGV
SIGINT
SIGALRM
SIGCHLD
假设程序本来正在正常执行。
突然来了一个 signal,需要先去执行 signal handler。
问题是:
handler 执行结束以后,程序怎么知道应该回到哪里?
原来的:
rip
rsp
rax
rbx
rcx
rdx
...
这些状态都不能凭空丢掉。
所以系统需要先保存原来的执行上下文。
可以很粗地理解成:
signal 到达
↓
保存当前进程状态
↓
构造 Signal Frame
↓
执行 signal handler
↓
sigreturn
↓
读取 Signal Frame
↓
恢复寄存器
↓
继续原来的程序
这里马上就有一个问题:
如果这个 Signal Frame 不是内核原来准备的,而是我伪造的呢?
fake Signal Frame
Signal Frame 会涉及用户栈上的数据。
如果已经能够控制栈,那么就有机会自己准备一个:
fake Signal Frame
然后主动触发:
sigreturn
系统会以为:
信号处理结束了
现在恢复之前的进程状态
于是从这个假的 frame 里面恢复:
rax
rbx
rcx
rdx
rdi
rsi
rsp
rip
...
这就很夸张了。
因为普通 ROP 想控制:
rdi
rsi
rdx
rax
往往需要:
pop rdi ; ret
pop rsi ; ret
pop rdx ; ret
pop rax ; ret
一个一个来。
SROP 相当于:
让内核一次性替我把整套寄存器全部恢复成我指定的值。
rt_sigreturn
在 x86-64 Linux 里面:
rt_sigreturn
的系统调用号是:
15
也就是:
rax = 15
然后:
syscall
就会进入对应的 sigreturn 处理流程。
如果栈上正好已经放好了:
fake Signal Frame
那么系统就会按照里面的数据恢复寄存器。
所以整个攻击思路可以变成:
stack overflow
↓
rax = 15
↓
syscall
↓
rt_sigreturn
↓
读取 fake Signal Frame
↓
一次恢复大量寄存器
↓
跳到指定 rip
用SROP执行execve
假设想执行:
execve("/bin/sh", NULL, NULL);
x86-64 下:
execve syscall number = 59
那么 fake frame 最终可以准备:
rax = 59
rdi = /bin/sh address
rsi = 0
rdx = 0
rip = syscall address
当第一次:
rax = 15
syscall
触发 sigreturn 以后,内核会恢复:
rax = 59
rdi = binsh
rsi = 0
rdx = 0
rip = syscall
接下来 CPU 去执行:
syscall
这一次就变成:
execve("/bin/sh", NULL, NULL);
整个过程:
第一次 syscall
↓
rt_sigreturn
↓
内核读取 fake frame
↓
恢复:
rax = 59
rdi = /bin/sh
rsi = 0
rdx = 0
rip = syscall
↓
第二次 syscall
↓
execve
pwntools构造Signal Frame
完整 Signal Frame 手工构造很麻烦。
pwntools 里面可以直接:
SigreturnFrame()
例如:
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)
就可以得到对应数据。
但是这里还是一样。
不能只记:
SigreturnFrame()
更重要的是知道:
为什么这个 frame 能一次控制这么多寄存器?
因为它本来就不是给攻击者准备的。
它本来就是操作系统为了:
signal handler 执行结束以后
恢复原程序上下文
而设计的。
攻击者只是把:
“恢复原来的寄存器”
变成了:
“恢复我指定的寄存器”
gadget很少的时候反而很好用
SROP 特别适合一种情况:
程序特别小
gadget 特别少
但是有:
syscall
并且还能控制栈。
有些很小的程序可能只有类似:
xor rax, rax
mov edx, 0x400
mov rsi, rsp
mov rdi, rax
syscall
ret
这里:
rax = 0
所以 syscall 对应:
read
也就是说程序可以直接:
read(0, rsp, 0x400);
向当前栈写很多数据。
看起来程序小得可怜,但实际上已经给了:
大量可控栈数据
+
syscall
+
ret
这种条件。
如果还能进一步想办法让:
rax = 15
就可以开始布置 SROP。
SROP不只是getshell
Signal Frame 能控制大量寄存器,所以它当然不只是拿来:
execve("/bin/sh")
还可以构造别的系统调用。
例如想执行:
write(1, leak_addr, 8);
可以准备:
rax = SYS_write
rdi = 1
rsi = leak_addr
rdx = 8
rip = syscall
sigreturn 以后就会得到对应寄存器状态。
同样也可以:
修改 rsp
把栈直接迁移到别的地方。
所以 SROP 更准确的理解应该是:
利用 sigreturn 获得一次非常强的批量寄存器控制能力。
至于最后拿这批寄存器去干什么,是后面的事。
两阶段SROP
如果当前栈空间不够,还可以分阶段。
第一阶段先让 SROP 调用:
read(0, bss, 0x400);
同时把:
rsp
迁移到:
.bss
例如 frame 里面:
rax = SYS_read
rdi = 0
rsi = bss
rdx = 0x400
rsp = bss
rip = syscall
sigreturn 以后:
read
↓
把第二阶段数据写入 .bss
然后:
rsp = bss
后面就可以继续从新的栈区域执行。
大概:
第一次 SROP
↓
read
↓
向 .bss 写第二阶段
↓
stack pivot
↓
第二次 SROP
这样即使原始溢出空间很小,也能继续把利用链往后铺。
高阶到底高在哪里
把这几个东西放在一起:
ret2dlresolve
ret2VDSO
SROP
会发现它们和最开始的:
pop eax
ret
已经不太是一个层面的东西。
ret2dlresolve 利用:
ELF 动态链接机制
ret2VDSO 利用:
进程中由内核映射的可执行区域
SROP 利用:
signal 上下文恢复机制
所以如果硬要分:
基础ROP
↓
利用程序本身已有代码
中阶ROP
↓
利用编译器留下的结构
利用当前程序状态
利用比较特殊的控制流
高阶ROP
↓
继续利用ELF
动态链接器
进程虚拟地址空间
操作系统机制
所谓“高阶”并不是:
payload更长
或者:
gadget更多
而是需要理解的东西开始越来越往程序底下走。
三种东西放在一起
ret2dlresolve
主要解决:
我不知道目标函数真正地址怎么办?
思路:
伪造 ELF 动态链接数据
↓
进入 resolver
↓
让 dynamic linker 替我找
需要理解:
ELF
PLT
GOT
.dynsym
.dynstr
relocation
dynamic linker
ret2VDSO
主要提醒:
目标 ELF gadget 太少怎么办?
答案不一定只是在 ELF 里面继续找。
整个进程还有:
libc
ld.so
VDSO
其他共享库
都可能存在可利用代码。
需要理解:
进程虚拟地址空间
ASLR
VDSO
system call
SROP
主要适合:
gadget 很少
但是有 syscall
而且可以控制栈
利用:
sigreturn
一次恢复大量寄存器。
需要理解:
signal
Signal Frame
rt_sigreturn
syscall
register context
三个东西表面上看完全不一样。
但是它们共同的一点就是:
已经不再只是在找一串 gadget,而是在利用程序本身依赖的系统机制。
从栈溢出一路看到这里
现在重新看前面的东西,整个路线倒是挺清楚。
最开始学栈溢出,只是在研究:
怎么覆盖 return address
后来到了初阶 ROP:
ret2text
ret2shellcode
ret2syscall
开始真正理解:
执行流可以被重新安排
再往后:
ret2csu
ret2reg
JOP
COP
BROP
开始处理:
没有理想 gadget
程序信息不完整
当前寄存器还能不能利用
控制流还能不能换一种组织方式
现在再到:
ret2dlresolve
ret2VDSO
SROP
已经开始碰:
ELF
动态链接
内核映射
signal
进程上下文
整个过程差不多就是:
覆盖一个返回地址
↓
控制一串返回地址
↓
组合 gadget
↓
利用编译器留下的代码结构
↓
利用动态链接机制
↓
利用进程和操作系统机制
越往后,真正重要的东西就越不是:
这个 payload 应该怎么背。
而是:
程序为什么会这样运行?
不要背模板
我现在越来越觉得,PWN 最容易掉进去的坑就是背模板。
比如:
ret2csu
就这样填
ret2dlresolve
就复制这个结构
SROP
就用这个 frame
做一两道题当然可能能过。
但是只要:
架构变了
编译器变了
libc变了
ELF结构变了
gadget位置变了
模板马上就不好用了。
真正应该理解的是:
CPU执行ret时到底发生什么?
函数参数是怎么传进去的?
PLT和GOT为什么存在?
动态链接器为什么能够找到函数?
signal来了以后原程序怎么恢复?
syscall以后到底是谁在处理?
这些东西。
ROP 只是把它们重新组合了一遍。
最后
高考结束以后重新看这些东西,感觉和以前又有点不一样。
最开始学 PWN 的时候,很多时候只是觉得:
居然还能这样?
栈里面改几个字节,程序居然就能跑到完全不应该去的地方。
后来慢慢开始看:
汇编
调用约定
ELF
动态链接
虚拟内存
系统调用
signal
才发现那些看起来很神奇的利用,其实都不是魔法。
程序能够被这样利用,是因为它原本就必须按照某种规则运行。
攻击只是找到这些规则里面:
哪些状态是我能控制的
哪些结构是程序会相信的
哪些执行路径可以被重新接起来
然后把它们重新组合。
所以学 ROP 学到后面,感觉真正学的已经不只是:
ROP
了。
而是:
程序到底是怎样运行起来的。
这个反而比记住多少种 ret2xxx 有意思得多。
高考结束了。
终于又可以继续看这些东西了。

















这一切,似未曾拥有