3、基础概念:变异算子、覆盖率引导、种子生成、崩溃分类
好,咱们正式开始接触模糊测试的核心概念。说实话,这四个词——变异算子、覆盖率引导、种子生成、崩溃分类——就是整个模糊测试的骨架。你把这四个东西搞明白了,后面所有的实战内容都会变得特别顺。
我刚开始学模糊测试的时候,觉得这东西不就是随机乱发数据吗?后来踩了不少坑才明白,真正的模糊测试,每一步都有讲究。咱们一个一个来拆。
3.1 变异算子:模糊测试的“手术刀”
变异算子,说白了就是怎么去修改输入数据。你不能只是简单地把数据改乱,那样效率太低了。
常见的变异算子有哪些?
- 位翻转(Bit Flip):把某个比特位从0变成1,或者反过来。这是最基础的,但有时候特别管用。
- 字节替换(Byte Replacement):随机替换掉某个字节。我习惯用这个来测试协议解析器。
- 块删除/插入(Block Deletion/Insertion):删掉一段数据,或者插入一段新数据。这个对测试边界条件很有帮助。
- 拼接(Splice):把两个不同种子的数据拼在一起。嗯,这个操作有点像基因重组。
- 字典替换(Dictionary-based):从预定义的字典里挑一个值替换进去。比如测试SQL注入时,我会准备一个包含
' OR 1=1 --的字典。
我个人经验:在实战中,不要只用一种变异算子。我建议你组合使用。比如先做一次位翻转,再做一次块插入。这样能覆盖更多奇怪的路径。
举个例子,假设原始输入是 GET /index.html HTTP/1.1,经过一次字节替换后,可能变成 GET /index\xFFtml HTTP/1.1。你想想看,如果服务器没处理好这个非法字节,是不是就可能崩溃?
3.2 覆盖率引导:让模糊测试“长眼睛”
早期的模糊测试是盲目的。随机生成数据,扔给程序,听天由命。但覆盖率引导的出现,彻底改变了这个局面。
覆盖率引导的核心思想:每次测试后,收集代码的覆盖信息。如果这次输入触发了新的代码路径,就保留它,作为后续变异的种子。否则,就丢弃。
为什么会这样?因为程序里的大部分bug都藏在那些“人迹罕至”的代码分支里。你只有不断探索新路径,才有可能挖到真正的漏洞。
常见的覆盖率指标:
| 指标 | 含义 | 我的看法 |
|---|---|---|
| 行覆盖率 | 代码行被执行的比例 | 最直观,但不够精细 |
| 分支覆盖率 | if/else等分支被覆盖的比例 | 我比较常用这个 |
| 边覆盖率 | 基本块之间的跳转关系 | AFL用的就是这个,效果很好 |
| 函数覆盖率 | 函数被调用的比例 | 适合做粗粒度分析 |
避坑指南:我曾经犯过一个错误,只盯着行覆盖率看。结果发现覆盖率很高,但漏洞一个都没找到。后来才意识到,很多漏洞藏在特定的分支组合里,行覆盖率根本反映不出来。所以我现在习惯用边覆盖率作为主要指标。
覆盖率引导的流程其实很简单:
- 用初始种子跑一次,记录覆盖率。
- 对种子进行变异,生成新输入。
- 用新输入跑程序,记录新覆盖率。
- 如果新覆盖率 > 旧覆盖率,保留这个新输入。
- 重复步骤2-4。
说白了,这就是一个“优胜劣汰”的过程。只有那些能探索新路径的输入,才有资格活下去。
3.3 种子生成:好的开始是成功的一半
种子,就是模糊测试的起点。你给什么样的种子,很大程度上决定了你能挖到什么样的漏洞。
种子从哪里来?
- 空种子:从零开始,完全靠变异算子生成。适合测试那些对输入格式要求不严格的程序。
- 合法输入:用程序正常工作时使用的输入作为种子。比如测试图片解析器时,用一张正常的PNG图片。
- 边界输入:故意构造一些边界值。比如长度为0的输入、超大的输入、包含特殊字符的输入。
- 从网络抓取:从实际流量中抓取数据包作为种子。我在测试网络协议时特别喜欢用这招。
- 从语料库生成:用程序自动生成大量符合格式规范的输入。比如用语法生成器生成一堆JSON文件。
注意:种子不是越多越好。我见过有人塞了几万个种子进去,结果大部分都是重复的,白白浪费计算资源。我建议种子集保持“小而精”,每个种子都要有代表性。
我个人习惯的做法是:先准备3-5个高质量的合法输入作为基础种子,然后让模糊测试工具在运行过程中自动生成新的种子。这样既保证了起点质量,又不会限制探索空间。
3.4 崩溃分类:别让漏洞从你眼皮底下溜走
模糊测试跑起来之后,你会得到一大堆崩溃样本。如果不做分类,你根本分不清哪些是同一个漏洞,哪些是新的。
崩溃分类的常见方法:
- 基于崩溃地址:根据程序崩溃时的指令地址来分类。同一个地址的崩溃,大概率是同一个bug。
- 基于调用栈:根据崩溃时的函数调用栈来分类。这个方法比地址更准确,因为同一个bug可能在不同地址触发。
- 基于哈希值:对崩溃样本的某些特征计算哈希值。比如对调用栈的每一层函数名做哈希。
- 基于相似度:用编辑距离等算法比较崩溃样本的相似度。适合处理那些调用栈不完全相同但本质相同的崩溃。
崩溃的严重程度分级:
| 级别 | 描述 | 示例 |
|---|---|---|
| P0 | 直接导致服务不可用,数据丢失 | 空指针解引用导致进程崩溃 |
| P1 | 严重功能异常,但服务还能运行 | 内存越界写入导致数据损坏 |
| P2 | 功能部分受影响,有变通方案 | 特定输入导致死循环 |
| P3 | 轻微问题,不影响核心功能 | 断言失败但程序能继续运行 |
我的经验:我曾经花了一周时间手动分析崩溃样本,结果发现80%都是同一个漏洞的不同触发方式。后来我写了个脚本,自动对调用栈做哈希去重,效率一下子提升了10倍。所以,自动化分类真的很重要。
嗯,这四个概念讲完了。你想想看,变异算子是“怎么改”,覆盖率引导是“往哪改”,种子生成是“从哪改”,崩溃分类是“改出问题了怎么处理”。这四个东西串起来,就是一个完整的模糊测试闭环。
下一章咱们会讲具体的工具链搭建,到时候这些概念都会用上。你先消化一下,有问题随时问。