模糊测试实战:AFL++ 与 libFuzzer 配置

模糊测试(Fuzzing)是把「找漏洞」从人工审计变成自动化流水线的核心手段:给定一个解析入口和一份种子语料,模糊测试引擎会用变异算法持续生成畸形输入,配合内存错误检测器把崩溃、越界、悬垂指针当场捕获。本文给出 AFL++ 与 libFuzzer 两套引擎的实战配置,覆盖编译插桩、语料最小化、并行调度、CI 集成与崩溃复现的完整链路。

适用场景

  • 文件格式解析器:图片、PDF、压缩包、配置文件、Office 文档。
  • 网络协议解析:自定义二进制协议、MQTT 报文、HTTP 头字段解析。
  • 序列化与反序列化入口:Protobuf、MessagePack、JSON Schema 校验。
  • 长期维护的基础库:编码转换、正则引擎、加解密封装。
  • 上线的第三方依赖,用模糊测试验证其在小概率输入下的稳定性。

前置条件

  • Linux 环境,建议 4 核以上 CPU 与不少于 4GB 可用内存。
  • clang / llvm 14 及以上版本,用于 libFuzzer 与 ASan。
  • AFL++ 4.x,可通过发行版仓库安装或源码编译。
  • 一台隔离的容器或虚拟机,模糊测试会执行任意代码,禁止在生产主机上运行。
  • 至少一份合法的样例输入作为种子语料。

原理说明

模糊测试的效率来自覆盖率反馈:引擎在目标程序中插入探针,记录每个基本块(或边)是否被执行,当一次输入触发了新的执行路径时,该输入会被保留进队列,后续变异以它为起点继续探索。这使模糊测试具备「越跑越懂目标」的自我引导能力,而不是随机盲猜。

  • AFL++ 支持三种插桩方式:编译期插桩(afl-cc 编译器套件,速度最快)、QEMU 模式(无源码)、无插桩模式(仅崩溃探测)。它采用 fork server 机制,避免每次执行都重新加载进程。
  • libFuzzer 把目标编译进同一个进程内反复调用,省去 fork 开销,速度通常比 AFL++ 快数倍,但必须自己编写 harness 函数。
  • Sanitizer 是模糊测试的眼睛:AddressSanitizer 捕获越界与 UAF,UndefinedBehaviorSanitizer 捕获整数溢出与移位异常,两者可同时启用。

实践中的组合策略是:用 libFuzzer 做单点深度挖掘,用 AFL++ 做多核并行与长时间挂机,两者共享同一份语料与字典。

操作步骤

步骤一:安装工具链

sudo apt update
sudo apt install -y clang llvm lld afl++ jq
afl-fuzz -V 2 2>&1 | head -5      # 打印版本,确认可用
clang --version

步骤二:带插桩与 ASan 编译目标

export CC=afl-cc
export CXX=afl-clang-fast++
export AFL_USE_ASAN=1
export AFL_LLVM_LAF_ALL=1        # 展开字符串比较,帮助突破 magic 校验

./configure --disable-shared --enable-static
make -j$(nproc)

关键参数说明:AFL_USE_ASAN=1 让 AFL++ 自动为编译产物链接 ASan 与相应的运行参数,比手动加 -fsanitize=address 更稳妥;AFL_LLVM_LAF_ALL=1 会把 strcmp、memcmp 等比较拆解为逐字节比较,显著提升对含固定魔数格式的渗透能力。

步骤三:准备并最小化语料

mkdir -p in out crashes
cp testdata/*.json in/ 2>/dev/null || cp samples/*.bin in/
afl-cmin -i in -o in_min -- ./target @@

afl-cmin 会剔除对覆盖率没有增量的冗余种子。语料质量比数量重要:每条种子都应能走通解析主流程,夹杂大量无效文件会浪费探索预算。若格式存在固定关键字,额外维护一份字典文件供 -x 使用。

步骤四:启动 AFL++ 并行实例

# 主实例:负责确定性变异;从实例:侧重 havoc 随机变异
AFL_SKIP_CPUFREQ=1 afl-fuzz -i in_min -o out -M main -m none -t 1000+ -- ./target @@
AFL_SKIP_CPUFREQ=1 afl-fuzz -i in_min -o out -S sec1 -m none -t 1000+ -- ./target @@
AFL_SKIP_CPUFREQ=1 afl-fuzz -i in_min -o out -S sec2 -x dict.txt -m none -- ./target @@

参数含义:-M / -S 指定主从角色,多个实例共享同一个 -o 输出目录并自动同步新语料;-m none 取消内存上限(ASan 会占用大量虚拟地址空间,必须放开);-t 1000+ 表示超过 1000ms 的执行视为超时并自动提升阈值;AFL_SKIP_CPUFREQ=1 跳过 CPU 调频检查,适合容器环境。

步骤五:编写 libFuzzer harness

#include <stdint.h>
#include <stddef.h>

extern int parse_buffer(const uint8_t *data, size_t size);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 4 || size > 1 << 20) return 0;   // 过滤明显无意义的输入
    parse_buffer(data, size);                    // 目标解析函数
    return 0;                                     // 返回值必须恒为 0
}
clang -g -O1 -fno-omit-frame-pointer \
  -fsanitize=fuzzer,address,undefined \
  harness.c parser.c -o fuzz_parser

mkdir -p corpus artifacts
./fuzz_parser corpus -max_len=4096 -timeout=10 \
  -jobs=4 -workers=4 -artifact_prefix=./artifacts/

要点:harness 内不要调用 exit(),不要保留跨次调用的全局状态,所有资源在单次调用内申请并释放。-max_len 限制单次输入长度,防止超大输入拖慢迭代;-jobs / -workers 启用多进程并行,各自独立探索后合并语料。

步骤六:Rust 项目快速接入

cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run fuzz_target_1 -- -max_total_time=600 -rss_limit_mb=2048

步骤七:CI 定时任务中挂载短时模糊

#!/usr/bin/env bash
set -u
timeout 900 afl-fuzz -V 600 -i in_min -o out -m none -t 1000+ -- ./target @@ || true
FOUND=$(find out -path '*crashes*' -name 'id:*' | wc -l)
echo "new crashes: $FOUND"
if [ "$FOUND" -gt 0 ]; then
  find out -path '*crashes*' -name 'id:*' -print0 | while IFS= read -r -d '' f; do
    ./target "$f" || echo "reproduced: $f"
  done
  exit 1
fi

步骤八:崩溃去重与最小化复现

export ASAN_OPTIONS=symbolize=1:abort_on_error=1:detect_leaks=0
export UBSAN_OPTIONS=print_stacktrace=1
./target out/default/crashes/id:000000*
afl-tmin -i out/default/crashes/id:000000* -o crash_min.bin -- ./target @@

afl-tmin 会把触发崩溃的输入压缩到最小字节序列,产出可直接提交给开发者的复现样本;配合 ASAN_OPTIONS=symbolize=1 可得到带函数名与行号的调用栈。

配置验证

  • 插桩有效性:afl-showmap -o /dev/null -- ./target in_min/seed1 应输出非零数量的元组,若提示 No instrumentation detected 说明编译未走 afl-cc。
  • 稳定性:afl-fuzz 界面中的 stability 应为 100%。低于 100% 说明目标存在随机性或时间依赖,会导致覆盖率统计失真,应先用固定随机种子排查。
  • 覆盖率增长:libFuzzer 运行时应持续输出 cov: 与 ft: 计数并缓慢上升;若长时间不变,说明语料或字典不足。
  • 执行速度:AFL++ 界面的 exec/s 是核心指标,低于 500 通常意味着 harness 中做了文件 IO 或重复初始化。

常见问题

Q1:AFL++ 提示 No instrumentation detected,如何排查?

按三步排查:一是确认 CC 与 CXX 真的指向 afl-cc 与 afl-clang-fast++,很多构建系统会覆盖这两个变量;二是若目标已用 -fsanitize=address 手工编译,会与 AFL++ 自动注入的 ASan 冲突,应删除手工参数改用 AFL_USE_ASAN=1;三是对二进制目标使用 AFL_DEBUG=1 afl-showmap 查看插桩流程,确认是否因 PIE、静态链接或 strip 导致探针丢失。

Q2:跑了几小时一个崩溃都没有,是目标足够安全吗?

更可能是探索效率不足。优先做四件事:用 afl-cmin 重做语料最小化;补充 -x 字典提供格式关键字;开启 cmplog 模式(用 AFL_LLVM_CMPLOG=1 单独编译一个二进制,再通过 -c 传给 afl-fuzz)以突破复杂条件判断;检查是否正确使用了 AFL_LLVM_LAF_ALL=1。此外要确认 harness 真的走到了深度解析逻辑,而不是在参数校验阶段就提前返回。

Q3:模糊测试能直接在业务服务器上跑吗?

不能。模糊测试的设计前提就是「让目标崩溃」,它会执行任意输入、绕过业务前置校验,还可能因为 ASan 报告触发信号而终止进程。必须放在隔离的容器或虚拟机中,禁用网络出站、以只读方式挂载根文件系统、限制 CPU 与内存配额,并且只挂载脱敏后的语料目录。

总结

模糊测试的投入产出比取决于三件事:插桩是否完整、语料是否精简、运行时长是否足够。落地路径建议为:先用 AFL++ 单实例跑出一批崩溃验证链路可用,再切换到多核并行长时间挂机,同时用 libFuzzer 对关键解析入口做定向深挖;最后把短时模糊测试固化进 CI,让每次提交都自动回归一遍历史崩溃样本,避免同类漏洞反复出现。