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

Michael Lynch

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

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

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

手ごわいPDFを集める

これまでの場当たり的なファズテストでは、IRS(米国内国歳入庁)のサイトからPDFを手作業でダウンロードしていました。まずはこの手順を自動化するところから始めます。

どうせ自動化するなら、1つのPDFだけでなく、もっと良い方法があるはずです。ファズテストでは、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.jsのリポジトリには外部PDFのURLを記した.linkファイルが数百件含まれています。

mkDerivationのステップではインターネットアクセスがブロックされるため、.linkファイルのURLからPDFをどうダウンロードすればよいか分かりませんでした。fetchUrlコマンドを使う手もありますが、数百件分を書く必要があります。

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

最初はcurlで1ファイルずつダウンロードしてみましたが、逐次ダウンロードのため非常に遅くなってしまいました。そこで、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

ファズ実行の自動化

本シリーズの前編では、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機能という、さらにシンプルな解決策があります。

packagesセクションの後に、Nix flakeへ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

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

nix run

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

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

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

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

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

ここまででファズテストのワークフローは機能するようになりましたが、さらに効率よく実行できます。

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

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

幸い、通常はクラッシュしないようなメモリエラーを強制的に即時クラッシュさせるツールがあります。Address Sanitizer(ASAN)は、プログラムのメモリ読み書きに安全性チェックを追加し、変数に割り当てられたメモリ範囲を超えて読み書きしようとすると、デバッグ出力とともにクラッシュさせます。

ファズテストのワークフローにASANを組み込むことで、そうでなければ見逃していたであろうメモリバグをより多く見つけられるようになります。xpdfをASAN有効でコンパイルするには、コンパイルステップに-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への変更はファイルのあちこちに分散しているため、ここで示すのは困難です。diffを見るのが最も分かりやすいでしょう。

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プレフィックスを修正できた唯一の方法は、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"

これでxpdfのソースコードのコピーが新しいgitリポジトリに用意できました。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をいじってきましたが、今回のような形で使ったことで、これまで曖昧だった多くの概念がはっきりしました。

ソースコード

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


xpdfからの抜粋はGPLv3ライセンスの下で使用しています。この記事の執筆に協力いただいたAnton Mosichさんに感謝します。

原文は Michael Lynch により に公開されました。

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