改进模糊测试工具

首次开始对新目标进行模糊测试时,模糊测试工具可能会很快崩溃。模糊测试通常会先发现大量缺陷,然后发现的缺陷数量会逐渐减少。发现缺陷的频率可能会因多种原因而下降,包括: * 被测代码中的缺陷较少。 * 模糊测试工具未测试代码的某些部分。

为了区分这些情况,您需要能够评估和改进模糊测试工具。

提高代码覆盖率

改进模糊测试工具的第一步是了解其当前的执行情况。对于覆盖率引导型模糊测试工具,一个明显的关键指标是代码覆盖率。您可以使用 fx fuzz 收集此信息。

例如:

fx fuzz analyze package/fuzzer

这会运行模糊测试工具 60 秒,并报告设备上语料库的代码覆盖率。 如果您指定 --staging 选项,该目录中的文件将首先添加到语料库中。

添加或改进种子语料库

如果您发现代码覆盖率存在差距,可以将各个输入添加到种子语料库中:

  1. 在模糊测试工具附近将目录添加到源代码树中。
  2. 向此目录添加一个或多个文件,每个文件都包含测试输入的原始字节,这些字节会导致模糊测试工具访问之前未覆盖的代码。
  3. 为此目录添加 resource GN 目标,并将其添加到模糊测试工具的 deps 中。
  4. 添加一个参数,其中包含模糊测试工具的组件清单来源的软件包相对路径。

例如:

cd $FUCHSIA_DIR
mkdir path-to-library/my-fuzzer-corpus
cp handcrafted-input path-to-library/corpus

//path/to/library/BUILD.gn 中:


import("//build/fuzz.gni")
import("//build/dist/resource.gni")

resource("my-library-corpus") {
  sources = [
    ...
    "corpus/handcrafted-input",
    ...
  ]
  outputs = [ "data/my-library-corpus/{{source_file_part}}" ]
}

cpp_fuzzer("my_fuzzer"){
  sources = [ "my-fuzzer.cc" ]
  deps = [
    ":my-library",
    ":my-library-corpus"
  ]
}

在模糊测试工具的组件清单来源中:

{
  ...
  program: {
    args: [
      ...
      "data/my-library-corpus",
      ...
    ]
  }
}

使代码更适合模糊测试

通常,当决策基于输入的字节时,libFuzzer 在查找探索新条件分支的输入方面相当有效。例如,它可以对比较指令(例如 CMP)使用插桩,以确定匹配对输入某些部分的检查所需的值。

但是,当模糊测试工具遇到“模糊测试工具不友好”的条件时,此方法可能会失败。这些条件包括:

C/C++

  • 使用外部来源的数据的条件。例如:
zx_cprng_draw(&val, sizeof(val));
if (val == 0) { ... }
  • 检查可以构造但难以猜测的值的条件。例如:
uint32_t actual = header.checksum;
header.checksum = 0;
uint32_t expected =  crc32(0, reinterpret_cast<const uint8_t*>(&header), sizeof(header));
if (actual == expected) { ... }
int result = ECDSA_verify(0, data, data_len, signature, signature_len, ec_key);
if (result == 0) { ... }

Rust

  • 使用外部来源的数据的条件。例如:
let mut randbuf = [0; 8];
zx::cprng_draw(&mut randbuf)?;
let val = u64::from_le_bytes(randbuf);
if val == 0 { ... }
  • 检查可以构造但难以猜测的值的条件。例如:
let mut c = Checksum::new();
c.add_bytes(&buf);
c.checksum()
if c == expected { ... }
let digest = H::hash(message);
if boringssl::ecdsa_verify(digest.as_ref(), self.bytes(), &key.inner.key) { ... }

Go

  • 使用外部来源的数据的条件。例如: golang if rand.Intn(100) == 0 { ... }

  • 检查可以构造但难以猜测的值的条件。例如:

iCksum := ipv4.CalculateChecksum()
if iCksum != want { ... }
ecdsaKey, ok := key.(*ecdsa.PublicKey)
h := e.hash.New()
h.Write(msg)
if ecdsa.Verify(ecdsaKey, h.Sum(nil), ecdsaSignature.R, ecdsaSignature.S) { ... }

作为代码维护人员,您可以使用条件编译在被测代码中为这些条件添加变通方法。libFuzzer 将此称为使用模糊测试工具友好的构建模式。

C/C++

使用通用构建宏 FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION

例如:

#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
  // Use hard-coded value when fuzzing.
  memset(&val, 0, sizeof(val));
#else
  zx_cprng_draw(&val, sizeof(val));
#endif

在此示例中,我们将 val 的所有字节都设置为始终为零。根据代码的不同,如果 val 是其他确定性值,甚至可能直接取决于模糊测试工具输入,则对模糊测试工具可能更有用。

Rust

使用 fuzz cfg 属性

例如:

#[cfg(not(fuzz))]
fn is_valid(&self, key: &EcPubKey<C>, message: &[u8]) -> bool {
    let digest = H::hash(message);
    boringssl::ecdsa_verify(digest.as_ref(), self.bytes(), &key.inner.key)
}

#[cfg(fuzz)]
fn is_valid(&self, key: &EcPubKey<C>, message: &[u8]) -> bool {
    // Skip validation when fuzzing.
    return true;
}

Go

使用 fuzz 软件包。由于 Go 仅在文件 级别执行条件编译,因此该软件包包含两个定义 const Enabled <bool> 的文件。包含哪个文件以及 Enabled 的值取决于代码是否在模糊测试工具变体中构建。

例如:

import "fuzz"

func (b IPv4) CalculateChecksum() uint16 {
  if fuzz.Enabled {
    // Return hard-coded value when fuzzing.
    return uint16(0xffff)
  }
  return Checksum(b[:b.HeaderLength()], 0)
}

添加自定义变异器

在某些情况下,模糊测试工具提供的输入可能会在被处理之前进行转换。这会大大降低模糊测试工具将输入与其产生的行为相关联的能力。 例如,正在进行模糊测试的库可能会先解压缩其输入,然后再处理这些输入。对该库进行模糊测试的最有效方法是对未压缩的输入执行变异,然后在调用该库之前对其进行压缩。

实现此目的的一种方法是添加自定义变异器。这些是用户提供的 LLVM 的 模糊测试接口定义的可选函数的实现:

extern "C" size_t LLVMFuzzerCustomMutator(uint8_t *Data, size_t Size,
                                          size_t MaxSize, unsigned int Seed);

如果提供,libFuzzer 将使用大小为 MaxSize 的缓冲区 Data 调用此函数,该缓冲区最初 填充了来自语料库的 Size 字节的有效输入。此函数可以在调用另一个 LLVM 模糊测试接口函数之前转换此数据 :

size_t LLVMFuzzerMutate(uint8_t *Data, size_t Size, size_t MaxSize);

此函数执行实际的变异以创建新输入。

在需要压缩输入的库的示例中,自定义变异器可以解压缩语料库中的输入,对其调用 LLVMFuzzerMutate 以创建新输入,并压缩结果。

Google 的公开模糊测试文档针对此情况和其他情况提供了详细示例。请参阅 通过 libFuzzer 进行结构感知型模糊测试

提供字典

字典是一组在接口的有效输入中常见的令牌。如果提供,模糊测试工具将使用字典来构造更可能是有效的输入,从而提供更深入的覆盖率。

如果您知道代码需要哪种令牌,可以将其添加到字典文件中,每行一个。然后,以与种子语料库相同的方式将该文件作为 resource 提供给模糊测试工具,并将其添加到模糊测试工具的组件清单来源中。

例如:

{
  ...
  program: {
    args: [
      ...
      "-dict=data/my-dictionary.txt",
      ...
    ]
  }
}

提升模糊测试工具性能

一旦模糊测试工具实现了良好的覆盖率,另一个关键指标就是它在给定的有限计算资源集下可以执行多少次迭代。模糊测试工具及其测试的代码的复杂性可能相差很大,因此模糊测试工具每秒应执行的迭代次数或模糊测试工具应保持在的内存限制没有单一的数字。但是,如果其他所有条件都相同,那么速度更快、更精简的模糊测试工具比速度较慢、内存密集型的模糊测试工具更有可能发现缺陷。

启动初始化

通常,被测代码需要进行一些开销较大的设置,然后才能进行测试。在每次迭代时执行此初始化可能会降低模糊测试工具的速度。在这些情况下,最好使用与进程生命周期相同的生命周期延迟初始化变量。

例如:

bool SetUp() {
   DoSomeExpensiveWork();
   return true;
}

extern "C" LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
  static bool ready = SetUp();
  ...
}

但是,请 非常 注意从一次迭代到下一次迭代的程序状态。如果缺陷不仅取决于当前的测试输入,还取决于所有先前输入的某个子集,那么如果不重放整个模糊测试工具运行,就很难重现该缺陷。

预分配存储空间

某些代码(例如压缩算法)需要处理大量内存。如果模糊测试工具尝试在每次迭代时分配和释放数兆字节的内存,性能将会下降。另一种方法是使用预分配的最大大小的缓冲区。

例如:

static const size_t kMaxOutSize = 0x10000000; // 256 MB

extern "C" LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
  static uint8_t out_buf[kMaxOutSize];
  size_t max = Decompressor::GetMaxUncompressedSize(size);
  if (sizeof(out_buf) < max) {
    return 0;
  }
  Decompressor::Decompress(data, size, out_buf, max);
  return 0;
}

如上所示,这会在性能和 Sanitizer 准确性之间做出权衡。如果上面的代码 改为分配长度为 max 的内存区域,AddressSanitizer 将 能够检测 Decompress 是否溢出任何数量。使用预分配的区域,它可能会静默成功。幸运的是,AddressSanitizer 提供了一种 手动毒害内存的方法。

例如:

#include <sanitizer/asan_interface.h>

static const size_t kMaxOutSize = 0x10000000; // 256 MB

extern "C" LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
  static uint8_t out_buf[kMaxOutSize];
  size_t max = Decompressor::GetMaxUncompressedSize(size);
  if (sizeof(out_buf) < max) {
    return 0;
  }
  ASAN_POISON_MEMORY_REGION(&data[max], sizeof(out_buf) - max);
  Decompressor::Decompress(data, size, out_buf, max);
  ASAN_UNPOISON_MEMORY_REGION(&data[max], sizeof(out_buf) - max);
  return 0;
}