模糊测试(Fuzzing)入门:libFuzzer与AFL在C语言中的应用

说实话,很多C语言开发者对测试的理解还停留在「写几个assert,跑一遍就完事」。但真正让我意识到测试不够的,是一次线上崩溃——一个解析用户输入的函数,在特定畸形数据下直接段错误。从那以后,我开始认真研究模糊测试。

模糊测试,说白了就是给程序喂各种「奇怪」的数据,看它会不会崩溃。你想想看,人工写的测试用例再多,也覆盖不了所有边界情况。而模糊测试工具能自动生成成千上万的变异输入,帮你找到那些隐藏极深的bug。

今天我就带你入门两个最主流的C语言模糊测试工具:libFuzzerAFL。它们各有千秋,但核心思路是一样的——用随机数据轰炸你的代码。

模糊测试的核心逻辑

先别急着上工具,我们得理解模糊测试到底在干什么。它的工作流程其实很简单:

  1. 生成输入:从一个初始种子(seed)出发,不断变异产生新数据
  2. 执行目标:把数据喂给被测函数
  3. 监控异常:如果程序崩溃、断言失败或超时,就记录下这个输入
  4. 反馈优化:如果新输入触发了新的代码路径,就把它加入种子池

嗯,这里有个关键点——覆盖率引导。好的模糊测试工具不是瞎蒙,它会根据代码覆盖率来调整变异策略。说白了,就是「哪条路没走过,我就往哪走」。

初始种子输入 变异生成新数据 执行被测函数 崩溃? 记录崩溃输入 检查覆盖率 新路径? 加入种子池 模糊测试核心流程

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 每秒执行次数 越高越好,但受被测函数复杂度影响
小技巧: 如果发现 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:唯一挂起数
注意: AFL 默认会占用大量 CPU。如果你在开发机上跑,建议用 -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 人工测试很难发现,但模糊测试几分钟就揪出来了。

核心要点: 模糊测试不是替代单元测试,而是补充。单元测试验证「正确性」,模糊测试验证「健壮性」。两者结合,才能写出真正可靠的 C 代码。

避坑指南

我曾经踩过几个坑,分享给你:

  • 种子质量很重要:空种子或太简单的种子,会导致覆盖率上不去。我一般会准备 5-10 个有代表性的合法输入。
  • 不要忽略超时:有些输入会导致死循环。设置合理的超时(比如 1 秒),避免测试卡死。
  • 并行跑更高效:libFuzzer 支持 -jobs=4,AFL 支持 -M/-S 主从模式。多核机器上一定要用起来。
  • 回归测试:每次发现的崩溃输入,都保存下来加入回归测试集。我吃过亏——修了一个 bug,过两个版本又复现了。

模糊测试入门其实不难,难的是坚持跑、持续改进。你想想看,每次跑出新的崩溃,都意味着你的代码少了一个潜在的线上事故。这个投入,绝对值。


公众号:蓝海资料掘金营,微信deep3321