免费模型重写 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() 处理步骤。diff 很干净。14 个测试第一次就全部通过了。这就是陷阱。
实现:差分模糊门
差分门需要同一个契约的两个实现。原版解析器是参考实现。打补丁的解析器是候选实现向两者输入相同的字节,比较解析树。
步骤 1:将两个解析器编译进一个可执行文件
两个版本编译进同一个 fuzz target。每个都暴露一个 parse() 函数;harness 调用两者并比较结果。
步骤 2:编写 harness
// diff_fuzz.cpp — differential harness for minicfg v1 vs 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); // reference
ParseResult parse_v2(const std::string& input); // patched
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)) {
// Divergence: write the input for later inspection, then stop.
__builtin_trap();
}
return 0;
}
比较是严格的。一个解析器接受另一个拒绝,或者解析出的 sections 不一致——harness 就触发陷阱。libFuzzer 将有问题的输入写入文件并报告崩溃。
#!/usr/bin/env bash
# fuzz_gate.sh — differential fuzz gate for the minicfg rewrite
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
# Seed with real config files plus known edge tokens.
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\——一个以反斜杠结尾的引号值,然后是 EOF。打补丁的扫描器的 unescape() 读取 s[i + 1] 时没有检查 i + 1 是否在边界内。AddressSanitizer 立即中止。
没有任何单元测试曾经生成过尾部反斜杠。参考解析器能处理它是因为它的分词器在消费下一个字符前会先检查。模型的重写优化掉了那个检查。
问题 2:引号值内部的 #
修复崩溃后,门发现了一个语义分歧。输入:key = "a#b"。参考解析器返回 a#b。打补丁的解析器返回 a。
模型的分词器在任何地方都将 # 视为注释开始,即使在引号内部。原版只在行首才那样处理。单元测试覆盖了注释和引号,但从未覆盖两者的交集。
问题 3:CRLF 值
输入:key = "a\r\n"。参考解析器去掉了 \r。打补丁的解析器保留了它。在运行测试的 Linux 上,这个差异是不可见的。在 Windows 配置文件上,它会破坏每个值。
${VAR} 展开功能本身是正确工作的。问题出在分词器的重写上。
差分 oracle 不需要比模型更聪明。它只需要更老。原版解析器成为了规范。模型针对它能看到的测试做了优化;门则强制执行它看不到的行为。
差分 oracle 不需要比模型更聪明。它只需要更老。原版解析器成为了规范。模型针对它能看到的测试做了优化;门则强制执行它看不到的行为。
覆盖率不是边缘行为代理。100% 的行覆盖率说明不了尾部反斜杠的问题。模糊测试通过生成字节找到它,而不是通过读代码行。
覆盖率不是边缘行为代理。100% 的行覆盖率说明不了尾部反斜杠的问题。模糊测试通过生成字节找到它,而不是通过读代码行。
模糊测试预算很便宜。90 秒的墙上时间发现了一个内存安全 bug 和两个语义回归。修复只是一个边界检查和两个条件微调。
模糊测试预算很便宜。90 秒的墙上时间发现了一个内存安全 bug 和两个语义回归。修复只是一个边界检查和两个条件微调。
免费模型访问对生成候选补丁很有用。门才是使它们可合并的东西。模型产生了一个干净、可读的 diff。门决定这个 diff 是否安全。
免费模型访问对生成候选补丁很有用。门才是使它们可合并的东西。模型产生了一个干净、可读的 diff。门决定这个 diff 是否安全。
谁不应该使用这种方法
差分模糊测试需要一个参考实现。如果你在重写一个解析器而旧版本已经被删除了,你就没有 oracle。先构建一个表征测试套件,然后再删除旧代码。
对于简单的解析器,门控也是杀鸡用牛刀。如果你的格式 50 行就能装下,维护两个构建的成本超过了他能降低的风险。
而且如果你不能在 CI 中运行 sanitizers,门控的内存安全这一半就形同虚设。ASan 是捕获崩溃的组件。
三个复现器连同门的裁决一起反馈给了模型。第二次尝试通过了:恢复了一个边界检查,两个分词器条件与参考实现对齐。
// The fix was smaller than the finding:
if (i + 1 < s.size() && s[i] == '\\') {
value.push_back(unescape(s[++i]));
}
总成本:一次模糊运行,三个小修复,零生产事故。
下次模型给你一个干净的 diff 时,问问你的门会怎么说。用你真实的配置文件种子同一个 harness,让模糊测试做审查。