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

Michael Lynch

Nixを使ってPDFパーサーをファズテストする(後編)

原文は Michael Lynch により に公開されました。 このブログを購読する

これは、Nixを使ってファズテストのワークフローを自動化する方法についての記事の後編です。

現時点ではhonggfuzzをpdftotextに対して実行できますが、開始するまでに少し手作業が必要です。前編では、インストールからファズテストまでのすべてを1つのコマンドにまとめると約束しました。

手強いPDFをダウンロードする

アドホックなファズテストでは、IRS(米国内国歳入庁)のウェブサイトからPDFを手動でダウンロードしました。まずはこの手順を自動化するところから始めます。

どうせ自動化するなら、PDFを1件だけでなく、もっと良い方法があるはずです。ファズテストでの目標は、PDFファイルフォーマットのさまざまな部分を実行させる、多種多様なPDFを用意することです。

Adobeは興味深いテスト用PDFのコーパスを公開していましたが、現在はオフラインになっています。

私が見つけた中で、解析が困難なPDFの最も優れたコレクションは、Mozillaのpdf.jsプロジェクトにありました。700件のPDFが含まれており、それらは同プロジェクトのツールで解析バグを引き起こしたものです。そのため、これらのPDFが他のPDFパーサーでも同様につまずく可能性は高いでしょう。

Mozillaのpdf.jsプロジェクトからすべてのPDFをダウンロードする新しいビルドステップを、Nix flakeに作成します。

{
    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リポジトリには外部PDFのURLを含む数百件の.linkファイルが含まれています。

mkDerivationのステップではインターネットアクセスがブロックされるため、.linkファイルのURLからPDFをダウンロードする方法がわかりませんでした。fetchUrlコマンドを使うこともできますが、何百件も書かなければなりません。

Anton Mosich氏が、すべての.linkファイルからPDFをダウンロードするエレガントな方法を教えてくれました。outputHashoutputHashModeoutputHashAlgoを指定すれば、Nixが制限を緩和してビルド中のインターネットアクセスを許可してくれるというのです。

当初はcurlで各ファイルをダウンロードしようとしましたが、ファイルを逐次的にダウンロードするため、あまりにも遅くて現実的ではありませんでした。URLを並列でダウンロードする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";
        };

更新したsample-pdfsステップをnix buildで実行します。

nix build .#sample-pdfs

そして./resultディレクトリを確認すると、リポジトリ内のPDFファイルだけをコピーしていたときよりも437件多いファイルがあることがわかります。

$ ls ./result | wc --lines
1137

ファズ実行の自動化

本シリーズのパート1では、Nixのdev shellからhonggfuzzを手動で実行する方法を紹介しました。Nix flake内でファザーの起動コマンドを定義すれば、このプロセスをさらに簡単にできます。

まずは、honggfuzzを起動するための新しいシェルスクリプトを追加します。

{
    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でビルドします。

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___

シェルスクリプト内では、Nixの変数はすべて絶対パスに置き換えられています。bash変数(CORPUS_DIR)はシンボルのまま残っているため、スクリプト実行時にbashが解釈できます。

このシェルスクリプトを実行すると、新しいファズテストセッションが開始されます。

./result/bin/fuzz-xpdf

このコマンドを実行すると、次のような画面が表示されるはずです。

ランチャースクリプトからhonggfuzzを実行できるようになりました

これは機能しますが、シェルスクリプトを実行するたびに毎回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アプリを用意したので、次の1つのコマンドでファズテストを開始できます。

nix run .#fuzz-xpdf

fuzz-xpdfをこのflakeのデフォルトアプリとして宣言したので、さらにシンプルなコマンドを使うこともできます。

nix run

この時点で、flake.nixこのようになっているはずです

これで、ファズテストのワークフローは完成です。

このNix flakeをまったく新しいディレクトリに置き、nix runを実行すれば、すべての手強いPDFをダウンロードし、xpdfをコンパイルして、ファズテストを開始してくれます。honggfuzzを無期限に実行し、どんなクラッシュが見つかるか見守ることができます。

Nix flakeにfuzz-xpdfアプリを追加したことで、完全なファズテストのワークフローができあがりました。ファザーを無期限に実行して、バグを見つけさせることができます。

ASANで微妙なメモリエラーを派手なクラッシュに変える

ここまでの時点でファズテストのワークフローは機能していますが、さらに効率的に実行できます。

ファズテストでは、対象のアプリケーションをクラッシュさせたときに初めて、興味深いバグを見つけたことがわかります。問題は、プログラムをクラッシュさせずに誤動作させる方法がたくさんあることです。

クラッシュを伴わないセキュリティバグの最も有名な例の一つが、2014年のOpenSSLのHeartbleedです。攻撃者がWebサーバーから機密情報を抽出することを可能にしましたが、サーバーをクラッシュさせることはありませんでした。意図された境界の外側のメモリを読み書きするようにプログラムを騙しても、必ずしもクラッシュするとは限らないのです。

良い知らせは、本来ならクラッシュしないようなメモリエラーを、即座にクラッシュさせるツールがあることです。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を2時間実行したままにしておき、確認しに戻ると、最初のクラッシュを発見していました。

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のエラーメッセージは少し難解ですが、pdftotextのコードが確保したバッファの外側の1バイトをpdftotextが読み取ろうとしたのをASANが捉えたことを示しています。

では、このクラッシュの原因をどうやってさらに深く掘り下げればよいのでしょうか。

デバッグシンボルを改善する

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を変更した後は、適切なASAN_SYMBOLIZER_PATH環境変数が設定されるため、詳細なスタックトレースを見るにはNix dev shellに入る必要があります。

# 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]; }

scharバッファなので、おそらく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

なるほど、これは実際にはかなり単純なバグです。

readFontDescriptornameNULLでないことはチェックしていますが、長さが少なくとも1であることを前提としています。nameが空文字列(長さ0)の場合、getCharの呼び出しはname->getChar(-1)と評価されます。するとgetChars[-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バイトの文字を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;

パッチをファズテスト用のディレクトリにコピーして戻します。

# 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と同じディレクトリに置いたので、xpdfのコンパイル時にカスタムパッチを適用するようNix flakeを更新します。

{
  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の両方についてより深く学ぶための素晴らしい機会になりました。この1年間、Nixをかじってきましたが、今回のような形でNixを使うことで、これまで曖昧だった多くの概念がはっきりと理解できるようになりました。

ソースコード

私のファズテストワークフローの完全なソースはGitLabで公開しています。


xpdfからの抜粋はGPLv3ライセンスの下で使用しています。この投稿への協力にAnton Mosich氏に感謝します。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント