用 Nix 模糊測試 PDF 解析器(上)
原文由 Michael Lynch 于 發布,訂閱此部落格
模糊測試是一種能自動找出軟體錯誤的技術。問題在於,它的設定過程非常麻煩。隨便找一篇模糊測試教學來看,第一個步驟往往就是花上一小時從原始碼建置工具,然後一層又一層地處理相依套件。
我最近發現 Nix 能省去模糊測試中大量繁瑣的工作。我建立了一個 Nix 設定,只要一道指令就能啟動整個模糊測試工作流程。唯一的相依需求只有 Nix 和 git。
我用這個 Nix 工作流程,在一個 PDF 渲染器中找到了一個尚未修補的錯誤,儘管我在 Nix 和模糊測試兩方面都還是新手。
成果預覽
以下是我的最終成果預覽:只要一道指令,就能開始對一套開源的 PDF 閱讀器進行模糊測試:
nix run gitlab:mtlynch/fuzz-xpdf這個指令在任何已安裝 Nix 的 Linux 系統上應該都能執行,或許在 MacOS 上也可以。經過幾分鐘的建置後,你應該會看到像這樣的終端機介面:

Nix 讓我能用一道指令就安裝好所有相依套件並開始模糊測試。
當你執行上述指令時,背後會依序完成以下事情:
- Nix 會下載 PDF 閱讀器與測試工具鏈所需的所有工具與相依套件。
- Nix 會從原始碼編譯 PDF 閱讀器,並加上模糊測試所需的插樁(instrumentation)。
- Nix 會下載一組邊界案例的 PDF 檔案,用來產生測試輸入。
- Nix 會自動產生新的 PDF,餵給 PDF 閱讀器,並回報哪些輸入導致程式當掉。
如果你想更改模糊測試的選項,或測試不同版本的 PDF 閱讀器,只要編輯單一檔案就能完成。
接下來,我會一步步分享我是如何建立這個模糊測試工作流程的。你也可以用同樣的方法,在其他專案中找出錯誤。
如果你等不及了,可以直接跳到最後查看我的最終成果。
什麼是模糊測試?
模糊測試(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
選擇模糊測試的目標
我要進行模糊測試的 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 為例,原始碼壓縮檔位於這個網址:
我使用 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=要修正雜湊值不符的問題,我把錯誤訊息中出現在「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/binNix 的這種行為是一把雙面刃。當它正常運作時,會讓人覺得很神奇,好像 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 會遵循 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 應該看起來像這樣。
在開發環境(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 套件的所有 nativeBuildInputs(cmake 和 honggfuzz)再加上 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 教學系列,本文的工作即是以此為基礎。
隨機一篇部落格
留言
登入後參與討論