Pwn ROP in x86
原理
通过学习 ret2text, 我们知道覆盖栈上的数据能够控制程序的走向. 找一些末尾含有 ret 的片段, 实现某些功能, 然后 ret, 通过构造好的栈上的数据控制程序走向. 由于所有函数末尾都有 ret, 所以完全可以伪造出一个函数调用栈, 然后执行函数. 这个攻击手段叫面向返回编程.
x86_64 函数传参
x86_64 处理器多了 8 个通用寄存器, r8 - r15. 64 位在调用函数传递参数的时候, 会将前 6 个参数分别通过 rdi, rsi, rdx, rcx, r8, r9 传递. 这里的顺序一样是逆序 (即默认的 cdecl 惯例), 参数从右往左依次传入 rdi, rsi…
栈地址对齐
先纠正一个观念, 64 位机器一次存取操作并不只有 64 bit, 具体和啥有关没学到, 也看不懂, 再说吧.
Glibc 2.27 版本以上, system 要求栈地址对齐到 16 字节. 而寄存器的大小是 8 字节 (8 字节对齐), 有时候构造的 ROP链 可能 rsp 并没有对齐, 然后程序就崩溃了, 拿不到 shell.
这时候可以在整个 ROP 链最前, 找一个 ret 指令, 放到返回地址上, 多一次 rip 跳转, 也使得 system 调用时, 栈顶 rsp 对齐 16 字节. 具体看后面的例题.
plt 表
纠正一下观念. 虽然库函数会进行动态链接和延迟绑定, 但是调用库函数, 并不是直接访问动态链接后的地址. got.plt 表中, 对应了所使用到的库函数的 “直接访问地址” (乱说的, 我也不知道叫什么), 然后在 got.plt 表内部, 会看这个函数有没有被填充实际地址, 如果没有, 则会找到实际地址然后填充并跳转. 如果有, 跳转到实际地址. 也就是说, got.plt 算是一层抽象, 在构造 ROP 的时候, 可以认为 got.plt 地址就是 程序所使用的库函数 的地址 (因为好像可以通过实际库函数地址去取得 .so 中存在但是该程序没有使用的库函数, 之后再学)
有空写一篇相关学习总结. (其实是因为当时看 «程序员的自我修养» 这一块的内容没看太明白, 只了解了个大概)
学习资料
例题
xctf level2
https://adworld.xctf.org.cn/task/answer?type=pwn&number=2&grade=0&id=5055&page=1
rizin 打开, iI
查看信息, x86 架构 32 位 ELF 文件, 仅开启栈不可执行保护. aaa
分析后, afl
可以看到 vulnerable_function
函数. s sym.vulnerable_function; pdg
查看反编译代码:
|
|
pdf
查看反汇编代码, 发现 buf
长度仅有 0x88, read
函数存在栈溢出.
s main; pdg
, main 函数仅是调用了这个函数.
iz
查看字符串, 发现 .data 节 0x0804a024 处存在 /bin/sh
.
有 system
函数和 /bin/sh
字符串, 我们完全可以伪造调用 system("/bin/sh")
的栈 — — 只需要 call system
时, 栈顶数据是 0x0804a024 即可. 覆盖 vulnerable_function
的返回地址为 0x0804849e (main
函数中指令 call system
的地址), 再向栈上多写入四个字节的 0x0804a024, 就可以了. 如下图所示:
读入结束后, 程序运行到 leave, 然后 ret, 栈结构如下:
之后执行指令, 栈顶数据是 "/bin/sh"
, 根据 32 位函数调用栈, 这个数据就是 system
的参数. 这样就得到了 shell.
据此, 构造 payload = b'a' * (0x88 + 0x04) + p32(system) + p32(bin_sh)
. exp:
|
|
ROP Emporium split
https://ropemporium.com/challenge/split.html
rizin 打开, iI
查看信息, x86 架构 64 位 ELF. 没开 PIE 和 canary.
aaa;afl
, 看到几个函数: pwnme
, usefulFunction
. 先看一下 usefulFunction
, s sym.usefulFunction; pdg
:
|
|
给了我们 system 函数, 但是参数并不是 "/bin/sh"
或者是 "cat flag"
之类的. iz
查看字符串, 发现 .data 节中有 "/bin/cat flag.txt"
:
nth paddr vaddr len size section type string
---------------------------------------------------------------------------------------------
0 0x000007e8 0x004007e8 21 22 .rodata ascii split by ROP Emporium
1 0x000007fe 0x004007fe 7 8 .rodata ascii x86_64\n
2 0x00000806 0x00400806 8 9 .rodata ascii \nExiting
3 0x00000810 0x00400810 43 44 .rodata ascii Contriving a reason to ask user for data...
4 0x0000083f 0x0040083f 10 11 .rodata ascii Thank you!
5 0x0000084a 0x0040084a 7 8 .rodata ascii /bin/ls
0 0x00001060 0x00601060 17 18 .data ascii /bin/cat flag.txt
查看 main
, 发现调用 pwnme
(略), 查看 pwnme
, s sym.pwnme; pdg
:
|
|
pdf
查看汇编, 检查 buf
到 rbp 距离:
|
|
可以看到 buf
距离 rbp 仅有 0x20 字节, 而 read
读入了 0x60
字节, 存在栈溢出. 那么我们的方法就是构造 ROP 链, 伪造函数调用.
根据 64 位传参规则, 需要把 "/bin/cat flag.txt"
的地址 0x00601060 放入 rdi, 然后再调用 system
. 这就需要寻找程序中相应的代码片段了. "/R/ pop rdi;ret"
来寻找是否有 pop rdi 然后 ret 的指令:
|
|
/R/
找到的 gadgets 是 rizin 认为的 gadgets, 并不是全文暴力搜索出来的. 有时候会找不到想要的 gadgets, 比如 64 位下对 r8 - r15 寄存器的操作就不被它认为是 gadgets. 要想找这些 gadgets, 可以用搜索汇编的指令 /ad/
, 比如 "/ad/ pop r15;ret"
.
不过 "/ad/ pop;ret"
也搜不到对 r8 - r15 的操作. 所以得把寄存器写出来. 即使 pop r15;ret
的后半段可以看作 pop rdi;ret
(其他寄存器也如此), 但是 rizin 也搜不到. 要注意如果想利用类似 pop r15;ret
操作 pop rdi;ret
, 还是得写成 "/ad/ pop r15;ret"
才能搜到. (暂时没去翻 rizin 或者 r2 的 issue, 但是简单搜索了一下没查到什么有用的信息.)
命令需要加引号, 因为分号操作在某个版本后变成了分割命令的标志. 引号用来把它们当作一个整体.
为什么需要带一个 ret 呢? 因为我们希望把数据存入 rdi 后还能控制程序的流程, 这里让他执行 ret 指令, 那么会把栈顶的数据弹到 rip 上. 栈数据可以通过溢出写入构造, 于是就达到了控制的效果.
需要注意的一点是, 程序在 usefulFunction
中调用了 system
, 但是我们不应该让程序跑到 call system
这条指令上, 因为 call 会把下一条指令地址压到栈上, 从而破坏了我们构造好的栈(可以看下面的栈示意图, 然后回来想想). 所以这里直接跑到 system
函数里面第一条指令, 即跳到 plt.system 的地址就行. afl
查看到该地址为 0x00400560. 栈结构的精妙之处就在于函数跑完以后就完全恢复了, 所以我们并不用管 system 内部, rsp 和 rbp 会怎么变化.
于是, 我们要构造的栈看起来如下:
当执行到 pwnme
的 ret 指令前, 栈顶的数据是 pop rdi; ret 指令的地址, ret 执行后, rip 指向 pop rdi; ret. 栈顶数据为字符串 "/bin/cat flag.txt"
的地址, 接下来执行 pop rdi, rdi 的内容为 0x00601060, 栈顶元素为 plt.system
的地址. 之后 ret, rip 指向 plt.system
的地址, 执行 system
.
然后就可以兴致勃勃地写 exp 了. 写完发现, 失败了. 把 system 改成 puts 构造, 可以打印出 “/bin/cat flag “. 这是因为栈地址没对齐. 需要构造这样的栈, 来强行使栈对齐:
对比前面, 这里多加了一个 ret 指令. 因为 64 位栈一定是 8 字节对齐的, 栈的宽度也是 8 字节, 所以再向栈中压入一条 8 字节的数据, 就可以使本来没有对齐的 (执行 pop rid 时的) 栈顶 16 字节对齐了. 这里压入 ret 指令, 不会破坏构造好的程序流. 可以分析一下, 当 pwnme 的 ret 执行后, 下一条指令依旧是 ret, 执行完后 rip 和之前的一样, 指向我们写入的 pop rdi; ret.
exp:
|
|
CTF Wiki ret2libc2
rizin 打开后 iI
查看信息, x86 架构 32 位 ELF. 没开 PIE 和 canary. aaa
后 afl
, 发现函数 secure
, s dbg.secure;pdg
:
|
|
不是后门函数. 但是给了我们 system. 尝试寻找 “/bin/sh” 字符串, 查看字符串表 iz
, 并没有.
s main;pdg
:
|
|
发现 gets
函数, 存在栈溢出.
没有 “/bin/sh”, 那只好自己构造咯. 一个思路是调用 gets, 读入 “/bin/sh” 到内存某个地方. 首先来查看一下哪里可以存数据. is | gerp OBJ
来查看变量符号:
|
|
有一个非常显眼的大小为 100 的符号 buf2. 查看其地址, 发现它在 .bss 中. iS | grep .bss
, 可以看到 .bss 具有可写权限 (啊虽然不看也知道, 但还是巩固一下).
|
|
这下万事俱备, 只欠 ROP 了.
由于需要控制返回地址, 所以不能像第一个例题一样, 从 call system
这个地方调 system (gets
同理), 而是要像第二题一样, 直接跳到函数第一行开始执行的地方. 那么这样就会有一个问题, 就是栈上需要构造返回地址(因为函数结束后会执行 ret, 从栈上获取数据). 第二题在调用函数完之后没有写返回地址, 因为只有一个函数 system, 执行了就达到了想要的效果, 所以就无所谓了. 而这一题需要先调用 gets, 所以要在栈上构造 gets 的返回地址.
先想一下栈的结构和指令流:
- 覆盖 main 的返回地址为 gets.
- 如果是正常 call, 那么现在栈顶应该是一个返回地址, 再往上才是参数.
- 如果函数结束, 那么栈顶依然是参数, 这可能对之后调用其他函数不利, 因为可能要传其他参数. 所以应该有一个 pop 指令, 弹出这个参数. 那么之前那个返回地址可以找一个 pop;ret 指令, 达到效果.
- ret 会弹栈顶地址到 eip, 也就是说, 接下来应该填下一个要调用的函数/指令地址, 在这里, 也就是 system 的地址.
- 同理, 如果正常 call system, 栈上有一个返回地址, 由于我们不必要再指定指令流, 所以这个地方可以乱填.
- 最后再是传入 system 的参数.
那么现在我们还差一个 pop;ret, 用 "/ad/ pop;ret"
或者 "/R/ pop;ret"
搜一下, 得到 0x08048756 或 0x0804843d.
"/ad"
找到的是 ret 的地址, 不是 pop 的… 离谱…所以, 我们构造这样的栈:
不一步步演示了, 累了写不动了.
或者, 我们可以换一种写法. 由于这里只需要调用两个函数, 并且参数都是一个, 那么构造这样的栈也能达到效果:
这样 gets
的返回地址是 system
, 参数是 buf2
(第二个), 然后执行 system
, 返回地址是 buf2
(第二个) 的地址, 参数是 buf2
(第一个). 这种写法不像上面那种具有普适性, 但可以有效减少输入的长度, 如果对写入的大小有限制的话, 可能只能使用这种构造方法.
exp:
|
|