模糊测试与安全测试自动化

详解模糊测试(Fuzzing)的原理与工程化落地:覆盖率引导 fuzzing、AFL++ 与 libFuzzer 插桩、结构化与协议 fuzzing、语料库管理、CI 持续 fuzzing,以及与 SAST/DAST/SCA 的协同落地策略。

在所有自动化安全测试手段里,模糊测试(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 需要一个"可观测的失败信号",常见有三:

  1. 进程崩溃/信号:SIGSEGV、SIGABRT 最直接。
  2. Sanitizer:编译时插入检测,把"未崩溃但已越界"的内存错误变成硬失败。这是现代 fuzzing 的关键。
    • AddressSanitizer(ASan):堆/栈越界、UAF
    • UndefinedBehaviorSanitizer(UBSan):整数溢出、空指针解引用
    • MemorySanitizer(MSan):未初始化读取
    • ThreadSanitizer(TSan):数据竞争
  3. 断言与超时: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×24OSS-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 基础设施,核心是三条:

  1. 每个项目提供 build.sh,编译出带 sanitizer 的 fuzzer;
  2. ClusterFuzz 负责调度、去重、最小化、bisect 出引入崩溃的 commit;
  3. 发现的问题自动开 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 分阶段落地

  1. 选一个高价值目标:解析不可信输入、用 C/C++ 写的组件。
  2. 写第一个 harness:目标是"纯函数",同样的输入产生同样的行为;避免依赖全局状态与网络。
  3. 本地跑通:用 ASan 编译,确认能产出崩溃(可先用已知漏洞验证 harness 有效)。
  4. 接进 CI:PR 跑 3 分钟,nightly 跑数小时,语料持久化。
  5. 扩大范围:逐步把更多解析器、协议实现纳入。

一个反直觉的经验:harness 的质量比 fuzzer 的配置更重要。一个能直接进入解析核心、避免无关前置校验的 harness,比调参带来的收益大得多。

小结

模糊测试是"用机器的耐心替代人的想象"——它不假设攻击者会怎么打,而是穷举程序能走到的每一条路。落地要点可以浓缩成四句:用 sanitizer 让错误可见,用覆盖率引导让探索有方向,用结构化变异让输入有意义,用持续运行让价值累积。当它和 SAST/DAST/SCA 一起进入流水线,自动化安全测试才真正形成闭环。更多 fuzzing 工具链与实战可延伸阅读 模糊测试专题 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「安全」更多文章

  1. SOAR 安全编排自动化与响应
  2. 内部威胁与 UEBA 用户行为分析
  3. PKI 与 TLS 证书生命周期管理