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

Michael Lynch

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

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

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

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

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

解決策のプレビュー

最終的にできたものを先にお見せしましょう。次の1つのコマンドで、オープンソースのPDFリーダーのファズテストを開始できます。

nix run gitlab:mtlynch/fuzz-xpdf

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

pdftotextをファズテスト中のhonggfuzzのターミナルUIのスクリーンショット

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

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

  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のようなものです。ビルドステップとその依存関係を定義でき、ビルドを要求すると、要求した成果物を作るために必要なすべてのステップを実行してくれます。

必要なもの

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

ファズテスト対象の選定

今回ファズテストするPDFリーダーは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パッケージやアプリケーションを定義するものです。

現時点では、これはNix flakeのボイラープレートの骨組みにすぎません。次の1行を除けば、特筆すべき点はほとんどありません。

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

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

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

ソースのtarballを指定する

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

まず、Nixでビルドコンポーネントを定義する方法であるmkDerivationを呼び出します。これにはパッケージ名(pname)とバージョンが必要なので、ファズテストしたいパッケージである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を実行すると、Nixがハッシュが間違っているとエラーを出します。

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=

ハッシュの不一致を解消するため、エラーメッセージに表示された値を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 or newer
  • FreeType 2.0.5 or newer
  • Qt 5.x or 6.x (for xpdf only)
  • libpng (for pdftopng and pdftohtml)
  • zlib (for pdftopng and 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のバイナリをビルドしたのか不思議に思ったなら、私も同じでした。

私はNixにxpdfのビルド手順を教えた覚えすらないのに、なぜわかったのでしょうか?

実は、私が呼び出したNixのmkDerivation関数は、標準的なmakeのビルドプロセスを前提としているのです。

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

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

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

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

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

暗黙的に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が使えるようになりましたが、CMakeにこれまで使っていたコンパイラの代わりにhonggfuzzのコンパイラを使うよう、どう伝えればよいでしょうか?

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

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++
      '';
}

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

開発シェルでのアドホックなファジング

honggfuzzのコンパイラを使ってxpdfをコンパイルできましたが、いよいよ本番の楽しい部分に入りたいと思います。

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チュートリアルシリーズに感謝します。本記事は同シリーズに基づいています。

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

コメント