88、漏洞逆向分析(二):堆溢出漏洞分析、Use-After-Free
堆溢出和 Use-After-Free(UAF),这两个名字在漏洞分析圈里,基本就是「噩梦」的代名词。我刚开始啃二进制漏洞那会儿,觉得栈溢出简单粗暴,堆嘛……总觉得隔着一层纱。直到第一次在真实项目中抓到堆溢出的现场,才明白这玩意儿有多阴险。
说白了,堆不像栈那样规规矩矩。栈是后进先出,函数调完就回收,干净利落。堆呢?你申请、我释放、他修改,乱成一锅粥。攻击者就爱这种混乱。
今天咱们就掰开揉碎,把堆溢出和 UAF 的逆向分析思路讲清楚。嗯,我会带上一些我踩过的坑,希望能帮你少走弯路。
一、堆溢出漏洞:从原理到逆向定位
1.1 堆溢出是什么?
堆溢出,说白了就是你在堆上申请了一块内存,比如 64 字节,结果你往里写了 128 字节的数据。多出来的 64 字节,直接覆盖了相邻堆块的管理结构,或者别的堆块的数据。
你想想看,堆管理器(比如 glibc 的 ptmalloc)是靠链表来管理空闲块的。一旦堆块头部(chunk header)被篡改,链表就乱了。攻击者可以利用这个乱,实现任意地址写。
核心要点:堆溢出的本质是「写越界」。攻击者通过覆盖相邻堆块的元数据或数据,劫持程序控制流。
1.2 逆向分析堆溢出的步骤
我个人习惯,拿到一个疑似堆溢出的样本,会按下面几步来:
- 定位堆操作函数:在 IDA 或 Ghidra 里搜索 malloc、calloc、realloc、free。这些是堆操作的入口。
- 检查拷贝操作:找到 memcpy、strcpy、sprintf 等函数调用。看它们的目标地址是不是堆指针。
- 计算拷贝长度:分析拷贝长度参数是否受用户输入控制。如果长度可控,且没有边界检查,那基本就是堆溢出了。
- 观察堆块布局:在调试器中(比如 GDB 配合 pwndbg),用
heap命令查看堆块分布。看看溢出后相邻堆块是否被破坏。 - 验证利用可行性:尝试构造输入,看能否触发崩溃,或者劫持函数指针。
小技巧:在 GDB 里用 watch 命令监视堆块头部的 size 字段。一旦被改写,立刻断下来。我曾经用这招抓到一个隐藏很深的 off-by-one 漏洞。
1.3 代码示例:一个典型的堆溢出
// 漏洞代码示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *buf1 = (char *)malloc(16); // 申请 16 字节
char *buf2 = (char *)malloc(16); // 相邻堆块
// 假设用户输入长度可控
size_t user_len = 32; // 恶意输入长度
read(0, buf1, user_len); // 堆溢出!写入 32 字节到 16 字节的缓冲区
// buf2 的头部和数据被覆盖
printf("buf2: %s\n", buf2);
return 0;
}
这段代码里,buf1 只申请了 16 字节,但 read 却读了 32 字节。多出来的 16 字节直接覆盖了 buf2 的堆块头部。如果攻击者精心构造这 16 字节,就能篡改 buf2 的 size 字段,进而实现任意地址写。
二、Use-After-Free:幽灵般的漏洞
2.1 UAF 的本质
UAF 更阴险。它发生在你释放了一块堆内存之后,但指针还保留着(悬空指针)。然后你通过这个指针去读写内存。这时候那块内存可能已经被重新分配给别的对象了。
为什么会这样?因为堆管理器不会把释放的内存清零,只是把它标记为「空闲」。如果攻击者能控制这块内存的新主人,就能在旧指针下塞入恶意数据。
注意:UAF 最常见的利用手法是「类型混淆」。释放一个对象 A,然后申请一个大小相同的对象 B。B 的虚函数表指针覆盖了 A 原来的位置。通过 A 的悬空指针调用虚函数,就跳到了 B 的恶意代码。
2.2 逆向分析 UAF 的步骤
我记得有一次分析一个浏览器漏洞,UAF 藏得特别深。我的分析流程是这样的:
- 第一步:追踪对象的生命周期。找到对象的创建(new/malloc)、使用、释放(delete/free)三个点。
- 第二步:检查释放后的使用。在释放点之后,看是否还有对该指针的读写操作。IDA 的交叉引用(XREF)功能在这里很有用。
- 第三步:分析引用计数。很多 C++ 对象用引用计数管理生命周期。如果计数逻辑有误,可能导致提前释放。
- 第四步:调试确认。在释放点下断点,记录指针值。然后在疑似使用点检查该内存是否已被重新分配。
避坑指南:我曾经在分析一个 UAF 时,一直盯着主线程看,结果漏洞其实发生在子线程。多线程下的 UAF 更难定位,因为释放和使用的时序不确定。建议用 AddressSanitizer(ASan)来辅助检测。
2.3 代码示例:UAF 的典型场景
// UAF 漏洞示例
#include <iostream>
#include <cstdlib>
class Animal {
public:
virtual void speak() { std::cout << "Animal sound\n"; }
};
class Dog : public Animal {
public:
void speak() override { std::cout << "Woof!\n"; }
};
int main() {
Animal *ptr = new Dog();
delete ptr; // 释放对象
// 悬空指针 ptr 仍然指向已释放的内存
// 攻击者可以在这里申请一个大小相同的对象,覆盖原内存
char *fake = (char *)malloc(sizeof(Dog));
// 在 fake 中构造恶意虚函数表指针
ptr->speak(); // UAF!调用虚函数,跳转到恶意代码
return 0;
}
这段代码里,delete ptr 之后,ptr 变成了悬空指针。如果攻击者在 ptr->speak() 之前,通过 malloc 申请到同一块内存,并写入精心构造的虚函数表指针,那么 speak() 就会执行攻击者的代码。
三、知识体系与核心逻辑
下面这张图,是我梳理的堆溢出和 UAF 分析的核心逻辑。你看一眼,基本就能把握住重点。
四、实战中的避坑指南
做堆漏洞分析,有几个坑我反复踩过,今天一并告诉你:
- 别信调试器里的堆布局:GDB 显示的堆块信息,有时候会因为优化或 ASLR 而失真。我建议用
malloc_hook或free_hook来打日志,记录每次堆操作的真实地址。 - 注意 glibc 版本差异:2.26 之后引入了 tcache,堆管理的逻辑大变。你在 Ubuntu 16.04 上能用的 exploit,到 18.04 可能就废了。分析前先确认目标系统的 libc 版本。
- UAF 不一定是立即崩溃的:有时候释放后很久才使用,中间堆块被反复分配释放。这时候用
valgrind或ASan比手动调试更高效。 - 堆风水(Heap Feng Shui):攻击者会通过精心安排申请和释放的顺序,让目标对象落在可控的位置。逆向分析时,要留意这种「布局」操作。
重要提醒:堆溢出和 UAF 的利用,往往需要绕过各种保护机制(ASLR、DEP、Safe Unlink 等)。但逆向分析阶段,我们只关注漏洞本身的存在和触发路径。利用技巧是后续课程的内容。
五、总结
堆溢出和 UAF,是二进制漏洞分析里的硬骨头。但说白了,只要抓住两条主线:
- 堆溢出:盯着拷贝操作的长度参数,看它是不是用户可控的。
- UAF:盯着对象的释放点和后续使用点,看中间有没有重新分配。
剩下的,就是耐心和细心了。我刚开始做的时候,一个堆溢出分析了两周才找到根因。后来经验多了,慢慢就能从反汇编代码里嗅出不对劲的地方。嗯,这行当就是这样,没有捷径,只有堆出来的经验。
公众号:蓝海资料掘金营,微信deep3321