1. 从一道CTF赛题看堆利用的实战艺术
最近在复盘一些经典的CTF(Capture The Flag)题目,特别是关于二进制安全的Pwn方向,总能发现很多值得深挖的细节。今天想和大家聊聊一道来自2022年CISCN(全国大学生信息安全竞赛)初赛的题目——newest_note。这道题在当年引起了不少讨论,它本身是一个经典的堆(Heap)利用题目,涉及UAF(Use-After-Free)漏洞。但我想聊的不仅仅是解题步骤,而是通过这道题,拆解在真实漏洞利用场景下,我们如何像侦探一样,从零开始分析一个陌生的二进制程序,如何理解其背后的内存模型,并最终构建出稳定的利用链。这个过程,远比单纯记住几个漏洞利用模板要有价值得多。
对于刚接触Pwn的同学来说,堆利用常常是道坎。它不像栈溢出那样有直观的返回地址覆盖,堆的世界更“动态”,充满了malloc、free、chunk、bin这些概念。newest_note这道题就是一个非常好的“教学案例”。它没有复杂的混淆,漏洞点清晰,但需要你扎实地理解glibc堆管理器的行为,并灵活运用调试技巧。接下来,我会假设我们手头只有这个名为newest_note的二进制文件(以及可能附带的libc.so),一步步还原分析、调试和最终利用的全过程。
2. 逆向工程:理解程序逻辑与漏洞定位
拿到二进制文件,第一步永远是尝试运行它,看看它提供了什么功能。用file命令查看,它通常是一个64位的ELF可执行文件。直接运行,或者用./newest_note,程序可能会呈现一个简单的菜单。
1. New note 2. Show note 3. Edit note 4. Delete note 5. Exit这是一个典型的“记事本”类程序模型,也是CTF堆题的经典框架。我们的目标就是通过逆向分析,找到这些功能背后的内存操作是否存在漏洞。
2.1 使用IDA Pro/Ghidra进行静态分析
我们需要用反汇编工具(如IDA Pro、Ghidra或Binary Ninja)打开它。这里以IDA Pro的免费版为例。加载后,首先找到main函数,然后定位到显示菜单和处理用户输入的循环部分。
关键是要分析每个功能函数:
- New note:通常会调用
malloc或calloc分配一块内存来存储“便签”内容。我们需要关注它分配的大小是否用户可控、分配后是否初始化、以及分配的指针存储在哪里(通常是一个全局数组或链表)。 - Show note:打印指定便签的内容。这里可能泄露关键信息,比如堆地址或libc地址。
- Edit note:编辑指定便签的内容。这里是漏洞的高发区,可能存在堆溢出、off-by-one等漏洞。
- Delete note:释放(
free)指定便签的内存。这里需要关注在释放后,是否将存储指针的变量置空(即是否处理了“悬空指针”)。如果没有,就可能导致UAF。
在newest_note这道题中,通过逆向分析,我们很快能发现一个明显的漏洞模式:在Delete note(即free操作)之后,程序并没有将指向该内存的指针置为NULL。而Show note和Edit note函数在操作前,并没有检查该指针是否有效(即是否已被释放)。这就构成了一个教科书式的Use-After-Free条件。
注意:UAF漏洞的精髓在于“使用已释放的内存”。攻击者可以在内存被
free后、但程序逻辑仍通过旧指针访问它之前,想方设法操控这块已被释放内存的内容,从而改变程序行为。
2.2 漏洞的具体形态
假设逆向后我们得到如下伪代码逻辑(高度简化):
struct note { int size; char *content; }; note *notes[16]; // 全局数组,存储最多16个便签的指针 void delete_note() { int idx; printf("Index: "); scanf("%d", &idx); if (idx < 0 || idx >= 16 || !notes[idx]) { puts("Invalid index!"); return; } free(notes[idx]->content); // 释放内容缓冲区 free(notes[idx]); // 释放note结构体本身 // 漏洞点:没有执行 notes[idx] = NULL; } void edit_note() { int idx; printf("Index: "); scanf("%d", &idx); if (idx < 0 || idx >= 16 || !notes[idx]) { // 检查指针非空,但无法判断是否已free puts("Invalid index!"); return; } printf("Content: "); read_input(notes[idx]->content, notes[idx]->size); // UAF发生在这里! }看delete_note函数,它释放了note结构体和其内部的content缓冲区,但没有清空notes[idx]这个全局指针。因此,在edit_note函数中,即使对应的note已经被释放,程序依然会通过这个“悬空指针”notes[idx]去访问内存,并调用read_input向notes[idx]->content写入数据。此时,notes[idx]和notes[idx]->content指向的内存区域都已经被归还给堆管理器,处于“空闲”状态。我们的写入操作,就是在向一块空闲的堆块(free chunk)里填数据,这为我们操控堆管理器的内部数据结构(如fd、bk指针)创造了条件。
3. 堆风水:操控glibc堆管理器的内部状态
理解了漏洞在哪里,下一步就是思考如何利用它。UAF的常见利用目标是劫持程序控制流,比如覆盖函数指针、修改返回地址等。在堆利用中,一个强大的“跳板”是__free_hook或__malloc_hook这类glibc的全局函数钩子。如果我们将__free_hook的值修改为system函数的地址,那么当下一次程序调用free()时,实际上就会执行system()命令。
但问题来了,我们如何修改__free_hook呢?它位于libc的数据区,我们需要一个任意的内存写原语(Arbitrary Write Primitive)。UAF配合堆风水(Heap Feng Shui)可以帮助我们实现这一点。
3.1 核心原理:tcache poisoning
在现代glibc(>= 2.26)中,引入了tcache(thread local cache)机制来提升堆分配效率。它对小内存块(默认64位下小于1032字节)的分配和释放有单独的缓存。一个被释放到tcache bin中的chunk,其用户数据区的开头8字节会被写入一个fd(forward pointer)指针,指向同一个tcache bin中的下一个chunk。
UAF漏洞允许我们在chunk被释放后,仍然能修改其用户数据区。如果我们释放一个chunk到tcache bin,然后通过UAF修改它的fd指针,让它指向一个我们想要控制的目标地址(比如__free_hook附近)。那么,当程序再次申请同样大小的内存时,堆管理器会顺着tcache链表分配,就有可能把我们伪造的“地址”当作一个“chunk”分配给我们。之后,我们向这个“chunk”写入数据,实际上就是在向__free_hook的位置写入数据!
这个过程叫做tcache poisoning,是当前堆利用中最主流、最稳定的技术之一。
3.2 针对newest_note的利用链构建
我们需要结合程序的具体逻辑来设计利用步骤。假设我们逆向得知:
note结构体大小为0x20字节。content缓冲区的大小可在一定范围内自选(例如0x80字节)。- 最多可以创建16个note。
那么,一个可能的利用链如下:
第一步:堆布局与地址泄露
- 创建若干个note,填满tcache bin,让后续释放的chunk进入
unsorted bin或small bin。一个在unsorted bin中的chunk,其fd和bk指针会指向libc中的main_arena结构,而main_arena的地址与libc基址有固定偏移。 - 利用
Show note功能(如果它打印content),去读取一个处于unsorted bin中的chunk的内容,就能泄露出fd指针,从而计算出libc的基地址。进而得到__free_hook和system的实际地址。
第二步:制造UAF并实施tcache poisoning
- 创建两个
content大小为0x90的note(记为A和B)。0x90是考虑到chunk头对齐后的一个常用大小,它会进入tcache。 - 删除note A,再删除note B。此时,A和B的
contentchunk都被释放,并链入0xa0大小(0x90用户大小+0x10 chunk头)的tcache bin。链表头是B,B的fd指向A。 - 此时,由于UAF漏洞,我们仍然可以通过
edit_note功能去“编辑”已经被删除的note B(因为它的指针没被清空)。我们通过编辑,修改note B的content指针所指向的内存(即B的contentchunk的用户数据区)的前8个字节,也就是修改B chunk的fd指针。我们将其修改为__free_hook的地址。 - 现在,tcache链表变成了:链表头 -> B ->
__free_hook地址(伪造的chunk)。
第三步:分配伪造chunk并写入目标地址
- 连续申请两个0x90大小的
content缓冲区。第一次malloc会返回B chunk,第二次malloc就会返回我们伪造的、位于__free_hook处的“chunk”。 - 当我们向这第二个“chunk”写入数据时,就是在向
__free_hook写入数据。我们将system函数的地址写进去。
第四步:触发shell
- 现在
__free_hook已经被替换为system。 - 创建一个新的note,其
content内容设置为字符串/bin/sh\x00。 - 删除这个note。程序会调用
free(note->content),由于__free_hook被劫持,这实际上变成了system("/bin/sh"),从而获得一个shell。
4. 动态调试:使用GDB与pwndbg验证每一步
理论规划好了,但在实际利用中,内存布局可能因为各种原因(如分配顺序、对齐、线程)而偏离预期。动态调试是必不可少的。
4.1 调试环境搭建
推荐使用pwndbg或gef插件来增强GDB。它们提供了直观的堆命令,如heap、bins、vis等,能让你清晰地看到堆块的状态和bins链表。
gdb ./newest_note pwndbg> r在程序运行时,我们需要在关键操作(如malloc/free前后)下断点,观察内存变化。
4.2 关键断点与观察点
- 在
malloc和free函数入口下断点:b malloc,b free。但更有效的是在程序调用这些函数的地方下断点,这需要结合静态分析找到调用指令的地址。 - 观察tcache状态:在疑似完成tcache poisoning后,使用
heap bins tcache或tcachebins命令查看对应大小的tcache链表,确认fd指针是否被成功修改为我们伪造的地址。 - 验证写入:在向伪造的chunk写入
system地址后,使用x/gx &__free_hook命令查看__free_hook处的值是否已被更改。 - 内存布局可视化:频繁使用
vis_heap_chunks或heap命令,图形化地查看堆内存的分配和释放情况,确保布局符合我们的预期。
提示:在实际比赛中,题目通常提供
libc.so.6。在调试时,需要用patchelf修改二进制文件的解释器和库链接,或者使用pwntools的gdb.debug功能,确保调试环境使用的libc与题目一致。地址偏移的计算必须基于题目提供的libc。
4.3 一个常见的坑:tcache double free检测
glibc对tcache有简单的double free检测:它不会将同一个chunk连续两次插入同一个tcache bin的链表头部。但在newest_note这类题目中,我们的利用链通常不依赖于对同一个chunk的double free,而是利用UAF修改已被释放chunk的fd指针,所以这个检测一般不影响我们。但如果你的利用链涉及double free,可能需要通过先放入其他bin等方式来绕过。
5. 编写自动化利用脚本:使用pwntools
手动操作菜单来利用漏洞是繁琐且容易出错的。CTF选手通常使用Python的pwntools库来编写自动化攻击脚本(Exploit)。这不仅能提高效率,也便于调整和测试。
下面是一个极简化的脚本框架,展示了如何与newest_note程序交互并实施上述利用链:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' # 启动进程 p = process('./newest_note') # 如果题目远程,用 remote('host', port) # p = remote('node4.buuoj.cn', 12345) # 加载题目提供的libc,用于计算偏移 libc = ELF('./libc.so.6') def new_note(size, content): p.sendlineafter(b'>> ', b'1') p.sendlineafter(b'Size: ', str(size).encode()) p.sendafter(b'Content: ', content) def show_note(idx): p.sendlineafter(b'>> ', b'2') p.sendlineafter(b'Index: ', str(idx).encode()) # 这里需要根据实际输出格式来解析返回的数据 def edit_note(idx, content): p.sendlineafter(b'>> ', b'3') p.sendlineafter(b'Index: ', str(idx).encode()) p.sendafter(b'Content: ', content) def delete_note(idx): p.sendlineafter(b'>> ', b'4') p.sendlineafter(b'Index: ', str(idx).encode()) # 1. 堆布局,泄露libc地址 # ... 创建、释放一系列note,触发unsorted bin ... # show_note(x) 泄露地址 # libc_base = leak_addr - libc.sym['main_arena'] - 0x60 # 具体偏移需根据libc版本确定 # free_hook = libc_base + libc.sym['__free_hook'] # system_addr = libc_base + libc.sym['system'] # 2. Tcache Poisoning # new_note(0x88, b'A'*0x88) # note 0 # new_note(0x88, b'B'*0x88) # note 1 # delete_note(1) # delete_note(0) # 此时0的content chunk在tcache链首 # 通过UAF编辑note 1(其指针未清空),修改其content chunk的fd # edit_note(1, p64(free_hook)) # new_note(0x88, b'fill') # 取回原note 1的chunk # new_note(0x88, p64(system_addr)) # 取回伪造的free_hook chunk,并写入system地址 # 3. 触发 # new_note(0x88, b'/bin/sh\x00') # note 2 # delete_note(2) # 触发 system("/bin/sh") p.interactive()这个脚本只是一个骨架,实际编写时需要根据逆向出的具体菜单提示符、输入格式以及泄露出的地址精确计算偏移来填充细节。pwntools的sendlineafter、recvuntil等方法能很好地处理这种交互。
6. 举一反三:UAF漏洞的变种与防御
通过newest_note这道题,我们完整地实践了从逆向、漏洞分析、利用链设计、调试到脚本编写的全过程。UAF作为一种内存安全漏洞,其变种和组合形式非常多。
- 结合类型混淆:如果释放的是一个包含虚函数表指针的C++对象,UAF后修改其内存,可能导致程序调用被篡改的虚函数,实现代码执行。
- 结合堆溢出:UAF提供了读写已释放内存的能力,如果再有一个堆溢出可以修改相邻堆块的关键数据,利用方式会更加灵活。
- 在浏览器引擎中的利用:UAF是浏览器漏洞的常客,攻击者通过精心构造的JavaScript代码触发DOM对象的释放与重用,最终实现沙箱逃逸。
从防御角度,开发者应该:
- 及时清空指针:在
free之后,立即将指针变量置为NULL。这是最简单有效的习惯。 - 使用智能指针:在C++中,使用
std::unique_ptr或std::shared_ptr等RAII机制管理资源,可以很大程度上避免手动管理内存带来的问题。 - 启用安全机制:现代编译器和操作系统提供了很多缓解措施,如ASLR(地址空间布局随机化)、DEP(数据执行保护)、Stack Canaries、以及glibc自身的保护(如tcache double free检测、
safe linking等)。虽然攻击者仍在不断寻找绕过方法,但这些机制极大地提高了利用门槛。
对于安全研究者或CTF选手而言,理解这些漏洞的本质和利用技巧,不仅是为了攻击,更是为了深刻理解计算机系统底层的工作原理,从而能设计出更安全的软件。每一次成功的利用,都像完成了一次精密的逻辑拼图,这种成就感正是二进制安全研究的魅力所在。