模糊测试(Fuzzing)入门:libFuzzer与AFL在C语言中的应用
说实话,很多C语言开发者对测试的理解还停留在「写几个assert,跑一遍就完事」。但真正让我意识到测试不够的,是一次线上崩溃——一个解析用户输入的函数,在特定畸形数据下直接段错误。从那以后,我开始认真研究模糊测试。
模糊测试,说白了就是给程序喂各种「奇怪」的数据,看它会不会崩溃。你想想看,人工写的测试用例再多,也覆盖不了所有边界情况。而模糊测试工具能自动生成成千上万的变异输入,帮你找到那些隐藏极深的bug。
今天我就带你入门两个最主流的C语言模糊测试工具:libFuzzer 和 AFL。它们各有千秋,但核心思路是一样的——用随机数据轰炸你的代码。
模糊测试的核心逻辑
先别急着上工具,我们得理解模糊测试到底在干什么。它的工作流程其实很简单:
- 生成输入:从一个初始种子(seed)出发,不断变异产生新数据
- 执行目标:把数据喂给被测函数
- 监控异常:如果程序崩溃、断言失败或超时,就记录下这个输入
- 反馈优化:如果新输入触发了新的代码路径,就把它加入种子池
嗯,这里有个关键点——覆盖率引导。好的模糊测试工具不是瞎蒙,它会根据代码覆盖率来调整变异策略。说白了,就是「哪条路没走过,我就往哪走」。
libFuzzer:轻量级进程内模糊测试
libFuzzer 是 LLVM 项目的一部分,跟 clang 编译器深度集成。我个人特别喜欢它,因为它不需要外部进程通信,直接链接到你的测试代码里,效率非常高。
安装与配置
你需要一个支持 libFuzzer 的 clang 编译器(版本 6.0 以上)。安装很简单:
# Ubuntu/Debian
sudo apt install clang lld
# macOS (Homebrew)
brew install llvm
编写第一个模糊测试目标
假设我们要测试一个解析 CSV 行的函数。先写一个简单的被测函数:
// csv_parser.c
#include <string.h>
#include <stdlib.h>
int parse_csv_line(const char *line, int *values, int max_values) {
if (!line || !values) return -1;
int count = 0;
const char *p = line;
char *end;
while (*p && count < max_values) {
// 跳过空白
while (*p == ' ') p++;
if (*p == '\0') break;
// 解析整数
values[count] = strtol(p, &end, 10);
if (end == p) return -1; // 解析失败
p = end;
count++;
// 跳过逗号
if (*p == ',') p++;
}
return count;
}
然后写模糊测试入口:
// fuzz_target.c
#include <stdint.h>
#include <stddef.h>
// 声明被测函数
int parse_csv_line(const char *line, int *values, int max_values);
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// libFuzzer 会反复调用这个函数,每次传入不同的数据
// 构造一个以 null 结尾的字符串
char *input = malloc(size + 1);
if (!input) return 0;
memcpy(input, data, size);
input[size] = '\0';
// 调用被测函数
int values[10];
parse_csv_line(input, values, 10);
free(input);
return 0; // 非零返回值表示错误
}
编译命令:
clang -g -fsanitize=address,fuzzer csv_parser.c fuzz_target.c -o fuzz_csv
这里我加了 -fsanitize=address,也就是 AddressSanitizer。它能检测内存越界、use-after-free 等问题。我在项目中遇到过好几次,ASan 配合 libFuzzer 简直是黄金搭档。
运行与观察
./fuzz_csv
你会看到类似这样的输出:
INFO: Running with entropic power schedule (0xFF, 100)
INFO: Seed: 12345678
INFO: Loaded 1 modules (5 inline 8-bit counters): 5 [0x...]
INFO: Loaded 1 PC tables (5 PCs): 5 [0x...]
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
#2 pulse cov: 3 ft: 4 corp: 1/1b exec/s: 0 rss: 28Mb
#5 pulse cov: 4 ft: 5 corp: 2/3b exec/s: 0 rss: 28Mb
#1048576 pulse cov: 5 ft: 6 corp: 3/5b exec/s: 1048576 rss: 32Mb
关键指标解读:
| 字段 | 含义 | 我的经验 |
|---|---|---|
| cov | 覆盖的代码块数 | 这个数字增长慢是正常的,说明代码路径有限 |
| ft | 触发的特性数(更细粒度) | 比 cov 更敏感,我一般优先看这个 |
| corp | 种子池大小/总字节数 | 如果一直增长,说明不断发现新路径 |
| exec/s | 每秒执行次数 | 越高越好,但受被测函数复杂度影响 |
-timeout=5 设置超时,避免被卡住。
AFL:老牌模糊测试工具
AFL(American Fuzzy Lop)是另一个经典工具。跟 libFuzzer 不同,AFL 是外挂式的——它启动被测程序,通过管道或文件传递输入,然后监控进程退出状态。
安装 AFL
git clone https://github.com/google/AFL.git
cd AFL
make
sudo make install
编写 AFL 测试目标
AFL 的测试目标跟普通程序一样,从 stdin 读取输入:
// afl_target.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int parse_csv_line(const char *line, int *values, int max_values);
int main() {
char buf[4096];
// AFL 会通过 stdin 不断喂数据
while (fgets(buf, sizeof(buf), stdin)) {
int values[10];
parse_csv_line(buf, values, 10);
}
return 0;
}
编译时要用 AFL 的编译器:
afl-gcc csv_parser.c afl_target.c -o afl_csv
准备种子输入
种子文件很重要。我建议放几个典型的合法输入:
mkdir seeds
echo "1,2,3" > seeds/seed1.txt
echo "42" > seeds/seed2.txt
echo " 10 , 20 , 30 " > seeds/seed3.txt
运行 AFL
afl-fuzz -i seeds -o findings ./afl_csv
AFL 会启动一个交互界面,显示:
- cycles done:模糊测试轮数
- total paths:发现的唯一路径数
- uniq crashes:唯一崩溃数
- uniq hangs:唯一挂起数
-t 50 限制每个用例的执行时间(毫秒),避免影响其他工作。
libFuzzer vs AFL:怎么选?
我两个都用过,说说我的感受:
| 维度 | libFuzzer | AFL |
|---|---|---|
| 集成难度 | 低,只需写一个函数 | 中,需要编译插桩 + 处理 stdin |
| 执行速度 | 极快(进程内) | 较快(进程间通信有开销) |
| 覆盖率反馈 | 基于 LLVM SanitizerCoverage | 基于编译插桩 |
| 内存检测 | 原生支持 ASan | 需额外配置 ASan |
| 适用场景 | 库函数、API 测试 | 完整程序、命令行工具 |
我个人习惯是:测试库函数用 libFuzzer,测试完整程序用 AFL。但如果你刚开始接触模糊测试,我建议先从 libFuzzer 入手——它配置简单,反馈快,能让你快速看到效果。
实战:发现一个真实 bug
让我给你看个真实案例。有一次我在测试一个 JSON 解析器,写了这样的模糊测试目标:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
char *input = malloc(size + 1);
memcpy(input, data, size);
input[size] = '\0';
JsonValue *val = json_parse(input);
if (val) {
json_free(val);
}
free(input);
return 0;
}
跑了大概 10 分钟,libFuzzer 就报了一个 crash:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1 at 0x... thread T0
#0 in json_parse_string json_parser.c:120
#1 in LLVMFuzzerTestOneInput fuzz_target.c:15
原来是解析转义字符 \u 时,没有检查缓冲区边界。输入 "\u" 就直接越界读取了。这种 bug 人工测试很难发现,但模糊测试几分钟就揪出来了。
避坑指南
我曾经踩过几个坑,分享给你:
- 种子质量很重要:空种子或太简单的种子,会导致覆盖率上不去。我一般会准备 5-10 个有代表性的合法输入。
- 不要忽略超时:有些输入会导致死循环。设置合理的超时(比如 1 秒),避免测试卡死。
- 并行跑更高效:libFuzzer 支持
-jobs=4,AFL 支持-M/-S主从模式。多核机器上一定要用起来。 - 回归测试:每次发现的崩溃输入,都保存下来加入回归测试集。我吃过亏——修了一个 bug,过两个版本又复现了。
模糊测试入门其实不难,难的是坚持跑、持续改进。你想想看,每次跑出新的崩溃,都意味着你的代码少了一个潜在的线上事故。这个投入,绝对值。
公众号:蓝海资料掘金营,微信deep3321