使用 libFuzzer 对浮点代码进行结构感知模糊测试
libFuzzer 这类覆盖率引导的模糊测试器,在 parser 和协议实现中找 bug 非常出色。但在浮点代码上,它们往往力不从心,因为它们把输入视为一串不透明的字节,几乎不会生成那些真正重要的值:NaN、±∞、-0.0、DBL_EPSILON、次正规数。一个自定义 mutator 就能解决这个问题。
问题:一个”永远不会返回 NaN”的函数
看一个显式跳过 NaN 输入的累加函数:
double sum(const double* begin, const double* end) {
return std::accumulate(begin, end, 0.0, [](auto a, auto b) {
return std::isnan(b) ? a : a + b;
});
}
直觉会说:它永远不会返回 NaN —— NaN 输入都被过滤掉了。用 libFuzzer 验证这个断言,测试入口很直接:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* Data, size_t Size) {
auto* begin = reinterpret_cast<const double*>(Data);
auto* end = begin + Size / sizeof(double);
if (std::isnan(sum(begin, end))) {
std::abort();
}
return 0;
}
没有自定义 mutator 时,libFuzzer 生成的随机字节几乎从不会对齐到有意义的 IEEE 754 位模式,可能永远找不到这个 bug。有了自定义 mutator,它几乎立刻就能找到。
自定义 mutator
mutator 产生的是一个 double 数组,元素取自 IEEE 754 的”有意思的角落”:
extern "C" size_t LLVMFuzzerCustomMutator(
uint8_t* Data, size_t Size, size_t MaxSize, unsigned int Seed)
{
auto* begin = reinterpret_cast<double*>(Data);
auto* end = begin + Size / sizeof(double);
std::minstd_rand gen(Seed);
auto interesting_double = [&]() -> double {
switch (std::uniform_int_distribution<>(0, 10)(gen)) {
case 0: return std::numeric_limits<double>::quiet_NaN();
case 1: return std::numeric_limits<double>::min(); // 最小正规数
case 2: return std::numeric_limits<double>::max();
case 3: return -std::numeric_limits<double>::min();
case 4: return -std::numeric_limits<double>::max();
case 5: return std::numeric_limits<double>::epsilon();
case 6: return -std::numeric_limits<double>::epsilon();
case 7: return std::numeric_limits<double>::infinity();
case 8: return -std::numeric_limits<double>::infinity();
case 9: return 0.0;
default: return std::uniform_real_distribution<>(-1.0, 1.0)(gen);
}
};
switch (std::uniform_int_distribution<>(0, 3)(gen)) {
case 0: // 修改一个已有元素
if (begin != end) {
auto idx = std::uniform_int_distribution<>(0, (int)(end - begin) - 1)(gen);
begin[idx] = interesting_double();
}
break;
case 1: // 追加一个元素
if (Size + sizeof(double) <= MaxSize) {
*end++ = interesting_double();
}
break;
case 2: // 移除最后一个元素
if (begin != end) --end;
break;
case 3: // 洗牌
std::shuffle(begin, end, gen);
break;
}
return (end - begin) * sizeof(double);
}
自定义 crossover
crossover 函数把两个输入合并起来,每个元素以相等的概率选自任一”父本”:
extern "C" size_t LLVMFuzzerCustomCrossOver(
const uint8_t* Data1, size_t Size1,
const uint8_t* Data2, size_t Size2,
uint8_t* Out, size_t MaxOutSize, unsigned int Seed)
{
std::minstd_rand gen(Seed);
std::bernoulli_distribution coin(0.5);
size_t n = std::min({Size1, Size2, MaxOutSize}) / sizeof(double);
for (size_t i = 0; i < n; ++i) {
reinterpret_cast<double*>(Out)[i] = coin(gen)
? reinterpret_cast<const double*>(Data1)[i]
: reinterpret_cast<const double*>(Data2)[i];
}
return n * sizeof(double);
}
编译与运行
clang++ -g -fsanitize=fuzzer fpfuzzing.cpp -o fpfuzzing
./fpfuzzing
fuzzer 几乎立刻就会找到一个使程序崩溃的输入。
它找到了什么
导致崩溃的输入是 {+∞, NaN, NaN, -∞}。函数把两个 NaN 都跳过了,然后计算 +∞ + (-∞)。IEEE 754 规定 ∞ - ∞ = NaN。函数确实返回了 NaN —— 那个 filter 只防御了 NaN 输入,没有防御”由无穷相加产生”的 NaN 结果。
SUMMARY: libFuzzer: deadly signal
Base64: AAAAAAAA8H8AAAAAAAAAAAAAAAAAAPD/
解码后:[+∞, NaN, NaN, -∞]。
为什么这件事重要
这是用覆盖率引导引擎做属性测试。被测试的属性是”这个函数永远不会产出 NaN”。fuzzer 用几毫秒就证伪了它 —— 用的是人类审阅时很容易漏掉的输入。
同样的技术可以扩展到更复杂的领域:对称矩阵、线性系统、带有特定数值约束的函数。任何”输入具有某种结构、而随机字节几乎无法满足”的地方,自定义 mutator 的投入都能立刻回本。