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

Michael Lynch

用 Nix 模糊測試 PDF 解析器(上)

原文由 Michael Lynch 發布,訂閱此部落格

模糊測試是一種能自動找出軟體錯誤的技術。問題在於,它的設定過程非常麻煩。隨便找一篇模糊測試教學來看,第一個步驟往往就是花上一小時從原始碼建置工具,然後一層又一層地處理相依套件。

我最近發現 Nix 能省去模糊測試中大量繁瑣的工作。我建立了一個 Nix 設定,只要一道指令就能啟動整個模糊測試工作流程。唯一的相依需求只有 Nix 和 git。

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

成果預覽

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

nix run gitlab:mtlynch/fuzz-xpdf

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

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

Nix 讓我能用一道指令就安裝好所有相依套件並開始模糊測試。

當你執行上述指令時,背後會依序完成以下事情:

  1. Nix 會下載 PDF 閱讀器與測試工具鏈所需的所有工具與相依套件。
  2. Nix 會從原始碼編譯 PDF 閱讀器,並加上模糊測試所需的插樁(instrumentation)。
  3. Nix 會下載一組邊界案例的 PDF 檔案,用來產生測試輸入。
  4. Nix 會自動產生新的 PDF,餵給 PDF 閱讀器,並回報哪些輸入導致程式當掉。

如果你想更改模糊測試的選項,或測試不同版本的 PDF 閱讀器,只要編輯單一檔案就能完成。

接下來,我會一步步分享我是如何建立這個模糊測試工作流程的。你也可以用同樣的方法,在其他專案中找出錯誤。

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

什麼是模糊測試?

模糊測試(fuzz testing,或簡稱 fuzzing)是一種透過隨機產生輸入資料,並檢查這些輸入是否會導致目標應用程式當掉,來找出軟體錯誤的方法。

舉例來說,若要測試一個會調整 JPEG 圖片大小的程式,流程會像這樣:

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

什麼是 Nix?

Nix 是一個複雜的工具,能做很多不同的事,其中有不少我自己也還沒搞懂。

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

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

環境需求

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

選擇模糊測試的目標

我要進行模糊測試的 PDF 閱讀器叫做 xpdf。它是一個 PDF 檢視器,但同時也附帶了一整套 PDF 工具。其中一個工具 pdftotext 是很適合的模糊測試目標,因為它非常單純。它沒有圖形介面,只接受 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(我要模糊測試的套件)和 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=

要修正雜湊值不符的問題,我把錯誤訊息中出現在「got」旁邊的值貼到我的 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 一個惱人的陷阱是,除非檔案已納入 git 版本控制,否則 Nix 看不到它們。如果你看到「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

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

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 應該看起來像這樣

在開發環境(dev shell)中臨時進行模糊測試

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

我本可以在 Nix flake 中設定一個優雅的指令來啟動模糊測試,但在這個階段,我只想趕快動手、盡快開始嘗試。為此,我建立一個包含所有工具的 Nix 開發環境(dev shell)。

要建立 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 的工具。

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

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___:指定要進行模糊測試的目標程式。___FILE___ 是一個佔位參數。honggfuzz 在每次執行時會將它替換為新產生檔案的路徑。

我執行這個指令後,就會看到 honggfuzz 的模糊測試介面:

honggfuzz 會顯示一個終端機介面來呈現模糊測試的進度

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

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

到這裡,我已經展示了如何使用 Nix 和 honggfuzz 對 xpdf 這款 PDF 閱讀器進行基本的模糊測試。

在後續的文章中,我將會展示如何:

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

請繼續閱讀:


感謝 Antonio Morales 製作了 Fuzzing101 教學系列,本文的工作即是以此為基礎。

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

留言