我理解的 Fuzzing:不是随机乱测,而是反馈驱动的搜索
很多人第一次听到 Fuzzing,容易把它理解成“随机生成一堆输入,然后看程序会不会崩”。这个理解不能说完全错,但只停留在了最表层。真正有效的 Fuzzing,核心并不是“随机”,而是“反馈驱动的搜索”。
换句话说,Fuzzing 并不是单纯地往程序里乱塞数据,而是在一个巨大的输入空间里,不断尝试找到更有价值的测试样例,让程序走到更深、更复杂、更少见的执行路径中,从而暴露隐藏的 bug、安全漏洞或者异常行为。
对我来说,Fuzzing 更像是一种自动化探索过程:程序本身是一个黑盒或者半透明的迷宫,输入就是进入迷宫的钥匙。Fuzzer 每次生成一个输入,程序就会执行一次;如果这次执行带来了新的覆盖率、新的状态、新的路径,Fuzzer 就会认为这个输入更有价值,并围绕它继续变异、扩展和探索。
这就是 Fuzzing 最本质的思想:用执行反馈指导后续测试。
1. Fuzzing 解决什么问题
软件测试的一个根本难点是:程序的输入空间太大了。
一个文件解析器、一个网络协议实现、一个浏览器组件、一个数据库引擎,可能接受的输入组合几乎是无限的。人工编写测试用例只能覆盖很小的一部分,传统单元测试也很难穷尽复杂边界情况。
很多漏洞恰好就藏在这些边界里。比如:
一个字段长度刚好超过预期;
一个结构体嵌套层数过深;
一个解析状态没有被正确重置;
一个异常分支里遗漏了边界检查;
一个输入触发了罕见的状态组合。
这些问题很难靠人工逐个设计测试样例发现。Fuzzing 的目标,就是尽可能自动化地生成大量输入,让程序在海量执行中暴露这些隐藏问题。
所以 Fuzzing 并不是为了证明程序正确,而是为了尽可能高效地发现程序哪里会出错。
它关心的问题包括:程序会不会崩溃?有没有内存越界?有没有 use-after-free?有没有整数溢出?有没有断言失败?有没有超时、死循环或者资源耗尽?在安全研究中,这些异常往往就是进一步分析漏洞的入口。
2. 黑盒、灰盒、白盒 Fuzzing 的区别
按照 Fuzzer 能拿到多少程序内部信息,通常可以把 Fuzzing 分成黑盒、灰盒和白盒三类。
黑盒 Fuzzing 基本不理解程序内部结构。它只负责生成输入,然后观察程序有没有崩溃。比如给一个图片解析器不断喂各种畸形图片,如果程序崩了,就把这个输入保存下来。黑盒方法简单、通用,但效率通常不高,因为它不知道哪些输入更有希望探索到新路径。
白盒 Fuzzing 则相反,它会尽可能理解程序内部逻辑,典型方式是符号执行、约束求解等。它可以分析程序路径条件,然后构造满足特定分支条件的输入。白盒方法理论上更“聪明”,但代价也更高,容易遇到路径爆炸、约束复杂、环境建模困难等问题。
灰盒 Fuzzing 介于两者之间。它不完全理解程序语义,但会收集轻量级运行反馈,最典型的就是覆盖率信息。比如某个输入让程序执行到了新的基本块、新的边、新的路径,Fuzzer 就会记录下来,并把这个输入作为后续变异的基础。
现代主流漏洞挖掘中,灰盒 Fuzzing 非常重要。它在工程上比较实用,开销相对可控,同时又比纯黑盒随机测试聪明得多。
AFL 及其后续演进版本 AFL++,就是灰盒 Fuzzing 中非常典型的代表。
3. AFL++ 为什么重要
AFL++ 的重要性不只在于它是一个工具,更在于它代表了一套成熟的灰盒 Fuzzing 工程范式。
在 AFL++ 的基本流程里,用户首先提供一个或多个初始种子输入。Fuzzer 会反复从种子队列里挑选样例,对它们进行变异,然后把新输入送进目标程序执行。执行结束后,Fuzzer 会根据覆盖率反馈判断这个输入是否有价值。
如果一个输入触发了新的覆盖率,它就可能被保存进队列,成为新的种子。后续 Fuzzer 又会基于它继续变异。这样,测试过程就形成了一个逐步扩展的搜索过程:从已有输入出发,向未知路径推进。
AFL++ 的强大之处在于,它不仅实现了基础的覆盖率反馈循环,还集成了大量工程优化,比如更丰富的变异策略、更强的插桩方式、更好的性能支持、CmpLog、persistent mode、不同 power schedule 等。这些机制共同提升了 Fuzzer 的路径探索能力。
从学习角度看,理解 AFL++ 有助于理解现代 Fuzzing 的核心问题:不是简单地“生成更多输入”,而是如何更有效地选择输入、变异输入、分配资源,并根据反馈不断调整策略。
这也是 Fuzzing 研究中很多工作的共同出发点。
4. 覆盖率反馈的意义
覆盖率反馈是灰盒 Fuzzing 的核心。
没有反馈时,Fuzzer 只能盲目尝试。它不知道一个输入是让程序走了老路径,还是打开了新分支;不知道某次变异是无意义的扰动,还是让程序进入了更深层逻辑。
有了覆盖率反馈之后,Fuzzer 就获得了一个简单但有效的目标:尽可能触发新的执行路径。
举一个简单例子。假设程序里有这样的逻辑:
if (input[0] == 'M') {
if (input[1] == 'Z') {
parse_pe_file(input);
}
}
如果 Fuzzer 完全随机生成输入,它可能需要很久才能碰巧生成以 MZ 开头的数据。但如果它发现某个输入让程序进入了第一层 input[0] == 'M' 的分支,那么这个输入就比普通输入更有价值。接下来围绕它继续变异,就更可能触发 input[1] == 'Z',进而进入更深的解析逻辑。
覆盖率反馈的意义就在这里:它让 Fuzzer 知道“哪些输入把我带到了更远的地方”。
当然,覆盖率也不是完美指标。新覆盖率不一定代表新漏洞,高覆盖率也不等于充分测试。有些漏洞并不需要特别高的覆盖率,而是需要触发特定状态组合;有些路径虽然覆盖到了,但关键数据条件没有满足。因此,覆盖率是重要信号,但不是全部答案。
这也是后续很多 Fuzzing 优化工作的动机:除了覆盖率,还能不能引入更多信号?比如稀有路径、比较指令进展、执行速度、超时情况、队列状态等。
5. 种子队列、变异、能量分配是什么
要理解 Fuzzing,就必须理解三个基本概念:种子队列、变异和能量分配。
种子队列可以理解为 Fuzzer 目前认为“有价值的一批输入”。这些输入通常来自初始样例,或者来自 Fuzzing 过程中触发新覆盖率的新样例。Fuzzer 不会完全从零开始随机生成数据,而是不断围绕这些种子做变异。
变异就是对已有输入进行修改。比如翻转某些 bit,插入一些字节,删除一段内容,替换整数,拼接两个输入,或者根据字典插入特定 token。变异的目标不是让输入“看起来正常”,而是让它在尽量保持一定结构的同时,产生足够多的扰动,从而探索新的执行路径。
能量分配则决定一个种子应该被变异多少次。不是所有种子都值得投入同样多的资源。如果一个种子执行速度快、覆盖路径稀有、历史表现好,它可能值得更多变异机会;如果一个种子执行很慢,或者总是走老路径,那继续在它身上投入太多资源可能就不划算。
这三个环节构成了 Fuzzing 的核心资源调度问题:
选择哪个种子?
怎么变异它?
给它多少测试预算?
很多 Fuzzing 优化工作,本质上都在回答这几个问题。
6. 为什么调度策略会影响效果
Fuzzing 的执行资源是有限的。
即使一台机器每秒可以跑几千次、几万次目标程序,面对巨大的输入空间,这些执行次数仍然远远不够。因此,Fuzzer 的效果很大程度上取决于资源如何分配。
调度策略决定了 Fuzzer 把时间花在哪里。它可能偏向执行速度快的种子,也可能偏向覆盖稀有路径的种子;可能偏向深层路径,也可能偏向近期带来新覆盖率的输入;可能更重视探索新区域,也可能更重视利用已有高价值种子。
不同程序的结构差异很大。有的程序浅层分支多,适合快速扩展覆盖率;有的程序需要突破复杂比较条件;有的程序执行速度慢,需要谨慎分配预算;有的程序会频繁超时,盲目投入会浪费大量资源。
因此,不存在一个永远最优的固定策略。
一个调度策略在某个 benchmark 上效果很好,换到另一个程序上可能就不明显。甚至在同一个程序的不同阶段,最合适的策略也可能不同。Fuzzing 早期可能更需要快速探索,后期可能更需要突破稀有路径或复杂条件。
这也是我认为 Fuzzing 很有意思的地方:它看起来是在测试程序,实际上也在不断做搜索、选择和资源分配。
7. 我对自适应 Fuzzing 的理解
自适应 Fuzzing 的核心思想,是让 Fuzzer 根据当前状态动态调整策略,而不是从头到尾使用固定规则。
传统 Fuzzer 中很多策略是预先设定的,比如某种固定的能量分配方式、固定的种子选择逻辑、固定的变异比例。这些策略当然有用,但它们不一定适合所有程序,也不一定适合同一个程序的所有阶段。
自适应 Fuzzing 想解决的问题是:Fuzzer 能不能观察自己的运行状态,然后判断当前更应该做什么?
比如,当覆盖率增长很快时,可以继续偏向广泛探索;当覆盖率停滞时,可能需要增加对稀有路径、比较指令或者特殊种子的关注;当超时比例升高时,需要避免把太多资源浪费在低效输入上;当某类策略近期贡献更大时,可以临时提高它的优先级。
这就把 Fuzzing 从“固定流程”推进到了“动态决策”。
当然,自适应并不意味着一定要引入复杂模型。一个有效的自适应系统,关键是找到合适的状态信号、动作空间和反馈指标。状态信号要能描述当前 Fuzzing 的运行局面,动作空间要能真实影响调度行为,反馈指标要能反映策略是否带来了有效进展。
从这个角度看,自适应 Fuzzing 的难点不只是“用什么算法”,而是如何把 Fuzzing 过程抽象成一个合理的在线决策问题。
例如,一个 Fuzzer 可以周期性地观察当前覆盖率增长、稀有路径发现、执行吞吐、队列状态、超时比例等信息,然后选择不同的调度 profile。每个 profile 代表一种资源分配倾向。执行一段时间后,再根据新覆盖率、稀有路径、比较指令进展和执行效率等反馈,更新对不同策略的判断。
这种方式的目标不是让 Fuzzer 变得“复杂”,而是让它少做无效工作,把有限的执行预算尽量投入到更可能产生新发现的方向上。
8. 随机性仍然重要,但不是全部
强调反馈驱动,并不是否定随机性。
Fuzzing 仍然需要随机性。随机变异可以制造大量非预期输入,帮助程序进入人工难以设计的边界状态。很多 bug 本身就是在偶然扰动中暴露出来的。如果没有随机性,Fuzzer 很容易陷入固定模式,探索能力会下降。
但随机性应该服务于搜索,而不是替代搜索。
低效的 Fuzzing 是盲目随机;高效的 Fuzzing 是在反馈指导下进行有偏随机。它既保留随机扰动带来的探索能力,又利用覆盖率、路径稀有性、执行效率等反馈不断修正方向。
这也是我对 Fuzzing 的核心理解:随机只是手段,反馈才是方向。
9. 总结
Fuzzing 表面上是在生成输入、运行程序、观察崩溃;本质上是在一个巨大的程序状态空间里进行反馈驱动的搜索。
黑盒 Fuzzing 简单但盲目,白盒 Fuzzing 精确但昂贵,灰盒 Fuzzing 则在工程实践中取得了很好的平衡。AFL++ 这类工具之所以重要,是因为它们把覆盖率反馈、种子队列、变异策略和资源调度组织成了一个高效的自动化漏洞发现流程。
进一步看,Fuzzing 的关键问题并不只是“能不能跑更多次”,而是“每一次执行是否更有价值”。种子怎么选,能量怎么分,策略怎么切换,反馈怎么利用,都会直接影响最终效果。
所以,Fuzzing 不是随机乱测,而是带着反馈信号不断搜索。它既有工程系统的复杂性,也有算法决策的空间。理解这一点之后,再去看 AFL++、覆盖率引导、power schedule、自适应调度,很多问题就会清晰得多。