dev.to #ai新闻
案例研究:免费模型重写配置解析器,差分模糊测试门在90秒内发现崩溃
一篇案例研究展示了差分模糊测试在验证AI生成代码中的价值。一个免费模型重写了C++配置解析器,虽然通过了所有单元测试,但差分模糊测试在90秒内发现了堆缓冲区溢出和两个语义分歧。该方法将原解析器作为参考,通过比较新旧实现的输出来捕捉测试遗漏的边界情况。
该补丁通过了全部 14 项单元测试。差分模糊测试门禁在 90 秒内将其驳回:一个堆缓冲区溢出,两处语义分歧。单元测试编码的是意图,模糊测试编码的是行为。这是一个关于小型 C++ 配置解析器、免费模型重写以及那道拦截了代码审查漏网之鱼的门禁的故事。
背景:一个谁都不想碰的 450 行解析器
minicfg 是一个 C++17 风格的 INI 配置解析器。它位于一个构建辅助工具内部,负责解析 [section] 标题、key=value 行、# 注释,以及带有 \n、\t 和 \\ 转义序列的带引号值。大约 450 行,手写,单遍扫描。
该解析器有 14 项单元测试,全部通过。行覆盖率报告为 100%。没人真信这个覆盖率数字有多大意义,但也没人能证明它是错的。
分词器是自然生长出来的。每添加一个功能就多一个特例。结果能跑,但可读性很差。
目标:在不破坏任何功能的前提下添加 ${VAR} 展开
功能需求很简单:在值内部展开 ${HOME} 和 ${PATH}。约束条件则更严格:重写后的代码必须通过现有的 14 项测试,外加一个新的差分模糊测试门禁。
我通过 MonkeyCode 的免费模型访问生成了候选补丁。声明:本文是 MonkeyCode 产品推广活动的一部分。模糊测试器没读这份声明,也不在乎。
模型的方案很合理。将手写分词器替换为基于 std::string_view 的扫描器,然后添加一个独立的 expand_env() 遍历。补丁很干净。14 项测试一次全部通过。这就是陷阱所在。
实现:差分模糊测试门禁
差分门禁需要同一契约的两个实现。原始解析器是参照实现,补丁后的解析器是候选实现。向两者输入相同的字节,然后比较解析树。
步骤 1:将两个解析器构建到同一个二进制文件中
两个版本都编译进同一个模糊测试目标。每个版本都暴露一个 parse() 函数;测试框架调用两者并比较结果。
步骤 2:编写测试框架
// diff_fuzz.cpp — minicfg v1 与 v2 的差分测试框架
#include <cstddef>
#include <cstdint>
#include <map>
#include <string>
struct ParseResult {
bool ok;
std::map<std::string, std::map<std::string, std::string>> sections;
};
ParseResult parse_v1(const std::string& input); // 参照实现
ParseResult parse_v2(const std::string& input); // 补丁后实现
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
std::string input(reinterpret_cast<const char*>(data), size);
ParseResult a = parse_v1(input);
ParseResult b = parse_v2(input);
if (a.ok != b.ok || (a.ok && a.sections != b.sections)) {
// 出现分歧:将输入写入文件供后续检查,然后停止。
__builtin_trap();
}
return 0;
}比较是严格的。一个解析器接受而另一个拒绝,或者解析出的 sections 不同——测试框架就会触发陷阱。libFuzzer 将违规输入写入文件并报告崩溃。
步骤 3:运行门禁
#!/usr/bin/env bash
# fuzz_gate.sh — minicfg 重写的差分模糊测试门禁
set -euo pipefail
BUDGET_SECONDS="${1:-120}"
clang++ -std=c++17 -g -O1 \
-fsanitize=fuzzer,address,undefined \
diff_fuzz.cpp src_v1/parser.cpp src_v2/parser.cpp \
-o build/diff_fuzz
mkdir -p corpus
# 用真实配置文件加上已知边界标记作为种子。
printf '[build]\ncc = "clang++"\n' > corpus/real.cfg
printf 'key = "a#b"\n' > corpus/hash_in_quote.cfg
printf 'key = "abc\\\n' > corpus/trailing_backslash.cfg
./build/diff_fuzz -max_total_time="$BUDGET_SECONDS" corpus/该门禁作为 CI 任务运行在 MonkeyCode 的免费服务器选项上。120 秒的预算不会阻塞本地机器,且结论是可复现的:相同的种子语料库、相同的标志、相同的结果。
结果:90 秒内发现三个问题
模糊测试器在大约 41,000 次迭代时命中第一个崩溃,大约在 90 秒时。
发现 1:unescape() 中的堆缓冲区溢出
输入是 key = "abc\ —— 一个以反斜杠结尾的带引号值,然后是文件结束符。补丁后扫描器的 unescape() 读取了 s[i + 1],却没有检查 i + 1 是否在边界内。AddressSanitizer 立即中止。
从来没有单元测试生成过结尾反斜杠。参照解析器能处理这种情况,因为它的分词器在消费下一个字符之前会先检查。模型的改写把这个检查优化掉了。
发现 2:带引号值内部的 #
修复崩溃后,门禁发现了一处语义分歧。输入:key = "a#b"。参照解析器返回 a#b。补丁后解析器返回 a。
模型的分词器在任何位置都将 # 视为注释开始,即使在引号内也是如此。原始实现只在行首才这样做。单元测试覆盖了注释和引号,但从未覆盖两者的交集。
发现 3:CRLF 值
输入:key = "a\r\n"。参照解析器去掉了 \r。补丁后解析器保留了它。在测试运行的 Linux 上,这个差异不可见。但在 Windows 配置文件上,它会破坏每一个值。
${VAR} 展开功能本身工作正常。问题出在分词器的重写上。
经验教训
- 差分测试预言机不需要比模型更聪明,只需要比模型更老。原始解析器成了规范。模型针对它能看到的测试进行优化;门禁则强制执行它看不到的行为。
- 覆盖率并不能代表边界情况的行为。100% 的行覆盖率对尾部反斜杠的问题毫无说明。模糊测试器通过生成字节发现了它,而不是通过阅读代码行。
- 模糊测试的预算很便宜。90 秒的实际运行时间发现了一个内存安全漏洞和两个语义回归。修复只需要一个边界检查和对两个条件的调整。
- 免费模型访问对生成候选补丁很有用。门禁才是让这些补丁可以合并的关键。模型产出了一个干净、可读的 diff。门禁决定这个 diff 是否安全。
谁不应该使用这种方法
差分模糊测试需要一个参考实现。如果你正在重写一个解析器,而旧版本已经被删除,你就没有预言机了。先构建一个特征测试套件,然后再删除旧代码。
对于简单的解析器,门禁也属于过度设计。如果你的格式能塞进 50 行代码里,维护两套构建的成本超过了风险。
如果你无法在 CI 中运行 sanitizer,门禁的内存安全部分就是盲的。ASan 正是捕获崩溃的那个组件。
结语
三个复现用例连同门禁的判定结果一起反馈给了模型。第二次尝试通过了:恢复了一个边界检查,两个分词器条件与参考实现对齐。
// 修复比发现还小:
if (i + 1 < s.size() && s[i] == '\\') {
value.push_back(unescape(s[++i]));
}总成本:一次模糊测试运行,三个小修复,零生产事故。
下次当模型递给你一个干净的 diff 时,问问你的门禁会怎么说。用你真实的配置文件去播种同一个测试框架,让模糊测试器来做代码审查。