yuqi-zheng

使用 libFuzzer 对浮点代码进行结构感知模糊测试


libFuzzer 这类覆盖率引导的模糊测试器,在 parser 和协议实现中找 bug 非常出色。但在浮点代码上,它们往往力不从心,因为它们把输入视为一串不透明的字节,几乎不会生成那些真正重要的值:NaN±∞-0.0DBL_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 的投入都能立刻回本。