改善模糊效果

首次對新目標執行模糊測試時,模糊測試器可能會很快當機。模糊測試通常會先發現大量缺陷,然後是長尾效應。發現的瑕疵頻率可能會因多種原因而下降,包括: * 測試的程式碼中瑕疵較少。 * 模糊測試器未測試部分程式碼。

如要區分這兩者,您必須評估及改善模糊測試器。

提高程式碼涵蓋率

如要改善模糊測試器,第一步是瞭解目前的成效。程式碼涵蓋率是涵蓋範圍導向模糊測試工具的明顯重要指標。您可以使用 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) { ... }

荒漠油廠

  • 使用外部來源資料的條件。例如:
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) { ... }

查看

  • 使用外部來源資料的條件。例如: 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

荒漠油廠

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

查看

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

如上所述,這會在效能和清除器準確率之間取捨。如果上述程式碼改為配置長度為 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;
}