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

Michael Lynch

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

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

這篇文章的下半部延續了前一篇關於使用 Nix 自動化模糊測試工作流程的主題。

到目前為止,我已經可以用 honggfuzz 來測試 pdftotext,但啟動過程還需要一些手動操作。我在第一篇曾承諾,要把安裝與模糊測試的全部流程濃縮成單一指令。

下載難以處理的 PDF

在先前的臨時模糊測試中,我是手動從美國國稅局網站下載 PDF 來用的。接下來,我要先把這個步驟自動化。

既然要自動化,只用單一 PDF 實在有點可惜。做模糊測試時,我的目標是收集種類盡可能多元的 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.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

自動化模糊測試的執行

在這個系列的第一篇中,我示範了如何從 Nix 開發環境手動執行 honggfuzz。我可以透過在 Nix flake 中定義一個模糊測試器的啟動指令,讓這個過程變得更簡單。

首先,我新增一個用來啟動 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 腳本,就會啟動一個新的模糊測試任務:

./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,我就可以用單一指令啟動模糊測試:

nix run .#fuzz-xpdf

我已將 fuzz-xpdf 設為這個 flake 的預設 app,所以實際上可以用更簡單的指令:

nix run

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

至此,模糊測試的工作流程就大功告成了。

我可以把這個 Nix flake 放到一個全新的目錄中,只要執行 nix run,它就會下載所有難以處理的 PDF、編譯 xpdf,然後開始模糊測試。我可以讓 honggfuzz 無限期地跑下去,看看它會找出哪些當機問題。

在 Nix flake 中加入 fuzz-xpdf app 後,我就擁有了一套完整的模糊測試工作流程。我可以讓模糊測試器一直執行,讓它自行找出錯誤。

用 ASAN 把隱蔽的記憶體錯誤變成明顯的當機

到目前為止,我的模糊測試工作流程已經可以運作,但還能跑得更有效率。

做模糊測試時,只有當目標應用程式當機,你才會知道自己找到了一個值得關注的錯誤。問題在於,有很多方式能讓程式行為異常,卻不一定會導致當機。

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

好消息是,有個工具能讓那些原本不會當機的記憶體錯誤立刻造成當機。Address Sanitizer(ASAN)會在程式的記憶體讀寫中加入額外的安全檢查,一旦程式試圖讀寫超出變數記憶體範圍的位置,就會立刻當機並輸出除錯資訊。

在模糊測試工作流程中加入 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 應該會長這樣

現在,我終於準備好要啟動模糊測試器,讓它幫我找錯誤了。有了我的 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 試圖讀取超出已配置緩衝區 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 的這些修改分散在檔案的多個不同地方,不容易直接展示,所以直接看 diff 會最清楚。

修改完 Nix flake 後,我需要進入 Nix 開發環境才能看到完整的堆疊追蹤,因為那樣才會設定好正確的 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 前綴的方法,是一個有點醜陋的權宜之計:在編譯時使用 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)) {

這樣就能確保函式會把空字串和空指標(null pointer)同等對待,不再繼續處理。

為了驗證我的推測,我會建立一個包含這項修改的修補檔,重新編譯 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;

我把修補檔複製回我的模糊測試目錄:

# 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 是打造模糊測試工作流程的絕佳工具。

雖然要搞懂 Nix 用於模糊測試的各種細節花了一些功夫,但現在要換成不同的 PDF 解析器或不同的模糊測試器應該就很容易了。Nix 的美妙之處在於它具有很好的可組合性,因此在工作流程中替換不同元件非常輕鬆。

這個專案最終成為深入學習模糊測試與 Nix 的好方法。過去一年我斷斷續續地接觸 Nix,但透過這樣的方式使用 Nix,幫助我釐清了許多原本模糊不清的概念。

原始碼

我的模糊測試工作流程的完整原始碼可在 Gitlab 上取得:


節錄自 xpdf 的內容係依GPLv3 授權條款使用。感謝 Anton Mosich 對本文的協助。

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

留言