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

Michael Lynch

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

這是關於使用 Nix 自動化 fuzz testing(模糊測試)工作流程一文的下半篇。

到目前為止,我已經可以在 pdftotext 上執行 honggfuzz,但啟動時仍需要一些手動操作。我在上篇曾承諾,會把所有的安裝與 fuzzing 流程精簡到單一指令即可完成。

下載難以處理的 PDF

在先前臨時的 fuzzing 過程中,我是手動從美國國稅局(IRS)網站下載 PDF。而現在,我要先將這個步驟自動化。

既然要自動化,只用單一 PDF 顯然不夠理想。對於 fuzzing 來說,我的目標是擁有種類盡可能豐富的 PDF,以便觸及 PDF 檔案格式的各個部分。

Adobe 曾經提供一批看起來很有趣的測試用 PDF 語料庫,但該資源已經下線。

我找到最適合的難解 PDF 集合,是 Mozilla 的 pdf.js 專案。它包含了 700 份 PDF,這些檔案都曾在該專案的工具中引發解析錯誤,因此很可能同樣會讓其他 PDF 解析器出錯。

我在 Nix flake 中新增一個建置步驟,用來下載 Mozilla pdf.js 專案中的所有 PDF:

{
    packages = rec {
        ...
        sample-pdfs = pkgs.stdenv.mkDerivation rec {
          pname = "sample-pdfs";
          version = "4.7.76";
          src = pkgs.fetchzip {
            url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
            hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
          };
          buildCommand = ''
            mkdir -p $out
            cp $src/test/pdfs/*.pdf $out
          '';
        };

到這個階段,flake.nix 應該會長這樣

我用以下指令執行新的 sample-pdfs 建置步驟:

nix build .#sample-pdfs

接著,我驗證 Nix 是否已成功下載 700 份 PDF:

$ ls ./result | head -n 5
160F-2019.pdf
alphatrans.pdf
annotation-border-styles.pdf
annotation-button-widget.pdf
annotation-caret-ink.pdf

$ ls ./result | wc --lines
700

這個新的建置步驟為我提供了由邊界案例 PDF 組成的初始語料庫,應該能觸及 PDF 解析程式中較少執行的程式碼路徑。

下載更多棘手的 PDF

除了 PDF 檔案本身,pdf.js 儲存庫中還包含了數百個 .link 檔案,裡面存放著外部 PDF 的網址。

我一時想不出如何從這些 .link 檔案的網址下載 PDF,因為 mkDerivation 階段會封鎖網路存取。我雖然可以使用 fetchUrl 指令,但那樣就得寫上好幾百行。

Anton Mosich(安東·莫西奇)教了我一個優雅的方法,可以一次下載所有 .link 檔案指向的 PDF。他告訴我,只要指定 outputHashoutputHashModeoutputHashAlgo,Nix 就會放寬限制,允許在建置過程中存取網路。

我一開始嘗試用 curl 逐一下載每個檔案,但速度慢到令人卻步,因為它是循序下載。我找到一個名為 aria2c 的工具,它可以平行下載網址,讓過程快上許多:

{
    packages = rec {
        ...
        sample-pdfs = pkgs.stdenv.mkDerivation rec {
          pname = "sample-pdfs";
          version = "4.7.76";
          src = pkgs.fetchzip {
            url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
            hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
          };
          nativeBuildInputs = [
            pkgs.aria2
          ];
          SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
          buildPhase = ''
            # Extract the URLs and filenames from .link files into an input
            # file of URLs for aria2c.
            url_file=$(mktemp)
            for pdf in $src/test/pdfs/*.pdf.link; do
              url=$(sed 's/\r$//' "$pdf")
              filename=$(basename "$pdf" .link)
              echo "$url" >> "$url_file"
              echo "  out=$filename" >> "$url_file"
            done
            aria2c \
                --input-file="$url_file" \
                --max-tries=5 \
                --retry-wait=20 \
                --auto-file-renaming=false \
                --max-concurrent-downloads=5 \
                --max-connection-per-server=1 \
                --dir=.
            cp $src/test/pdfs/*.pdf .
          '';
          installPhase = ''
            mkdir -p $out
            cp -r . $out
          '';
          # We need to specify the output hash so that Nix allows Internet
          # access during the build.
          outputHash = "sha256-lcPF6AQNVsXH2RIiyGZQpp5VjcaBhtolQxmbqSduCNs=";
          outputHashMode = "recursive";
          outputHashAlgo = "sha256";
        };

我用 nix build 執行更新後的 sample-pdfs 步驟:

nix build .#sample-pdfs

如果我檢查 ./result 目錄,會發現現在的檔案數量比只複製儲存庫內 PDF 檔案時多了 437 個:

$ ls ./result | wc --lines
1137

自動化 fuzz 執行

在本系列的第一篇中,我展示了如何在 Nix dev shell 中手動執行 honggfuzz。我可以透過在 Nix flake 中定義一個 fuzzer(模糊測試器)的啟動指令,讓這個過程更加簡便。

首先,我新增一個用來啟動 honggfuzz 的 shell 指令稿:

{
    packages = rec {
      xpdf = pkgs.stdenv.mkDerivation rec {
        ...
      }
      fuzz-xpdf = pkgs.writeShellScriptBin "fuzz-xpdf" ''
        readonly CORPUS_DIR='fuzz-corpus'
        mkdir -p "$CORPUS_DIR"
        # Copy the source corpus into a new directory for active fuzzing.
        cp --force ${sample-pdfs}/*.pdf "$CORPUS_DIR"
        ${pkgs.honggfuzz}/bin/honggfuzz \
          --input "$CORPUS_DIR" \
          --instrument \
          --timeout 10 \
          -- ${xpdf}/bin/pdftotext ___FILE___
      '';

我用 nix build 建置這個 shell 指令稿:

nix build .#fuzz-xpdf

接著 Nix 會在 ./result/bin/fuzz-xpdf 產生一個 bash 指令稿:

$ cat ./result/bin/fuzz-xpdf
#!/nix/store/1xhds5s320nfp2022yjah1h7dpv8qqns-bash-5.2p32/bin/bash
readonly CORPUS_DIR='fuzz-corpus'
mkdir -p "$CORPUS_DIR"
# Copy the source corpus into a new directory for active fuzzing.
cp --force /nix/store/gncc6jy3cry5lwbkd2b54h1dg46wfkdc-sample-pdfs-4.7.76/*.pdf "${CORPUS_DIR}"
/nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/honggfuzz \
  --input "$CORPUS_DIR" \
  --instrument \
  -- /nix/store/pixq8qiqyy6iwsc4wisb1vrmgy7l1kas-xpdf-4.05/bin/pdftotext ___FILE___

在這個 shell 指令稿中,Nix 已將所有的 Nix 變數替換為絕對路徑。而 bash 變數(CORPUS_DIR)則保留為符號,讓 bash 能在指令稿執行時進行解析。

如果我執行這個 shell 指令稿,就會啟動一個新的 fuzzing 工作階段:

./result/bin/fuzz-xpdf

該指令應該會產生如下畫面:

我現在可以透過啟動器指令稿執行 honggfuzz

這樣雖然可行,但每次執行 shell 指令稿之前,都得記得先執行 nix build 指令。Nix 透過其 apps 功能提供了更簡單的解法。

我在 Nix flake 的 packages 區段之後,加入 apps 定義:

{
    packages = rec {
        ...
    };
    apps = {
      default = self.apps.${system}.fuzz-xpdf;
      fuzz-xpdf = {
        type = "app";
        program = "${self.packages.${system}.fuzz-xpdf}/bin/fuzz-xpdf";
      };
    };

有了 fuzz-xpdf 這個 app,我就可以用單一指令啟動 fuzzing:

nix run .#fuzz-xpdf

我已將 fuzz-xpdf 宣告為這個 flake 的預設 app,因此其實可以用更簡單的指令:

nix run

到這個階段,flake.nix 應該會長這樣

至此,fuzzing 工作流程就已經完成。

我可以把這個 Nix flake 放到一個全新的目錄下,執行 nix run 時,它就會下載所有棘手的 PDF、編譯 xpdf,並開始 fuzzing。我可以讓 honggfuzz 無限期地執行,看看它能發現哪些當機案例。

在 Nix flake 中加入 fuzz-xpdf app 後,我就擁有完整的 fuzzing 工作流程。我可以讓 fuzzer 無限期執行,讓它自行找出錯誤。

使用 ASAN 將細微的記憶體錯誤變成明顯的當機

到這個階段,我的 fuzzing 工作流程已經可以運作,但還可以讓它更有效率。

進行 fuzz testing 時,只有當目標應用程式當機,你才會知道找到了值得關注的錯誤。問題在於,有很多方式可以讓程式行為異常,卻不會導致當機。

最著名的無當機安全性錯誤範例之一,就是 2014 年 OpenSSL 的 Heartbleed 漏洞。它讓攻擊者能從網頁伺服器竊取敏感資訊,卻不會造成伺服器當機。誘使程式讀寫超出預期邊界的記憶體,並不一定會導致當機。

好消息是,有一個工具可以強迫那些原本不會當機的記憶體錯誤立刻讓程式當機。Address Sanitizer(位址消毒器) (ASAN) 會為程式的記憶體讀寫加上額外的安全檢查,一旦程式嘗試讀寫超出變數記憶體範圍,就會當機並輸出除錯資訊。

在 fuzzing 工作流程中加入 ASAN,能讓我找到比原本更多的記憶體錯誤。若要以啟用 ASAN 的方式編譯 xpdf,我在 xpdf 的編譯步驟中加入 -fsanitize=address

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            export CC=${pkgs.honggfuzz}/bin/hfuzz-clang
            export CXX=${pkgs.honggfuzz}/bin/hfuzz-clang++
            # Use address sanitizer (ASAN).
            export CFLAGS="$CFLAGS -fsanitize=address"
            export CXXFLAGS="$CXXFLAGS -fsanitize=address"
          '';
        };

到這個階段,flake.nix 應該會長這樣

現在,我終於準備好啟動 fuzzer,讓它幫我找出錯誤。透過 Nix flake,只要執行以下指令即可:

nix run

找到第一次當機

我讓 honggfuzz 執行了兩個小時,回來查看時發現它已發現第一次當機:

honggfuzz 將導致當機的 PDF 儲存在一個名為:

  • SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz

為了重現當機,我執行了以下指令:

# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Rebuild pdftotext in the result folder
nix build

# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null

程式確實當機了,ASAN 回報它捕捉到緩衝區溢位:

==1259902==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x557c3230bd58 bp 0x7ffd7f070cf0 sp 0x7ffd7f070ce8
READ of size 1 at 0x60200002228f thread T0
    #0 0x557c3230bd57  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
    #1 0x557c3231f6ea  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
    #2 0x557c32327a0d  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)
    #3 0x557c3232733c  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ff33c)
    #4 0x557c322bfaa7  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x397aa7)
...
SUMMARY: AddressSanitizer: heap-buffer-overflow (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
Shadow bytes around the buggy address:
  0x602000022000: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fa
  0x602000022080: fa fa fd fd fa fa fd fd fa fa 00 01 fa fa fd fd
  0x602000022100: fa fa 00 03 fa fa fd fa fa fa 00 00 fa fa fd fa
  0x602000022180: fa fa fd fa fa fa fd fd fa fa 00 02 fa fa fd fa
  0x602000022200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa 00 00
=>0x602000022280: fa[fa]00 fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000022300: fa fa fd fa fa fa fd fa fa fa 03 fa fa fa fd fa

ASAN 的錯誤訊息有點艱澀,但它是在告訴我,ASAN 捕捉到 pdftotext 試圖在已配置給 pdftotext 程式碼的緩衝區之外讀取 1 個位元組。

那麼,我該如何更深入地探究造成這次當機的原因呢?

改善除錯符號

pdftotext 當機時,我原本期望能看到包含原始碼檔名與行號的堆疊追蹤。結果輸出卻只有二進位位移,讓除錯變得更加困難:

#0 0x557c3230bd57  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
#1 0x557c3231f6ea  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
#2 0x557c32327a0d  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)

奇怪的是,整個過程中最困難的部分,竟然是弄清楚如何讓除錯符號正常運作,以便在當機傾印中看到原始碼資訊。

首先,我偶然注意到 nix run 的日誌輸出中提到,會從二進位檔中去除除錯資訊。原來 Nix 有一個一個 dontStrip 選項,預設值為 false,表示它會自動去除除錯資訊。

我也注意到 xpdf 的編譯說明提到了 CMAKE_BUILD_TYPE 選項。雖然沒有文件說明,但搜尋原始碼後發現它接受 Debug 這個值

為了在 xpdf 二進位檔中保留除錯符號,我在 xpdf 套件定義的末尾加入這些選項:

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            ...
          '';
          cmakeFlags = [
            "-DCMAKE_BUILD_TYPE=Debug"
          ];
          # Don't strip debug information from binaries, as the debug symbols
          # are usefule during crash analysis.
          dontStrip = true;
        };

到這個階段,發生了一件奇怪的事。我一度得到了包含檔名與行號的完整堆疊追蹤,但幾小時後它們又莫名其妙地失效了。至今我仍不知道原因。

為了讓完整的堆疊追蹤能穩定運作,我必須使用一個我之前從未聽過、名為 llvm-symbolizer 的工具。幸好,llvm-symbolizer 包含在熱門的llvm_18 Nix 套件中,因此我在 Nix flake 中加入該套件,並新增一個名為 ASAN_SYMBOLIZER_PATH 的環境變數來指向該二進位檔。

由於對 Nix flake 的修改分散在檔案的多個不同位置,不容易直接展示,因此最簡單的方式是查看差異比較

完成對 Nix flake 的修改後,我需要進入 Nix dev shell 才能看到完整的堆疊追蹤,因為這會設定正確的 ASAN_SYMBOLIZER_PATH 環境變數值。

# Enter the nix dev shell.
nix develop

# Rebuild pdftotext in the result folder
nix build

# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null

接著,我終於應該能看到包含檔名的堆疊追蹤:

=================================================================
==1461608==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /build/source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /build/source/xpdf/GfxFont.cc:553:20
    #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /build/source/xpdf/GfxFont.cc:1732:3
    #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /build/source/xpdf/GfxFont.cc:190:16
...
    #22 0x7ffff7a7110d in __libc_start_call_main (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a10d) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
    #23 0x7ffff7a711c8 in __libc_start_main@GLIBC_2.2.5 (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a1c8) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
    #24 0x555555698a04 in _start (/nix/store/x59ccyx8gz0ap74zapdi7k8ssgypmipm-xpdf-4.05/bin/pdftotext+0x144a04)

成功了!現在我能看到原始碼檔名、行號和函式名稱。

但仍有一個問題。看看任何一個檔案的路徑。

/build/source/xpdf/GfxFont.cc
^^^^^^^^^^^^^
  Where is this coming from?

xpdf 的原始碼全都指向一個名為 /build/source 的根目錄,但我的系統上並不存在這個路徑:

$ ls /build/source
ls: cannot access '/build/source': No such file or directory

我不確定 /build/source 這個路徑是 Nix 的特殊行為,還是 xpdf 建置設定的緣故。編按:此前綴來自 Nix 的sandbox-build-dir 選項,該選項定義了從原始碼建置時的根目錄。感謝 Dionysis Grigoropoulos(狄奧尼西斯·格里戈羅普洛斯)的指正。)

我唯一能修復 /build/source 前綴的方法,是用一個有點醜陋的 hack,在編譯時使用 clang 的 -fdebug-prefix-map 旗標,將錯誤的路徑替換為正確的路徑:

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            ...
            # For some reason, without these flags, the debug symbols point to
            # source files at the base filesystem /build/source, so we
            # manually fix the source path.
            export CXXFLAGS="$CXXFLAGS -fdebug-prefix-map=/build/source=${xpdf.src}"
          '';

如果我重新執行 nix build 流程,堆疊追蹤終於正確了:

==1498830==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
    #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:1732:3
    #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:190:16
    #4 0x55555594a065 in GfxFontDict::load(char*, GfxFontDictEntry*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:2393:12

如果我把該檔案路徑丟給 sed,它會印出檔案內容:

$ sed -n '548,558p' /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc
      i -= 2;
    } else if (i > 7 && !strncmp(name->getCString() + i - 7, "Oblique", 7)) {
      flags |= fontItalic;
      i -= 7;
    }
    char c = name->getChar(i-1);
    if (!((c >= 'A' && c <= 'Z') ||
          (c >= 'a' && c <= 'z') ||
          (c >= '0' && c <= '9'))) {
      --i;
    }

到這個階段,flake.nix 應該會長這樣

理解當機原因

我現在有一個會穩定導致當機的越界記憶體讀取。我已經擁有理解這個錯誤所需的所有除錯資訊,是時候深入研究原始碼了。

堆疊追蹤的最頂端指向這一行

// goo/GString.h

// Get <i>th character.
char getChar(int i) { return s[i]; }

好,s 是一個 char 緩衝區,所以它很可能存放著 C 風格的字串。getChar 函式並未進行任何邊界檢查,以確保呼叫者傳入的 i 是合法值,因此該函式會讀取超出為 s 配置的緩衝區以外的記憶體。

我往回追一層,查看當機前 getChar 是如何被呼叫的。堆疊追蹤的下一行指向這裡:

// xpdf/GfxFont.cc

void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
  ...
  // scan font name for bold/italic tags and update the flags
  if (name) {
    i = name->getLength();
    if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
      i -= 2;
    }
    ...
    char c = name->getChar(i-1); // <<< CRASH

好,這其實是一個相當單純的錯誤。

readFontDescriptor 會檢查 name 是否不是 NULL,但它假設其長度至少為 1。如果 name 是空字串(長度為 0),那麼 getChar 的呼叫就會變成 name->getChar(-1)。接著,getChar 會回傳 s[-1],也就是在為 s 配置的記憶體緩衝區之前 1 個位元組的位置。

當機時的除錯輸出也支持我的假設:

==241578==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002360f at pc 0x55555592fff5 bp 0x7fffffffa650 sp 0x7fffffffa648
READ of size 1 at 0x60200002360f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
...
SUMMARY: AddressSanitizer: heap-buffer-overflow /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32 in GString::getChar(int)
Shadow bytes around the buggy address:
...
=>0x602000023600: fa[fa]00 fa fa fa fd fd fa fa fd fa fa fa fd fa

ASAN 表示這是一次大小為 1 的非法讀取,這很合理,因為 getChar 試圖讀取單一個 1 位元組的字元。

ASAN 也顯示了它讀取包含 fa 位元組之記憶體位址時的記憶體配置。該位元組緊鄰在包含 00 的位址之前。變數 s 存放的是空字串,C++ 在記憶體中以 00 來表示,因此 pdftotext 試圖讀取空字串前一個位元組的資料。這次讀取是非法的,因為該位置包含的並非分配給變數 s 的資料。

修復錯誤

如果我的假設正確,這個錯誤應該很容易修復。在 readFontDescriptor 中,我可以將這一行:

  if (name) {

改成這樣:

  if (name && (name->getLength() > 0)) {

這樣就能確保該函式將空字串視為與空指標相同,不再進一步處理。

為了驗證我的假設,我將建立一個包含此修改的修補檔,重新編譯 xpdf,然後再用同一個 PDF 對 pdftotext 進行測試,看看修復是否能防止同樣的當機。

xpdf 作為一個開放原始碼專案有點特別,它並未發布 Git 儲存庫,只會定期釋出 tarball。不過沒關係,我會自行建立一個臨時的 Git 儲存庫,作為編輯程式碼的工作區:

ORIGINAL_SRC="$(nix eval --raw .#xpdf.src.outPath)"
MODIFIED_SRC="$(mktemp --directory)"

pushd "${MODIFIED_SRC}" && \
  cp --recursive --verbose $ORIGINAL_SRC/* . && \
  chmod -R u+w . && \
  git init && \
  git add --all && \
  git commit --message "Dummy base commit"

現在,我在一個全新的 Git 儲存庫中擁有一份 xpdf 原始碼的副本。我編輯 GfxFont.cc 來套用修復:

vim xpdf/GfxFont.cc

當我完成編輯並儲存檔案後,我呼叫 git diff 來產生修補檔,其內容如下:

diff --git a/xpdf/GfxFont.cc b/xpdf/GfxFont.cc
index c3db4e8..7074354 100644
--- a/xpdf/GfxFont.cc
+++ b/xpdf/GfxFont.cc
@@ -535,7 +535,7 @@ void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
   obj1.free();
   // scan font name for bold/italic tags and update the flags
-  if (name) {
+  if (name && (name->getLength() > 0)) {
     i = name->getLength();
     if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
       i -= 2;

我將修補檔複製回我的 fuzz testing 目錄:

# Create a patch file for the fix.
git diff > check-font-name-length.patch

# Get back to fuzz-xpdf git repo.
popd

mv "${MODIFIED_SRC}/check-font-name-length.patch" .

將修補檔放到與 flake.nix 相同的目錄後,我更新 Nix flake,指示它在編譯 xpdf 時套用我的自訂修補檔:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    src = pkgs.fetchzip {
      url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
      extension = "tar.gz";
    };
    # Add a custom patch to fix the font name length bug.
    patches = [
      ./check-font-name-length.patch
    ];

到這個階段,flake.nix 應該會長這樣

我已經準備好測試修復。我使用修補檔重新編譯 xpdf,並用先前導致當機的 PDF 來執行:

# Specify the path to the crashing PDF.
$ CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Rebuild pdftotext in the result folder
$ nix build

# Run pdftotext with the crashing PDF.
$ ./result/bin/pdftotext "${CRASHING_PDF}"
Syntax Error: Couldn't read xref table
Syntax Warning: PDF file is damaged - attempting to reconstruct xref table...
Syntax Error (2800): Bad dynamic code table in flate stream
Syntax Error (2800): Bad block header in flate stream
Syntax Error (2158): Dictionary key must be a name object
Syntax Error: Unterminated string
Syntax Error: Leftover args in content stream

看!pdftotext 回報了許多錯誤,但程式再也沒有當機。我的修復成功了。

在找到修復方法後,我才發現早在幾週前就有人回報過同樣的弱點,不過這次練習仍然很有收穫。

總結

我發現 Nix 是建立 fuzz testing 工作流程的絕佳工具。

要弄清楚 Nix 在 fuzzing 方面的各種細節確實花了一些功夫,但現在要替換成不同的 PDF 解析器或使用不同的 fuzzer 應該都很容易。Nix 的美妙之處在於它具有良好的可組合性,因此在工作流程中替換不同元件非常輕鬆。

這個專案最終成為深入學習 fuzzing 與 Nix 的絕佳方式。過去一年我一直在摸索 Nix,而以這種方式使用 Nix,幫我釐清了許多原本模糊的概念。

原始碼

我的 fuzzing 工作流程完整原始碼可在 Gitlab 上取得:


摘自 xpdf 的內容係依 GPLv3 授權使用。感謝安東·莫西奇對本文的協助。

原文由 Michael Lynch 發布

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