在所有自动化安全测试手段里,模糊测试(Fuzzing)是**唯一能"发现未知漏洞"而不是"匹配已知模式"**的一种。SAST 靠规则匹配代码模式,DAST 靠预置 payload 打接口,它们只能找到你已经知道的漏洞类型;而 fuzzer 通过海量随机但智能的输入去撞击程序,靠"崩溃"这个客观信号发现开发者自己都不知道的缺陷。本文讲清 fuzzing 的分类、覆盖率引导机制、结构化输入、CI 集成,以及它如何与 SAST/DAST/SCA 组成完整的自动化测试矩阵。
一、模糊测试的原理与分类
模糊测试的核心循环极其简单:生成输入 → 喂给目标 → 观察是否异常 → 根据反馈调整下一轮输入。难的是"如何生成有效的输入"和"如何判断异常"。
1.1 按对目标的了解程度分类
| 类型 | 是否看代码 | 是否有反馈 | 效率 | 典型工具 |
|---|---|---|---|---|
| 黑盒(Black-box) | 否 | 无 | 低 | radamsa、zzuf |
| 白盒(White-box) | 是 | 符号执行/约束求解 | 高但难扩展 | KLEE、angr |
| 灰盒(Grey-box) | 是(插桩) | 覆盖率反馈 | 最高性价比 | AFL++、libFuzzer、honggfuzz |
灰盒是工业界主流:它用编译期插桩(instrumentation)获得运行时覆盖率,把"能走到新代码路径"的输入保留下来作为下一轮变异的种子。这种"覆盖率引导"让 fuzzer 不再是盲目的随机,而是有方向地探索程序状态空间。
1.2 生成式 vs 变异式
- 变异式(Mutation-based):从已有种子出发,随机翻转字节、插入/删除块、拼接。AFL++ 默认属于这一类。
- 生成式(Generation-based):根据输入格式的语法(grammar)生成结构合法的输入。适合解析器、协议实现。
实践中两者常结合:先用 grammar 生成一批合法种子,再用变异式 fuzzer 在种子周围探索。
1.3 判断异常的三种手段
fuzzer 需要一个"可观测的失败信号",常见有三:
- 进程崩溃/信号:
SIGSEGV、SIGABRT最直接。 - Sanitizer:编译时插入检测,把"未崩溃但已越界"的内存错误变成硬失败。这是现代 fuzzing 的关键。
- AddressSanitizer(ASan):堆/栈越界、UAF
- UndefinedBehaviorSanitizer(UBSan):整数溢出、空指针解引用
- MemorySanitizer(MSan):未初始化读取
- ThreadSanitizer(TSan):数据竞争
- 断言与超时:
assert失败、超时(可能死锁或无限循环)。
# 用 ASan + UBSan 编译目标,fuzzing 时才能抓到内存错误
clang -fsanitize=address,undefined -fno-sanitize-recover=all \
-g -O1 -o target target.c
注意:-fno-sanitize-recover=all 让 UBSan 命中即 abort,否则它只打印警告、程序继续跑,fuzzer 感知不到。
1.4 覆盖率的几种口径
“覆盖率"这个词在不同工具里含义不同,选错口径会让 fuzzer 的引导方向跑偏:
| 口径 | 定义 | 对 fuzzing 的价值 |
|---|---|---|
| 函数覆盖 | 该函数是否被调用 | 粒度太粗,无法引导 |
| 行覆盖 | 该行是否执行 | 忽略分支内差异 |
| 分支覆盖 | 每个 if 的 true/false 是否都走过 | 较细,但漏掉顺序 |
| 边覆盖 | 基本块之间的跳转是否发生 | AFL 默认,性价比最高 |
| 路径覆盖 | 完整执行路径的组合 | 状态爆炸,无法穷举 |
AFL 系列选择边覆盖,是因为它既能区分"同一函数内不同分支”,又不像路径覆盖那样指数爆炸。理解这一点,就能明白为什么 -p explore 与 -p exploit 的调度差异有意义:前者倾向探索未命中的边,后者倾向在已命中的边附近深挖。
二、覆盖率引导 fuzzing
2.1 插桩与边覆盖
AFL 系列使用边覆盖(edge coverage):在编译期为每个基本块插桩,运行时用一张共享内存位图记录"从块 A 到块 B 的跳转是否发生过"。位图用随机索引映射,命中即置位。
# 用 afl-clang-fast 编译,自带插桩
afl-clang-fast -fsanitize=address -g -O1 -o target target.c
# 准备种子目录(放几个合法的输入样本)
mkdir in && cp samples/* in/
# 启动 fuzzing
afl-fuzz -i in -o out -- ./target @@
AFL++ 相比原版的关键增强:
- 多种调度策略(
-p fast/explore/exploit),不同策略在探索新路径与深挖已知路径之间权衡。 - CMplog:记录比较指令的操作数,把
memcmp/strcmp变成可求解的"输入提示",能显著加速魔数(magic bytes)的破解。 - CmpLog + LAF-Intel:把大整数比较拆成逐字节比较,让 fuzzer 能逐字节逼近正确答案。
# 开启 CmpLog,破解 "MAGIC" 这类校验
afl-fuzz -i in -o out -c ./target_cmplog -- ./target @@
2.2 libFuzzer:进程内 fuzzing
libFuzzer 与目标链接在同一个进程里,没有 fork 开销,速度可达每秒数万次执行。适合库函数、解析器等纯函数目标。
// fuzz_parse.c —— libFuzzer 入口
#include <stdint.h>
#include <stddef.h>
#include "parser.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// 目标必须是"纯函数式"的:同样的输入产生同样的行为
parse_message(data, size);
return 0;
}
clang -fsanitize=fuzzer,address -g -O1 -o fuzz_parse fuzz_parse.c parser.c
./fuzz_parse corpus/ -max_len=4096 -jobs=8 -workers=8
几个实用参数:
| 参数 | 作用 |
|---|---|
-max_len | 单次输入上限,防止 fuzzer 把时间浪费在超长输入上 |
-timeout | 单次执行超时(秒),抓死循环 |
-rss_limit_mb | 内存上限,抓内存膨胀 |
-jobs/-workers | 并行任务数 |
-dict=keywords.dict | 提供关键 token,加速语法探索 |
字典(dictionary)对格式解析类目标效果显著:
# keywords.dict —— 每行一个关键 token
"GET"
"POST"
"HTTP/1.1"
"Content-Length:"
"\r\n\r\n"
2.3 崩溃去重与分诊
fuzzer 几小时内就能产出成百上千个崩溃,其中大多数是同一个根因。去重(deduplication)按"崩溃时的调用栈哈希"或"覆盖边集合"归并:
# AFL++ 自带的崩溃分类
afl-cmin -i out/default/crashes -o crashes_min -- ./target @@
# 用栈回溯哈希去重(示例:gdb 批量提取栈)
for f in out/default/crashes/id*; do
gdb -batch -ex 'run' -ex 'bt 5' --args ./target "$f" 2>/dev/null | md5sum
done | sort | uniq -c | sort -rn
分诊(triage)要把每个唯一崩溃映射到可利用性:ASan 报的堆越界写通常比空指针解引用严重得多。这一步决定了哪些崩溃值得开 CVE、哪些只是健壮性问题。
2.4 提升执行速度的几个手段
fuzzer 的产出与"总执行次数"近似成正比,因此每一处速度优化都会复利式放大效果:
- 持久模式(persistent mode):AFL++ 的
__AFL_LOOP让进程在单次启动内执行上千次输入,省掉 fork 开销,速度可提升 10 倍以上。 - 减少 IO:把目标改成从内存读取输入,避免每次写文件、开进程。
- 裁剪无关键:若目标初始化昂贵(加载大字典、建索引),把它移到循环外只做一次。
- 并行与亲和性:
-M/-S起主从实例,把进程绑定到不同 CPU 核,避免上下文切换。 - 关闭无关功能:日志、遥测、断言之外的调试输出全部关掉。
// 持久模式:一次启动,多次执行
__AFL_INIT();
while (__AFL_LOOP(10000)) {
size_t len = __AFL_FUZZ_TESTCASE_LEN;
parse_message(__AFL_FUZZ_TESTCASE_BUF, len);
}
三、结构化与协议 fuzzing
随机变异对"格式严格"的目标几乎无效——改一个字节就让解析器提前返回,覆盖率上不去。解决方案是让 fuzzer 理解输入结构。
3.1 结构化变异
- libprotobuf-mutator:用 Protocol Buffers 描述输入结构,fuzzer 在结构层面变异,天然产生合法消息。适合已有 protobuf 定义的协议。
- 自定义 mutator(AFL++
AFL_CUSTOM_MUTATOR):写一个afl_custom_fuzz函数,在变异前先做校验和修复。 - 语法 fuzzing(grammar-based):用 Nautilus、Grammarinator 这类工具,基于 EBNF 语法生成输入。
# AFL++ 自定义 mutator 骨架
import struct
def init(seed):
pass
def fuzz(buf, add_buf, max_size):
# 保证头部长度字段与实际长度一致,否则解析器直接拒绝
if len(buf) < 4:
return buf
return struct.pack('<I', len(buf) - 4) + buf[4:]
3.2 状态化协议 fuzzing
HTTP/2、TLS、MQTT 这类有状态的协议,单条报文合法不代表序列合法。需要状态机感知的 fuzzing:
- 先录一段正常的交互序列作为种子;
- fuzzer 在序列层面变异(重排、复制、丢弃某步);
- 用协议库(如 h2、Scapy)构造报文,避免手工拼字节。
这类测试常与 DAST 互补:DAST 打的是"外部接口",协议 fuzzing 打的是"协议实现本身的健壮性"。
3.3 面向 API 与文件格式的目标选择
优先级排序建议:
| 优先级 | 目标类型 | 理由 |
|---|---|---|
| 高 | 解析不可信输入的组件 | 攻击面直接暴露 |
| 高 | 内存不安全的语言(C/C++) | 崩溃可直接利用 |
| 中 | 反序列化、压缩、图片解码 | 历史漏洞高发区 |
| 中 | 协议实现(TLS/HTTP2/DNS) | 影响面广 |
| 低 | 纯逻辑、无外部输入 | 收益低 |
四、CI 集成与持续 fuzzing
一次性跑几小时远远不够——fuzzer 的价值随时间累积。正确姿势是把 fuzzing 变成常驻任务。
4.1 持续 fuzzing 的形态
| 形态 | 时长 | 用途 |
|---|---|---|
| PR 门禁 | 1~5 分钟 | 只跑变更相关的 harness,防止回归 |
| 每日构建 | 1~4 小时 | 覆盖主要目标,累积语料 |
| 常驻集群 | 7×24 | OSS-Fuzz 模式,深挖长尾 |
CI 中的短时 fuzzing 有一个关键技巧:把线上累积的语料(corpus)作为种子。语料是团队最宝贵的资产,它记录了所有被探索过的路径;语料越大,短时 fuzzing 的起点越高。
# GitHub Actions:PR 门禁跑 3 分钟
- name: Fuzz (short)
run: |
./fuzz_parse corpus/ \
-max_total_time=180 \
-dict=keywords.dict \
-artifact_prefix=artifacts/
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: artifacts/
4.2 语料库管理
- 版本化存储:语料应提交到仓库或用对象存储持久化,不能每次 CI 从零开始。
- 最小化:用
afl-cmin/-merge=1定期裁剪,去掉覆盖重复的样本,减小体积、加快加载。 - 合并:
./fuzz_parse corpus_new/ -merge=1 corpus_old/把新语料并入主库。 - 权限:语料可能包含崩溃样本(恶意输入),存储与分发要注意隔离。
4.3 OSS-Fuzz 模式
Google 的 OSS-Fuzz 为开源项目提供免费的持续 fuzzing 基础设施,核心是三条:
- 每个项目提供
build.sh,编译出带 sanitizer 的 fuzzer; - ClusterFuzz 负责调度、去重、最小化、bisect 出引入崩溃的 commit;
- 发现的问题自动开 issue 并通知维护者。
即使不自建集群,也可以借鉴它的思路:用 ClusterFuzzLite 在自己的 CI 里跑,得到自动去重与回归检测。
4.4 常见 harness 反模式
harness 写得不好,fuzzer 再强也白搭。几个高频错误:
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 依赖全局可变状态 | 输入与行为不对应,崩溃无法复现 | 每次调用前重置状态 |
| 把网络/文件 IO 写进 harness | 速度慢、不稳定 | 用内存 buffer 替代真实 IO |
| 过早做前置校验 | 大量输入在入口被拒,覆盖率上不去 | 把校验也纳入被 fuzz 的范围 |
| 吞掉异常 | fuzzer 看不到失败信号 | 让异常冒泡为 abort |
| 输入长度不设上限 | 时间浪费在超长输入 | 设 -max_len |
| 目标非确定性(随机数/时间) | 崩溃无法复现 | 固定种子、注入可控时钟 |
一个可复现的崩溃是资产,一个"偶发"的崩溃是负担——fuzzing 的一切工程化努力,最终都是为了把随机探索变成可复现、可修复的缺陷清单。
五、与 SAST/DAST/SCA 的协同
fuzzing 不是替代品,而是自动化测试矩阵里的一块拼图。
| 手段 | 看什么 | 何时跑 | 擅长发现 |
|---|---|---|---|
| SAST | 源码 | 提交/PR | 代码模式类缺陷(注入、硬编码密钥) |
| DAST | 运行中的接口 | 部署后 | 配置错误、可外部触发的漏洞 |
| SCA | 依赖清单 | 提交/每日 | 已知 CVE 的第三方组件 |
| Fuzzing | 运行时行为 | 持续 | 未知的内存/逻辑崩溃 |
| IAST | 运行时插桩 | 测试期 | 结合流量与代码的精确缺陷 |
协同的关键在于共享输入:
- SAST 报告的可疑解析函数,直接写成 fuzzing harness 的目标。
- fuzzing 发现的崩溃,回填为 DAST/回归测试的用例。
- 详见 SAST/DAST/SCA 实践 与 安全编码审计 中的缺陷分类体系。
在 DevSecOps 流水线里,fuzzing 属于"较慢但较深"的一层,通常放在 nightly 或独立流水线,而不是阻塞每次提交,见 DevSecOps 流水线 。
六、度量与落地路径
6.1 关键指标
| 指标 | 含义 | 健康值参考 |
|---|---|---|
| 执行速度(exec/s) | 每秒执行次数 | 进程内 >10k,fork 模式 >1k |
| 覆盖率增长 | 新增边/基本块 | 长期应趋于平台期 |
| 崩溃唯一率 | 去重后崩溃数 | 越接近原始崩溃数越说明目标健康 |
| 回归率 | 已修复问题重现 | 必须为 0 |
| 平均修复时长 | 从发现到修复 | 越短越好 |
6.2 分阶段落地
- 选一个高价值目标:解析不可信输入、用 C/C++ 写的组件。
- 写第一个 harness:目标是"纯函数",同样的输入产生同样的行为;避免依赖全局状态与网络。
- 本地跑通:用 ASan 编译,确认能产出崩溃(可先用已知漏洞验证 harness 有效)。
- 接进 CI:PR 跑 3 分钟,nightly 跑数小时,语料持久化。
- 扩大范围:逐步把更多解析器、协议实现纳入。
一个反直觉的经验:harness 的质量比 fuzzer 的配置更重要。一个能直接进入解析核心、避免无关前置校验的 harness,比调参带来的收益大得多。
小结
模糊测试是"用机器的耐心替代人的想象"——它不假设攻击者会怎么打,而是穷举程序能走到的每一条路。落地要点可以浓缩成四句:用 sanitizer 让错误可见,用覆盖率引导让探索有方向,用结构化变异让输入有意义,用持续运行让价值累积。当它和 SAST/DAST/SCA 一起进入流水线,自动化安全测试才真正形成闭环。更多 fuzzing 工具链与实战可延伸阅读 模糊测试专题 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。