使用 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 上也可以。建置幾分鐘後,你應該會看到如下的終端機介面:

Nix 讓我能用單一指令安裝所有相依套件並開始進行 Fuzz testing。
執行上述指令時,會發生以下所有事情:
- Nix 會下載 PDF 閱讀器與測試工具鏈所需的所有工具與相依套件。
- Nix 會從原始碼編譯 PDF 閱讀器,並加入適用於 Fuzz testing 的插樁。
- Nix 會下載一組邊界案例 PDF,用於產生測試輸入。
- Nix 會自動產生新的 PDF,將其餵給 PDF 閱讀器,並回報哪些輸入導致閱讀器當機。
如果你想變更 Fuzzing 選項或測試不同版本的 PDF 閱讀器,只要編輯單一檔案即可。
接下來我將逐步分享我是如何建立這個 Fuzz testing 工作流程的。你也可以用同樣的方法在其他專案中尋找錯誤。
如果你等不及了,可以直接跳到最後查看我的最終成果。
什麼是 Fuzz testing?
Fuzz testing 或稱「Fuzzing」,是一種透過隨機產生輸入資料,並檢查該輸入是否會導致目標應用程式當機,來尋找軟體錯誤的方法。
舉例來說,若要測試一個可調整 JPEG 影像大小的程式,工作流程會如下所示:
- 準備一組有效及/或格式錯誤的 JPEG 檔案。
- 隨機挑選其中一個輸入檔並隨機變異(翻轉某些位元、加入一些資料、刪除一些資料)。
- 將變異後的輸入檔餵給影像縮放程式。
- 如果變異後的輸入導致程式當機或卡死,就將該輸入儲存起來以供後續分析。
- 回到步驟 (2)。
什麼是 Nix?
Nix 是一個複雜的工具,能做很多不同的事,其中許多連我自己也不太了解。
就本文的目的而言,只要了解關於 Nix 的兩件事就足夠了:
- Nix 是一套套件管理工具,類似
apt或yum。Nix 在其環境中提供了超過 10 萬個可用的套件。 - Nix 是一套建置工具,類似
make或Docker。Nix 讓你定義一組建置步驟及其之間的相依關係。當你向 Nix 請求建置時,它會執行所有必要步驟來產生你要求的結果。
需求條件
要跟著本文操作,你只需要兩樣東西:
- Nix(需啟用 flakes 功能)
- 我推薦使用 Determinate Systems 安裝程式,它預設就會啟用 flakes。
- git
選擇 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 為例,原始碼壓縮檔位於以下網址:
我使用 pname 和 version 變數來指定 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/binNix 的這種行為是一把雙面刃。當它運作正常時,會讓人覺得很神奇,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 會遵循 CC 與 CXX 環境變數,這兩個變數分別指定要使用哪一個 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,我將 CC 和 CXX 變數指向正確的編譯器路徑:
{
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 套件的所有 nativeBuildInputs(cmake 和 honggfuzz)再加上 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 教學系列。
隨機一篇部落格