改进模糊测试工具

当您首次开始对新目标进行模糊测试时,模糊测试工具可能会很快崩溃。模糊测试通常会导致在发现的缺陷中首先出现一个峰值,然后出现长尾。发现缺陷的频率可能因多种原因而下降,包括: * 被测试代码的缺陷数量减少了。 * 模糊测试工具不会测试代码的某些部分。

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

提高代码覆盖率

改进模糊测试工具的第一步是了解它目前的表现如何。对于覆盖率引导型模糊测试工具,一个明显的关键指标是代码覆盖率。您可以使用 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++

使用常用的 build 宏 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 的所有字节都设为了 0。根据代码的不同,如果 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();
  ...
}

不过,请非常小心地从一次迭代继承到下一次迭代的程序状态。如果缺陷不仅取决于当前测试输入,还取决于之前所有输入的一部分,如果不重新执行整个模糊测试工具运行过程,将很难重现。

预先分配存储空间

某些代码(例如压缩算法)需要在大量内存上运行。如果模糊测试工具每次迭代时都尝试分配和释放大量 MB 的内存,则会出现性能下降的情况。另一种方法是使用预先分配最大大小的缓冲区。

例如:

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;
}

如上所示,这引入了性能与排错程序准确性之间的权衡。如果上面的代码改为分配长度为 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;
}