Fuzz Testing¶
Introduction¶
Fuzz的目的是驱动系统执行进入期望的状态。
Evolution¶
基因算法¶
基因算法是一种模拟自然选择的算法。将初始随机种子输入到种子池,通过选择和变异得到种子,以这个种子作为测试用例,执行程序,得到结果,根据结果判断是否是好的种子,如果是,加入到种子池,否则丢弃。
Coverage¶
如果fuzzer生成了一个能够扩大代码覆盖率的测试用例,那么这个测试用例就是好的种子。
覆盖分为Block Coverage(基本块覆盖)、Branch Coverage(分支覆盖)、Return Coverage(返回覆盖)、Path Coverage(路径覆盖)等。
如果我们可以获得一组覆盖程序控制流图(CFG)中每条可行路径的输入集,那么这个程序就被认为是饱和测试的。
路径覆盖是最全面的覆盖,但在实际中很难实现。在实践中,我们更多是采取分支覆盖,因为它在有效性和可行性之间取得了良好的平衡。
Loops: Trouble Maker for Branch Coverage¶
循环会影响分支覆盖的测试,应当使用Bounded Loop Unrolling技术。把循环变成一个一定次数的顺序执行的代码块。
Concolic execution: forced path exploration¶
Narrow-range constraints¶
对于一些约束很窄的情况,使用随机的输入很难找到满足约束的输入。但从另一方面,如果我们知道约束的范围,我们可以使用符号执行来找到满足约束的输入。
让fuzz完成大部分的状态空间探索,然后使用符号推理引擎来解决剩下的约束。
Fuzz 测试工具¶
AFL (American Fuzzy Lop)¶
AFL 是最经典的覆盖率引导模糊测试工具,由 Michal Zalewski 开发。
AFL 工作原理
AFL 使用 编译时插桩(compile-time instrumentation)来获取代码覆盖率信息:
- 在编译时注入 instrumentation 代码(通过
afl-gcc或afl-clang) - 维护一个 共享内存 bitmap(64KB),记录边覆盖率(edge coverage)
- 每个分支用
(prev_block << 16) | cur_block哈希作为 bitmap 索引 - 如果发现新的 bitmap 命中,将该输入加入种子队列
flowchart TD
A["种子队列"] --> B["选择种子"]
B --> C["变异输入"]
C --> D["执行目标程序(插桩)"]
D --> E{"新覆盖率?"}
E -->|是| F["加入种子队列"]
E -->|否| G["丢弃"]
F --> A
G --> A
- Bit Flip:翻转 1/2/4/8 个比特
- Byte Flip:翻转 1/2/4/8 个字节
- Arithmetic:对整数进行加减运算(+1, -1, +2, -2 等小值)
- Dictionary:替换为已知的特殊值(如
0,0xFF,0xFFFF) - Havoc:随机组合多种变异,适合探索复杂输入
- Splicing:将两个种子拼接
- Fork Server 模式:避免每次执行都 fork 整个进程
- 共享内存 bitmap:高效的覆盖率记录
- 自动字典推断:从二进制中提取常量字符串
- 持久模式(persistent mode):在进程内循环执行,大幅提高速度
LibFuzzer¶
LibFuzzer 是 LLVM 项目内置的进程内模糊测试引擎。
LibFuzzer 与 AFL 的区别
| 特性 | AFL | LibFuzzer |
|---|---|---|
| 执行方式 | 进程级(fork) | 进程内(in-process) |
| 插桩方式 | 编译时插桩 | Sanitizer 插桩(ASan/MSan/UBSan) |
| 目标格式 | 独立可执行文件 | 需要编写 harness 函数 |
| 速度 | 较快(fork server) | 极快(无 fork 开销) |
| 内存检测 | 需要配合 ASan | 内置 Sanitizer 支持 |
| 适用场景 | 黑盒/灰盒测试 | 库函数/API 测试 |
LibFuzzer 的 harness 函数签名:
Honggfuzz¶
Honggfuzz 是 Google 开发的另一个覆盖率引导模糊测试工具,支持 硬件辅助覆盖率收集。
Honggfuzz 的独特之处
- 支持硬件性能计数器(Intel PT、Branch)作为覆盖率反馈
- 支持 POSIX 信号处理,可以测试多线程程序
- 支持持久模式和外部模式
- 内置 Intel PT 解码器,无需内核模块
覆盖率引导原理详解¶
覆盖率引导是现代 fuzzer 的核心思想。其基本假设是:能够触发新代码路径的输入更有可能发现 bug。
覆盖率收集机制
- AFL 使用
(prev_block_id << 16) | cur_block_id作为边标识 - 边覆盖率比块覆盖率更细粒度,能区分
A->B和B->A - 使用哈希表(bitmap)存储,碰撞概率可接受
- 记录完整的执行路径(所有分支的组合)
- 理论上最精确,但状态空间爆炸
- 实践中通常用边覆盖率近似
- 考虑调用栈信息,区分不同调用路径到达的同一分支
- 能发现更多深层 bug,但开销更大
- 代表:AFLGo(定向模糊测试)
覆盖率引导的局限性
- Magic Number 问题:难以通过随机变异跳过
if (x == 0xDEADBEEF)这样的检查 - 校验和问题:CRC、MD5 等校验和会阻断变异
- 复杂数据结构:JSON、XML 等结构化输入难以随机变异
- 解决方案:字典辅助、结构感知变异、符号执行辅助
变异策略详解¶
变异策略是 fuzzer 生成新测试用例的核心。好的变异策略应该在 探索性 和 利用性 之间取得平衡。
常见变异策略
- 按固定规则逐一变异输入的每个字节/比特
- 优点:可重复,适合小输入
- 缺点:输入较大时组合爆炸
- 例子:bit flip、byte flip、arithmetic
- 随机选择多个变异操作并组合
- 优点:探索范围广,能发现意外路径
- 缺点:盲目性较大
- AFL 的 havoc 阶段会随机组合 2~8 次变异
- 使用预定义的 token 列表替换或插入输入
- 优点:能跳过 magic number 检查
- 缺点:需要人工或自动推断字典
- AFL++ 支持从二进制中自动提取字典
- 理解输入的语法结构,只在合法位置变异
- 优点:能绕过解析器的格式检查,深入测试业务逻辑
- 缺点:需要为每种格式编写变异器
- 代表:libprotobuf-mutator(Protocol Buffer 格式)
实际案例¶
CVE-2014-0160 (Heartbleed)
- OpenSSL 的 TLS Heartbeat 扩展存在缓冲区越界读取漏洞
- Fuzzer 可以通过变异 heartbeat 包的
payload_length字段触发 - 覆盖率引导:正常的 heartbeat 包不会触发新路径,但篡改长度字段会进入异常处理分支
- 教训:fuzzer 需要字典辅助 才能生成有效的 TLS 协议消息
ImageTragick (CVE-2016-3714)
- ImageMagick 的图像处理库存在多个漏洞(命令注入、文件读写)
- Fuzzer 通过变异图像文件格式触发
- 覆盖率引导:不同的图像格式(PNG、JPEG、SVG)会触发不同的解析路径
- 教训:结构感知变异 对于格式解析库至关重要
Chrome 渲染引擎漏洞
- Google 使用 libFuzzer + AddressSanitizer 测试 Chrome 的渲染引擎
- 通过编写 harness 函数测试 HTML/CSS/JS 解析器
- 发现了大量内存安全漏洞(越界读写、use-after-free)
- 教训:Sanitizer 组合(ASan + MSan + UBSan)能大幅提高漏洞检出率
Fuzz 测试最佳实践¶
编写高质量的 Fuzz Harness
- 保持 harness 简单:只调用一个或少数几个函数
- 使用 Sanitizer:编译时启用 ASan、MSan、UBSan
- 提供种子语料库:用真实的输入文件作为初始种子
- 限制执行时间:设置超时避免无限循环
- 清理状态:每次执行后释放所有分配的内存
- 避免全局状态:确保每次执行是独立的
提高 Fuzz 效率
- 使用字典:为协议/格式提供关键词字典
- 覆盖率去重:只保存触发新覆盖率的输入
- 并行执行:多实例并行 fuzz,共享种子队列
- 定向变异:针对特定代码路径进行定向 fuzz(如 AFLGo)
- 持续集成:将 fuzz 测试集成到 CI/CD 流程中