Using Nix to Fuzz Test a PDF Parser (Part One)

Michael Lynch

使用 Nix 對 PDF 解析器進行模糊測試(上篇)

Fuzz testing(模糊測試)是一種自動找出軟體錯誤的技術。問題在於設定起來非常麻煩。隨便看一篇 Fuzz testing 教學,第一個任務往往就是花上一小時從原始碼建置工具,然後追解一層又一層的相依套件。

我最近發現 Nix 能省去 Fuzz testing 中許多繁瑣的雜務。我建立了一個 Nix 設定,只要一道指令就能啟動 Fuzz testing 工作流程。唯一的相依需求只有 Nix 和 git。

我利用這個 Nix 工作流程,在一個 PDF 渲染器中找到了一個尚未修補的錯誤,儘管我在 Nix 和 Fuzz testing 方面都還是新手。

成果預覽

以下是我最終成果的預覽:只要一道指令,就能開始對一套開源 PDF 閱讀器進行 Fuzz testing:

nix run gitlab:mtlynch/fuzz-xpdf

這個指令在任何已安裝 Nix 的 Linux 系統上應該都能運作,或許在 MacOS 上也可以。建置幾分鐘後,你應該會看到如下的終端機介面:

honggfuzz 終端機介面的螢幕截圖,顯示正在對 pdftotext 進行模糊測試的進度

Nix 讓我能用單一指令安裝所有相依套件並開始進行 Fuzz testing。

執行上述指令時,會發生以下所有事情:

  1. Nix 會下載 PDF 閱讀器與測試工具鏈所需的所有工具與相依套件。
  2. Nix 會從原始碼編譯 PDF 閱讀器,並加入適用於 Fuzz testing 的插樁。
  3. Nix 會下載一組邊界案例 PDF,用於產生測試輸入。
  4. Nix 會自動產生新的 PDF,將其餵給 PDF 閱讀器,並回報哪些輸入導致閱讀器當機。

如果你想變更 Fuzzing 選項或測試不同版本的 PDF 閱讀器,只要編輯單一檔案即可。

接下來我將逐步分享我是如何建立這個 Fuzz testing 工作流程的。你也可以用同樣的方法在其他專案中尋找錯誤。

如果你等不及了,可以直接跳到最後查看我的最終成果

什麼是 Fuzz testing?

Fuzz testing 或稱「Fuzzing」,是一種透過隨機產生輸入資料,並檢查該輸入是否會導致目標應用程式當機,來尋找軟體錯誤的方法。

舉例來說,若要測試一個可調整 JPEG 影像大小的程式,工作流程會如下所示:

  1. 準備一組有效及/或格式錯誤的 JPEG 檔案。
  2. 隨機挑選其中一個輸入檔並隨機變異(翻轉某些位元、加入一些資料、刪除一些資料)。
  3. 將變異後的輸入檔餵給影像縮放程式。
  4. 如果變異後的輸入導致程式當機或卡死,就將該輸入儲存起來以供後續分析。
  5. 回到步驟 (2)。

什麼是 Nix?

Nix 是一個複雜的工具,能做很多不同的事,其中許多連我自己也不太了解。

就本文的目的而言,只要了解關於 Nix 的兩件事就足夠了:

  • Nix 是一套套件管理工具,類似 aptyum。Nix 在其環境中提供了超過 10 萬個可用的套件。
  • Nix 是一套建置工具,類似 makeDocker。Nix 讓你定義一組建置步驟及其之間的相依關係。當你向 Nix 請求建置時,它會執行所有必要步驟來產生你要求的結果。

需求條件

要跟著本文操作,你只需要兩樣東西:

選擇 Fuzzing 目標

我拿來進行 Fuzz testing 的 PDF 閱讀器叫做 xpdf。它是一個 PDF 檢視器,但同時附帶了一系列 PDF 工具。其中一個工具 pdftotext 是很理想的 Fuzzing 目標,因為它非常單純。它沒有圖形介面,只接受 PDF 作為輸入並產生純文字作為輸出。它仍會執行到 xpdf 複雜的 PDF 解析程式碼,因此如果我在 pdftotext 中找到錯誤,就代表很可能在整個 xpdf 套件中都存在同樣的錯誤。

建立 Nix 樣板

首先,我建立一個新資料夾並初始化 git 儲存庫。

mkdir fuzz-xpdf \
  && cd fuzz-xpdf \
  && git init

接著,我建立一個名為 flake.nix 的檔案:

{
  description = "compile xpdf from source for fuzzing";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        packages = rec {
          default = xpdf;

          xpdf = pkgs.stdenv.mkDerivation rec {
            # TODO: I'll populate this next.
          };
        };
      }
    );
}

這是一個 Nix「flake」,用來定義一組 Nix 套件與應用程式。

到目前為止,這只是一個 Nix flake 的樣板骨架。大部分內容不值得多談,除了這一行:

nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

這行告訴 Nix,當我要引入套件時,是從套件儲存庫的 2024 年 5 月分支取得,也就是撰寫本文時的最新穩定分支。

這個檔案目前只是骨架,還無法成功建置。要用 Nix 編譯 xpdf,我還需要補上一些內容。

指定原始碼壓縮檔

要編譯 xpdf,我需要一份它的原始碼。

首先,我呼叫 mkDerivation,這是 Nix 定義建置元件的方式。它需要套件名稱(pname)與版本,因此我指定 xpdf(我想進行 Fuzzing 的套件)以及 4.05(撰寫本文時 xpdf 的最新發布版本)。

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    pname = "xpdf";
    version = "4.05";
    ...

mkDerivation 中另一個必填欄位是 src 屬性,用來指定 Nix 應如何取得建置所需的輸入。以 xpdf 為例,原始碼壓縮檔位於以下網址:

我使用 pnameversion 變數來指定 xpdf 壓縮檔的網址,這樣未來版本號變更時,網址仍能繼續運作:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    src = pkgs.fetchzip {
      url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
      extension = "tar.gz";
    };

問題在於 Nix 需要壓縮檔的雜湊值來判斷本地版本是否與伺服器上的版本相符。如果我在這個階段執行 nix build,Nix 會抱怨雜湊值錯誤:

warning: found empty hash, assuming 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
error: hash mismatch in fixed-output derivation '/nix/store/z3ckfdjqpfd73xkkwsnpg4ijwj60vyz8-source.drv':
         specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
            got:    sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=

要修正雜湊不符的問題,我將錯誤訊息中顯示的數值貼到我的 flake.nix 中:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    src = pkgs.fetchzip {
      url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
      # Paste the hash that appeared next to "got" in the error message.
      hash = "sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=";
      extension = "tar.gz";
    };

從原始碼編譯 xpdf

現在我已經讓 Nix 知道如何取得 xpdf 的原始碼,接下來就得弄清楚如何建置這些程式碼。

xpdf 的編譯說明列出了以下相依套件:

請確認已安裝以下套件:

  • CMake 2.8.8 或更新版本
  • FreeType 2.0.5 或更新版本
  • Qt 5.x 或 6.x(僅供 xpdf 使用)
  • libpng(供 pdftopng 和 pdftohtml 使用)
  • zlib(供 pdftopng 和 pdftohtml 使用)

我只想執行 pdftotext,所以只需要 CMake 和 FreeType。

從原始碼建置複雜工具通常是個痛苦的過程。我想建置工具 A,但它相依於函式庫 X,所以我得先弄清楚如何安裝函式庫 X。結果發現函式庫 X 又相依於函式庫 Y 和 Z,所以我又得去弄清楚如何安裝那些套件,依此類推。

Nix 以兩種方式大幅簡化了從原始碼建置的流程:

  • Nix 擁有所有套件管理工具中數一數二龐大的套件儲存庫,因此我需要的大多數套件都已經現成可用。
  • Nix 套件並未綁定任何作業系統版本,因此只要有對應我硬體架構的 Nix 套件,我就能使用。

查看 Nix 套件儲存庫後,我發現 CMake 和 FreeType 的套件確實已經都有了:

我假設 CMake 只在建置時期需要,而非執行時期,因此它應該放在 nativeBuildInputs 底下。而 FreeType 可能在執行時期也需要,所以我將它放在 buildInputs 底下:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...

    # Build dependencies belong here.
    nativeBuildInputs = with pkgs; [
      cmake
    ];

    # Runtime dependencies belong here.
    buildInputs = with pkgs; [
      freetype
    ];

此時,我的 flake.nix 看起來像這樣:

{
  description = "compile xpdf from source for fuzzing";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        packages = rec {
          default = xpdf;

          xpdf = pkgs.stdenv.mkDerivation rec {
            pname = "xpdf";
            version = "4.05";

            src = pkgs.fetchzip {
              url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
              hash = "sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=";
              extension = "tar.gz";
            };

            nativeBuildInputs = with pkgs; [
              cmake
            ];

            buildInputs = with pkgs; [
              freetype
            ];
          };
        };
      }
    );
}

使用 Nix 建置時,它會在名為 result 的資料夾下產生輸出,因此我建立一個名為 .gitignore 的檔案,將該資料夾排除在版本控管之外:

echo 'result' > .gitignore

接著,我將所有檔案加入 git 儲存庫:

git add --all

注意:Nix flakes 有一個惱人的陷阱,那就是 Nix 無法看到未納入 git 版本控管的檔案。如果你看到「file not found」之類的錯誤訊息,請檢查是否已將檔案加入 git。

最後,我用 nix build 從原始碼建置套件:

nix build

如果一切順利,在 ./result/bin 底下應該會有一組可執行的二進位檔:

$ ls ./result/bin/
pdfdetach  pdffonts  pdfimages  pdfinfo  pdftohtml  pdftopng  pdftoppm  pdftops  pdftotext

果然,pdftotext 可以正常運作:

$ ./result/bin/pdftotext -v
pdftotext version 4.05 [www.xpdfreader.com]
Copyright 1996-2024 Glyph & Cog, LLC

作為測試,我從 IRS 網站下載了W-4 表格 PDF並餵給 pdftotext

$ ./result/bin/pdftotext fw4.pdf /dev/stdout | head -n 5
Form W-4
Department of the Treasury Internal Revenue Service

Employee's Withholding Certificate
Complete Form W-4 so that your employer can withhold the correct federal income tax from your pay. Give Form W-4 to your employer.

很酷,看起來是正確的。

此階段的完整原始碼已公布在 Gitlab 上

簡單到令人困惑

如果你對 Nix 如何建置出 xpdf 二進位檔感到困惑,我當時也是如此。

我根本還沒告訴 Nix xpdf 的建置流程是什麼,它怎麼會知道?

原來,我呼叫的 Nix mkDerivation 函式預設採用標準的 make 建置流程:

對於使用標準 ./configure; make; make install 建置介面的 Unix 套件,你完全不需要撰寫建置腳本;標準環境會自動完成所有工作。如果 stdenv 無法自動滿足你的需求,你可以輕鬆自訂或覆寫各個建置階段。

The Standard Environment(《標準環境》) 摘自 Nix 手冊

儘管如此,對我來說這還是顯得有點神奇了。

xpdf 的說明文件解釋了你必須告訴編譯器去哪裡找 FreeType 的標頭檔和函式庫。我從來沒做這件事,那 Nix 究竟是如何編譯這個專案的?

而且 make install 通常會寫入像 /usr/bin 這類全系統的目錄,如果我從未用 sudo 提升為 root 權限,那又是怎麼辦到的?

我懷疑,除了隱含地呼叫 make 建置序列之外,Nix 還透過環境變數悄悄地控制著建置過程。

為了驗證我的推測,我將 mkDerivation 中預設的 installPhase 區段替換為會傾印所有環境變數的版本:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    installPhase = ''
      printenv
      make install
    '';

接著,我用詳細日誌重新執行 nix build

nix build -L

果然,我看到它透過 CMAKE_INCLUDE_PATH 變數指向了 FreeType 的標頭檔:

CMAKE_INCLUDE_PATH=/nix/store/rmqyzrzpz2kzmn8329bc4fjmzvd33ylw-freetype-2.13.2-dev/include:...

而它沒有覆寫我的 /usr/bin 目錄的原因,是 Nix 告訴 CMake 要安裝到 Nix 專屬的安裝目錄:

cmakeFlags=...-DCMAKE_INSTALL_BINDIR=/nix/store/7w4ql3kdrl3c0knnvx3lxsnrqfzfcy34-xpdf-4.05/bin

Nix 的這種行為是一把雙面刃。當它運作正常時,會讓人覺得很神奇,Nix 竟能在我不需手把手指導的情況下就弄清楚建置流程。但如果它沒成功,我就得透過 Nix 不透明的抽象層來除錯問題。

使用 honggfuzz 編譯 xpdf

現在我已經能成功編譯 xpdf,是時候引入工作流程中 Fuzz testing 的部分了。

honggfuzz 是由 Google 維護的 Fuzz testing 工具。它是一款 coverage-guided fuzzer(涵蓋率導向模糊測試器),意思是它會追蹤特定測試輸入會執行到目標二進位檔的哪些部分。當它發現某個輸入導致二進位檔執行到新的程式碼路徑時,就會產生更多與該輸入相似的輸入,因為這代表有更大機會觸及未經測試的行為。

honggfuzz 附帶了 C 和 C++ 編譯器,因此用 honggfuzz 編譯 xpdf 應該就跟讓 Nix 指向 honggfuzz 的編譯器、而非 Nix 預設的編譯器一樣簡單。為此,我首先修改 nativeBuildInputs,將 honggfuzz 套件納入,以便在編譯期間可用:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    nativeBuildInputs = with pkgs; [
      cmake
      honggfuzz
    ];
}

好了,現在 honggfuzz 會在我的建置環境中可用,但我要如何告訴 CMake 改用 honggfuzz 的編譯器,而非它原本使用的編譯器呢?

Make 和 CMake 會遵循 CCCXX 環境變數,這兩個變數分別指定要使用哪一個 C 和 C++ 編譯器。

我可以看到 honggfuzz 附帶了名為 hfuzz-clang 和 hfuzz-clang++ 的編譯器。聽起來很有希望,但我不知道在 honggfuzz 的 Nix 套件中哪裡可以找到這些二進位檔。我像這樣搜尋套件:

$ nix build nixpkgs#honggfuzz

$ find -L result -type f -name hfuzz-clang
result/bin/hfuzz-clang

好了,這告訴我 honggfuzz 的 Nix 套件中的編譯器位於 bin/ 子目錄中。

要讓 Nix 使用 honggfuzz 的編譯器來建置 xpdf,我將 CCCXX 變數指向正確的編譯器路徑:

{
    xpdf = pkgs.stdenv.mkDerivation rec {
      ...

      preConfigure = ''
        export CC=${pkgs.honggfuzz}/bin/hfuzz-clang
        export CXX=${pkgs.honggfuzz}/bin/hfuzz-clang++
      '';
}

如果我以詳細輸出進行建置,會看到 Nix 的確正在使用 honggfuzz 的編譯器:

$ nix build -L
...
xpdf> -- The C compiler identification is Clang 16.0.6
xpdf> -- The CXX compiler identification is Clang 16.0.6
...
xpdf> -- Check for working C compiler: /nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/hfuzz-clang - skipped
...
xpdf> -- Check for working CXX compiler: /nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/hfuzz-clang++ - skipped

此時,flake.nix 應該看起來像這樣

在開發殼層中進行臨時 Fuzzing

我已經用 honggfuzz 的編譯器編譯好 xpdf,現在我想進入有趣的部分了。

我可以在 Nix flake 中設定一個優雅的指令來啟動 Fuzzing,但在這個階段,我只想趕快動手玩玩看。為此,我建立一個包含所有工具的 Nix 開發殼層。

要建立 Nix 開發殼層,我在 Nix flake 中加入以下內容:

{
    packages = rec {
        ...
    };

    devShells.default = pkgs.mkShell {
      buildInputs = self.packages.${system}.xpdf.nativeBuildInputs ++ (with pkgs; [
        wget
      ]);

      shellHook = ''
        wget --version | head -n 1
      '';
    };

此時,flake.nix 應該看起來像這樣

我輸入 nix develop 來進入 Nix 開發殼層:

$ nix develop
GNU Wget 1.21.4 built on linux-gnu.

「GNU Wget」這段輸出是來自 shellHook,它會印出殼層中可用工具的版本號。honggfuzz 二進位檔也同樣可用:

$ honggfuzz --help 2>&1 | head -n 1
Usage: honggfuzz [options] -- path_to_command [args]

這樣能運作,是因為在 mkShell 中,我將 buildInputs 指定為 xpdf 套件的所有 nativeBuildInputscmakehonggfuzz)再加上 wget,而 wget 是我只想在開發殼層中用來下載 PDF 的工具。

接著,我建立一個目錄來存放 Fuzzing 結果。由於這只是實驗性質,我使用暫存目錄:

PDF_DIR="$(mktemp --directory)"

然後,我抓取一個 PDF 作為範例輸入:

$ PDF_URL='https://www.irs.gov/pub/irs-pdf/fw4.pdf' && \
  wget --directory-prefix="${PDF_DIR}" "${PDF_URL}"

我再執行一次 nix build,以確保 pdftotext 已在 ./result/bin 資料夾下準備就緒:

$ nix build && ./result/bin/pdftotext -v
pdftotext version 4.05 [www.xpdfreader.com]
Copyright 1996-2024 Glyph & Cog, LLC

最後,迎來關鍵時刻。我啟動 honggfuzz 的測試執行器:

$ honggfuzz \
    --input "${PDF_DIR}" \
    -- ./result/bin/pdftotext ___FILE___

運作方式如下:

  • --input "${PDF_DIR}" 指定要進行變異的輸入檔所在目錄。
  • -- ./result/bin/pdftotext ___FILE___:指定要進行 Fuzzing 的目標程式。___FILE___ 是一個佔位參數。honggfuzz 在每次執行時會將其替換為新產生檔案的路徑。

我執行該指令後,迎來了 honggfuzz 的 Fuzzing 介面:

honggfuzz 顯示終端機介面以呈現 Fuzz testing 進度

成功了!我可以讓 honggfuzz 跑個幾天看看是否會捕捉到什麼,但我想再稍微打磨一下工作流程,以提高找到錯誤的機率。

接下來:使用 Nix 在 xpdf 中找出尚未修補的錯誤

到此為止,我已經展示了如何使用 Nix 和 honggfuzz 對 xpdf PDF 閱讀器進行基本的 Fuzz testing。

在後續文章中,我將說明如何:

  • 自動化完整的 Fuzzing 工作流程。
  • 蒐集更容易引發當機的刁鑽 PDF。
  • 在最新版的 xpdf 中找出一個尚未修補的錯誤。

請繼續閱讀:


感謝 Antonio Morales(安東尼奧·莫拉雷斯) 創建了本作品所依據的 Fuzzing101 教學系列

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯