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

Michael Lynch

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

ファズテストは、ソフトウェアに潜むバグを自動的に見つけ出すための手法です。問題は、環境構築が非常に面倒なことにあります。ファズテストのチュートリアルをどれを読んでも、最初の1時間はツールをソースからビルドし、依存関係のまた依存関係を追いかける作業に費やすことになります。

私は最近、Nixを使うとファズテストの面倒な準備作業の多くを省けることに気づきました。単一のコマンドでファズテストのワークフローを立ち上げられるNixの設定を作ったのです。必要なのはNixとgitだけです。

このNixワークフローを使って、Nixもファズテストも初心者ながら、PDFレンダラーに残っていた未修正のバグを見つけることができました。

解決策のプレビュー

まずは最終的な成果をご紹介します。次の1行のコマンドで、オープンソースのPDFリーダーのファズテストを開始できます。

nix run gitlab:mtlynch/fuzz-xpdf

このコマンドはNixがインストールされたLinuxシステムならどれでも動くはずで、おそらくmacOSでも動作します。数分間のビルドの後、次のようなターミナルUIが表示されます。

honggfuzzのターミナルUIのスクリーンショット。pdftotextをファズテスト中の進捗を表示している

Nixを使えば、すべての依存関係のインストールからファズテストの開始までを1つのコマンドで実行できます。

上記のコマンドを実行すると、内部では次のことが行われます。

  1. Nixが必要なツールと、PDFリーダーおよびテスト用ツールチェーンの依存関係をすべてダウンロードします。
  2. Nixがファズテスト用に適切に計測(インストルメンテーション)された状態で、PDFリーダーをソースからコンパイルします。
  3. Nixがテスト入力生成用のエッジケースPDF一式をダウンロードします。
  4. Nixが自動的に新しいPDFを生成し、PDFリーダーに与え、クラッシュを引き起こした入力を報告します。

ファズのオプションを変更したり、別のバージョンのPDFリーダーをテストしたい場合も、1つのファイルを編集するだけで済みます。

この記事では、このファズテスト用ワークフローをどのように作ったのかを順を追って解説します。同じ手法を使えば、他のプロジェクトでもバグを見つけられます。

せっかちな方は、最後まで飛ばして最終的な成果物をご覧ください。

ファズテストとは?

ファズテスト、すなわち「ファジング」とは、ランダムに生成した入力データを与え、その入力によって対象アプリケーションがクラッシュするかどうかを確認することで、ソフトウェアのバグを見つける手法です。

たとえば、JPEG画像をリサイズするプログラムをテストする場合、ワークフローは次のようになります。

  1. 有効なJPEGファイルや壊れたJPEGファイルのセットを用意します。
  2. 入力ファイルの中からランダムに1つを選び、ランダムに変異させます(ビットを反転させたり、データを追加・削除したりします)。
  3. 変異させた入力ファイルを画像リサイズプログラムに与えます。
  4. 変異した入力によってプログラムがクラッシュしたりハングしたりしたら、その入力を後で解析できるように保存します。
  5. ステップ(2)に戻ります。

Nixとは?

Nixは非常に多機能なツールで、正直なところ私自身もすべての機能を理解しているわけではありません。

この記事を読むうえでは、Nixについて次の2点だけ理解しておけば十分です。

  • Nixはパッケージマネージャーですaptyumのようなものです。Nixの環境内では10万件以上のパッケージが利用できます。
  • NixはビルドツールですmakeDockerのようなものです。Nixでは一連のビルド手順とその依存関係を定義できます。Nixにビルドを要求すると、要求した成果物を作るために必要なすべての手順が実行されます。

必要なもの

この記事の内容を試すのに必要なものは、次の2つだけです。

ファズ対象の選定

今回ファズテストするPDFリーダーはxpdfというものです。xpdfはPDFビューアですが、PDFユーティリティ一式が付属しています。その中の1つであるpdftotextは、ファズテストの対象として非常に適しています。GUIがなく、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パッケージやアプリケーションのセットを定義するものです。

現時点では、これはまだ雛形の骨組みに過ぎません。ここで注目すべきなのは次の1行以外、特に解説する必要はありません。

nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

この記述は、パッケージを取得する際に、パッケージリポジトリの2024年5月のブランチ、つまり執筆時点での最新の安定版ブランチから取得することをNixに伝えています。

このファイルはまだ骨組みだけで、このままではビルドは成功しません。Nixでxpdfをコンパイルするには、もう少し手を加える必要があります。

ソースのtarballを指定する

xpdfをコンパイルするには、ソースコードのコピーが必要です。

まず、Nixがビルドコンポーネントを定義する方法であるmkDerivationを呼び出します。ここではパッケージ名(pname)とバージョンが必須なので、ファズ対象のパッケージであるxpdfと、執筆時点でのxpdfの最新公開バージョンである4.05を指定します。

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    pname = "xpdf";
    version = "4.05";
    ...

mkDerivationで必須となるもう1つのフィールドはsrcプロパティで、ビルドの入力をどのように取得するかをNixに伝えます。xpdfの場合、ソースのtarballは次のURLにあります。

将来的にバージョン番号が変わってもURLがそのまま使えるように、pnameversion変数を使ってxpdfのtarballのURLを指定します。

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    src = pkgs.fetchzip {
      url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
      extension = "tar.gz";
    };

問題は、Nixがローカルのバージョンとサーバー上のものが一致するかを判定するためにtarballのハッシュを必要とすることです。この状態でnix buildを実行すると、ハッシュが間違っていると叱られます。

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はソースからのビルドを2つの点で大幅に簡素化します。

  • Nixはどのパッケージマネージャーよりも大規模なパッケージリポジトリを持っており、必要なパッケージのほとんどがすでに用意されています。
  • NixのパッケージはOSのバージョンに縛られません。そのため、アーキテクチャに対応した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(米国内国歳入庁)のウェブサイトからForm 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のバイナリをビルドしたのか混乱しているなら、私も同じでした。

xpdfのビルド手順すらNixに教えていないのに、どうやって分かったのでしょうか。

実は、呼び出したNixのmkDerivation関数は、標準的なmakeのビルド手順を前提としているのです。

標準的な./configure; make; make installというビルド手順を使うUnixパッケージであれば、ビルドスクリプトをまったく書く必要はありません。標準環境がすべてを自動的に行います。stdenvが自動的に行う処理で足りない場合は、各種ビルドフェーズを簡単にカスタマイズしたり上書きしたりできます。

「The Standard Environment」(Nixマニュアルより)

それでも、少し出来すぎているように感じました。

xpdfの手順には、FreeTypeのヘッダーやライブラリの場所をコンパイラに教える必要があると書かれています。私はそんなことをしていないのに、なぜNixはプロジェクトをコンパイルできたのでしょうか。

また、make installは通常/usr/binのようなシステム全体のディレクトリに書き込みますが、sudoで権限昇格していないのに、どうやってそれができたのでしょうか。

私は、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/bin

Nixのこの挙動は諸刃の剣です。うまくいけば、こちらが手取り足取り指示しなくてもビルド手順を理解してくれて魔法のように感じます。しかし、もしうまくいかなかった場合、Nixの不透明な抽象化を通して問題をデバッグしなければなりません。

honggfuzzでxpdfをコンパイルする

xpdfを正常にコンパイルできるようになったので、次はワークフローにファズテストの要素を導入します。

honggfuzzはGoogleがメンテナンスしているファズテストツールです。カバレッジガイド型のファザーで、特定のテスト入力に対して対象バイナリのどの部分が実行されたかを追跡します。バイナリに新たなコードパスを実行させる入力を発見すると、その入力に似た入力をさらに生成します。未テストの挙動に当たる可能性が高まるからです。

honggfuzzにはCおよびC++コンパイラが同梱されているため、honggfuzzでxpdfをコンパイルするのは、Nixのデフォルトコンパイラの代わりにhonggfuzzのコンパイラを指すようにするだけで済むはずです。そのために、まずnativeBuildInputsを修正して、コンパイル時に利用できるようにhonggfuzzパッケージを含めます。

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    nativeBuildInputs = with pkgs; [
      cmake
      honggfuzz
    ];
}

これでビルド環境内でhonggfuzzが利用できるようになりますが、以前使われていたコンパイラの代わりにhonggfuzzのコンパイラを使うよう、CMakeにどう伝えればよいでしょうか。

MakeやCMakeは、それぞれどのCコンパイラとC++コンパイラを使うかを指定するCCおよびCXX環境変数に従います。

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をビルドさせるには、CCCXX変数を正しいコンパイラのパスに向けます。

{
    xpdf = pkgs.stdenv.mkDerivation rec {
      ...

      preConfigure = ''
        export CC=${pkgs.honggfuzz}/bin/hfuzz-clang
        export CXX=${pkgs.honggfuzz}/bin/hfuzz-clang++
      '';
}

詳細出力付きでビルドすると、確かに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このようになっているはずです

開発シェルでアドホックにファズする

xpdfをhonggfuzzのコンパイラでコンパイルできましたが、ここからが本番です。

もちろん、Nix flake内にファズを開始するための気の利いたコマンドを用意することもできますが、現時点ではとにかく早く手を動かして試してみたいところです。そのために、必要なツールがすべて使えるNix開発シェルを作ります。

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パッケージのnativeBuildInputscmakehonggfuzz)に加え、PDFをダウンロードするために開発シェルでのみ使いたいwgetを指定したからです。

次に、ファズの結果を保存するディレクトリを作成します。今回は実験なので、一時ディレクトリを使います。

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を実行して、./result/binフォルダの下でpdftotextが実行できる状態であることを確認します。

$ 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がファズテストの進捗を表示するターミナルUI

やりました!このままhonggfuzzを数日間走らせて何かが引っかかるか見てもよいのですが、バグが見つかる確率を上げるために、ワークフローをもう少し磨き上げたいと思います。

次回:Nixを使ってxpdfの未修正バグを見つける

ここまでで、Nixとhonggfuzzを使ってxpdf PDFリーダーの基本的なファズテストを行う方法を解説しました。

次回の記事では、次の内容を紹介します。

  • 完全なファズ用ワークフローを自動化する
  • クラッシュを引き起こしやすい、厄介なPDFを集める
  • 最新バージョンのxpdfに潜む未修正のバグを見つける

続きは以下からご覧ください。


Antonio Morales氏が作成したFuzzing101チュートリアルシリーズを基にしています。感謝申し上げます。

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

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