1. 漏洞挖掘的困境:传统Fuzzing的局限性,为什么需要符号执行?
大家好,欢迎来到这门课。
做漏洞挖掘这么多年,我经常被问到同一个问题:“现在Fuzzing这么火,覆盖率也高,为什么还要学符号执行?”
嗯,这个问题问得好。说实话,我刚开始接触符号执行时,也觉得这东西有点“学院派”,不如Fuzzing来得直接。但后来在一次真实的漏洞挖掘项目中,我被Fuzzing狠狠坑了一把——一个极其简单的整数溢出漏洞,跑了三天三夜愣是没跑出来。从那以后,我才真正意识到:Fuzzing不是万能的,有些坑,它天生就填不上。
1.1 传统Fuzzing的“盲人摸象”困境
Fuzzing的本质是什么?说白了,就是“乱拳打死老师傅”。它通过生成大量随机或半随机的输入,去试探目标程序的行为。你想想看,这就像在黑夜里扔石子,扔得多了,总有一块能砸中窗户。
但问题来了——如果窗户藏在墙后面呢?
传统Fuzzing有一个致命的弱点:它看不到程序的内部逻辑。它只知道“这个输入导致崩溃了”,但不知道“为什么崩溃”。更糟糕的是,如果某个代码路径需要满足非常复杂的条件才能到达,Fuzzing几乎不可能生成那样的输入。
核心痛点:Fuzzing依赖“变异”和“覆盖率反馈”,但它本质上是在盲目搜索。遇到“魔法字节”或“复杂约束”时,它就彻底抓瞎了。
举个例子,我在分析一个网络协议解析器时,遇到过这样的代码:
if (data[0] == 0xAB && data[1] == 0xCD && data[2] == 0xEF) {
// 深度处理逻辑,这里有一个堆溢出
process_deep(data);
}
这个条件看起来简单吧?但Fuzzing要随机生成以 0xAB 0xCD 0xEF 开头的输入,概率是 1/2^24。嗯,你算算,这得跑多久?我当时的实验是:AFL跑了72小时,连这个if语句的边都没摸到。
1.2 为什么Fuzzing会“卡住”?——路径爆炸的另一种形式
你可能会说:“那用覆盖率引导的Fuzzing不就行了吗?”
没错,像AFL这样的工具确实能通过插桩来发现“新路径”。但这里有个隐藏的陷阱:覆盖率反馈只能告诉你“有没有走过这条路”,但不能告诉你“怎么走通下一条路”。
我习惯把Fuzzing的搜索空间想象成一个巨大的迷宫。Fuzzing就像一只蒙着眼睛的蚂蚁,它只能靠触觉(覆盖率信号)来判断“这面墙我撞过没”。但遇到下面这些情况,它就彻底没辙了:
- 魔法字节校验:比如文件头必须是
0x89 0x50 0x4E 0x47(PNG格式) - 哈希或CRC校验:输入数据必须通过某种校验和验证
- 状态机约束:必须先发送“握手包”,才能进入“数据处理”状态
- 复杂数学条件:比如
a * b == c且a > 1000
避坑指南:我曾经在挖一个PDF解析器时,Fuzzing跑了整整一周,覆盖率卡在23%不动了。后来用符号执行一分析,发现剩下的77%代码都被一个“文件版本号必须为5”的条件挡住了。Fuzzing永远生成不了版本号恰好为5的随机字节——概率太低了。
1.3 符号执行:给程序装上一双“透视眼”
那么,符号执行是怎么解决这个问题的?
说白了,符号执行把程序的输入不是当作具体的数值,而是当作“符号”。比如,你输入一个变量 x,符号执行不会关心 x=3 还是 x=5,它只关心 x 需要满足什么条件才能走到某条路径。
回到刚才那个例子:
if (data[0] == 0xAB && data[1] == 0xCD && data[2] == 0xEF) {
process_deep(data);
}
符号执行会怎么做?它会自动收集路径约束:
data[0] == 0xAB
data[1] == 0xCD
data[2] == 0xEF
然后,它调用一个叫SMT求解器的东西(比如Z3),直接算出满足这些条件的输入值。结果就是:data = [0xAB, 0xCD, 0xEF, ...]。整个过程可能只需要几秒钟。
核心优势:符号执行不是“猜”,而是“算”。它把漏洞挖掘从“概率游戏”变成了“逻辑推理”。
1.4 一个真实的对比实验
为了让你更直观地理解,我拿一个真实的CVE漏洞来举例。这是一个经典的整数溢出漏洞,代码简化后如下:
void process_packet(uint8_t *data, uint32_t len) {
uint32_t size = data[0] * 256 + data[1];
// 这里有一个整数溢出:如果 size 很大,malloc(size) 会分配很小的内存
char *buf = (char *)malloc(size);
if (buf == NULL) return;
memcpy(buf, data + 2, size); // 堆溢出!
}
这个漏洞的触发条件是:data[0] * 256 + data[1] 的结果大于 0xFFFFFFFF(32位溢出),但 malloc 实际分配的内存却很小。
我用两种方法测试:
| 方法 | 测试时间 | 是否触发漏洞 | 原因 |
|---|---|---|---|
| AFL(传统Fuzzing) | 48小时 | ❌ 否 | 随机生成满足 data[0]*256+data[1] 溢出的概率极低 |
| Angr(符号执行) | 3分钟 | ✅ 是 | 直接求解约束 data[0]*256+data[1] > 0xFFFFFFFF |
看到差距了吗?48小时 vs 3分钟。这就是“猜”和“算”的区别。
1.5 符号执行不是银弹——但它是Fuzzing的最佳搭档
当然,我也得说句公道话:符号执行不是万能的。
它也有自己的问题,比如:
- 路径爆炸:程序路径太多,符号执行可能算到天荒地老
- 复杂约束求解慢:遇到浮点数、非线性运算,SMT求解器也会卡住
- 内存建模复杂:指针、数组、动态分配处理起来很麻烦
但正因为如此,现代漏洞挖掘的最佳实践,都是把Fuzzing和符号执行结合起来。
我个人习惯的做法是:先用Fuzzing快速探索“浅层”漏洞,遇到覆盖率卡住时,再用符号执行去攻克那些“硬骨头”路径。两者互补,效果远好于单打独斗。
注意:不要指望符号执行能完全替代Fuzzing。在实际项目中,我见过有人试图用纯符号执行去分析一个几万行的二进制程序,结果跑了三天三夜,路径爆炸了。嗯,那场面,挺尴尬的。
1.6 本章小结
好了,我们来总结一下这一章的核心观点:
- 传统Fuzzing的局限性:它本质上是“盲搜”,遇到复杂约束(魔法字节、校验和、状态机)时效率极低。
- 符号执行的优势:它把输入当作符号,通过求解路径约束来精确生成触发漏洞的输入。
- 两者是互补关系:Fuzzing负责广度,符号执行负责深度。结合起来,才是漏洞挖掘的完整拼图。
下一章,我会带你深入符号执行的核心原理——符号化、路径约束、SMT求解。我会用具体的代码例子,一步步拆解符号执行到底是怎么“算”出漏洞的。准备好了吗?我们继续。