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

Michael Lynch

用 Nix 对 PDF 解析器进行模糊测试(第一部分)

模糊测试(Fuzz Testing,模糊测试)是一种自动发现软件缺陷的技术。问题在于它的搭建过程非常麻烦。随便读一篇模糊测试教程,第一个任务往往就是花一个小时从源码构建工具,还要追查一层又一层的依赖。

我最近发现 Nix 能省去模糊测试中大量的繁琐工作。我创建了一个 Nix 配置,只需一条命令就能启动整个模糊测试工作流。唯一的依赖就是 Nix 和 git。

我用这个 Nix 工作流在一个 PDF 渲染器中发现了一个尚未修复的缺陷——尽管我在 Nix 和模糊测试两方面都只是初学者。

最终效果预览

先看看我的最终成果:你可以用一条命令开始对一个开源 PDF 阅读器进行模糊测试:

nix run gitlab:mtlynch/fuzz-xpdf

这条命令应该能在任何安装了 Nix 的 Linux 系统上运行,也许 MacOS 也可以。经过几分钟的构建后,你应该会看到类似这样的终端界面:

honggfuzz 终端界面的截图,显示对 pdftotext 进行模糊测试的进度

Nix 让我能用一条命令安装所有依赖并开始模糊测试。

运行上面的命令时会发生以下所有事情:

  1. Nix 下载 PDF 阅读器和测试工具链所需的全部工具和依赖。
  2. Nix 从源码编译 PDF 阅读器,并加入适合模糊测试的插桩(instrumentation)。
  3. Nix 下载一组边界情况 PDF 文件,用于生成测试输入。
  4. Nix 自动生成新的 PDF 文件,把它们喂给 PDF 阅读器,并报告哪些输入导致阅读器崩溃。

如果你想修改模糊测试选项或测试不同版本的 PDF 阅读器,只需编辑一个文件即可。

接下来我会一步一步分享我是如何创建这个模糊测试工作流的。你可以用同样的方法在其他项目中寻找缺陷。

如果你等不及了,可以直接跳到文末查看我的最终成果

什么是模糊测试?

模糊测试或“fuzzing”是一种通过随机生成输入数据、检查输入是否导致目标应用崩溃来发现软件缺陷的方法。

例如,要测试一个调整 JPEG 图片尺寸的程序,工作流程大致如下:

  1. 准备一组有效和/或畸形的 JPEG 文件。
  2. 随机选择其中一个输入文件并进行随机变异(翻转一些比特位、添加一些数据、删除一些数据)。
  3. 把变异后的输入文件喂给图片缩放程序。
  4. 如果变异后的输入导致程序崩溃或挂起,就保存该输入以供后续分析。
  5. 回到第 (2) 步。

什么是 Nix?

Nix 是一个复杂的工具,功能繁多,其中很多我自己也搞不明白。

就本文而言,你只需要了解 Nix 的两点就够了:

  • Nix 是一个包管理器,类似于 aptyum。Nix 拥有超过 10 万个可在 Nix 环境中运行的软件包。
  • Nix 是一个构建工具,类似于 makeDocker。Nix 允许你定义一组构建步骤以及它们之间的依赖关系。当你向 Nix 请求一次构建时,它会执行所有必要的步骤来生成你要的结果。

环境要求

要跟着本文操作,你只需要两样东西:

选择模糊测试目标

我要进行模糊测试的 PDF 阅读器叫作 xpdf。它是一个 PDF 查看器,但自带一套 PDF 工具。其中的一个工具 pdftotext 是个很有吸引力的模糊测试目标,因为它非常简单:没有图形界面,只接受 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 的样板骨架。大部分内容不值得讨论,除了这一行:

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

这一行告诉 Nix:当我想拉取软件包时,要从软件包仓库的 2024 年 5 月分支拉取,也就是撰写本文时的最新稳定分支。

这个文件目前只是个骨架,还无法成功构建。要用 Nix 编译 xpdf,我还需要添加一些内容。

指定源码压缩包

要编译 xpdf,我需要一份它的源代码。

首先,我调用 mkDerivation,这是 Nix 定义构建组件的方式。它需要一个包名(pname)和版本号,所以我指定了 xpdf——即我要进行模糊测试的软件包,以及 4.05——撰写本文时 xpdf 的最新发布版本。

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

mkDerivation 中另一个必填字段是 src 属性,它指定 Nix 应如何获取构建所需的输入。对于 xpdf 来说,源码压缩包位于这个 URL:

我用 pnameversion 变量来指定 xpdf 压缩包的 URL,这样将来版本号变化时 URL 依然有效:

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

问题是 Nix 需要压缩包的哈希值来判断本地版本是否与服务器上的一致。如果此时运行 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 或更新版本
  • 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 通过两种方式从根本上简化了从源码构建的过程:

  • Nix 拥有所有包管理器中最大的软件包仓库之一,所以我需要的大多数软件包都已经现成可用。
  • Nix 软件包不绑定任何操作系统版本,所以只要存在适用于我架构的 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 有一个恼人的坑:Nix 无法看到不在 git 版本控制之下的文件。如果你收到“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 不需要我手把手指导就自己搞定了构建流程。但如果它没正常工作,我就得透过 Nix 那些晦涩难懂的抽象层去调试问题了。

用 honggfuzz 编译 xpdf

既然我已经能成功编译 xpdf 了,现在是时候引入工作流中的模糊测试部分了。

honggfuzz 是 Google 维护的一款模糊测试工具。它是一个覆盖率引导的 fuzzer,也就是说,它会追踪针对某个特定测试输入,目标二进制文件的哪些部分被执行了。当它发现某个输入能让二进制文件执行一条新的代码路径时,就会生成更多与之类似的输入,因为这意味着有更大的概率命中未被测试过的行为。

honggfuzz 自带 C 和 C++ 编译器,所以用 honggfuzz 编译 xpdf 应该很简单:只要让 Nix 使用 honggfuzz 的编译器而不是默认编译器即可。为此,我首先修改 nativeBuildInputs,加入 honggfuzz 软件包,使其在编译期间可用:

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

好了,现在 honggfuzz 已经在我的构建环境中可用了,但我该如何告诉 CMake 使用 honggfuzz 的编译器而不是之前用的那个呢?

Make 和 CMake 都遵循 CCCXX 环境变量,它们分别指定要使用的 C 编译器和 C++ 编译器。

我看到 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 应该是这个样子

在开发 shell 中临时进行模糊测试

我已经用 honggfuzz 的编译器编译好了 xpdf,但现在我想玩点有意思的东西。

我本可以在 Nix flake 里设置一条优雅的命令来启动模糊测试,但此刻我只想尽快动手折腾起来。为此,我创建一个包含所有所需工具的 Nix 开发 shell。

要创建 Nix 开发 shell,我在 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 开发 shell:

$ nix develop
GNU Wget 1.21.4 built on linux-gnu.

“GNU Wget”的输出来自 shellHook,它会打印 shell 内可用工具的版本号。honggfuzz 二进制文件也可用了:

$ honggfuzz --help 2>&1 | head -n 1
Usage: honggfuzz [options] -- path_to_command [args]

之所以可行,是因为在 mkShell 中,我把 buildInputs 指定为 xpdf 软件包的全部 nativeBuildInputscmakehonggfuzz),再加上 wget——后者我只希望在开发 shell 中用来下载 PDF 文件。

接着,我创建一个目录来存放模糊测试结果。由于这只是实验性质的,我使用一个临时目录:

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,确保 pdftotext 已准备好在 ./result/bin 文件夹下运行:

$ 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 显示一个终端界面来展示模糊测试进度

成功了!我可以让 honggfuzz 连续跑几天看它能抓到什么,但我想先把工作流再打磨一下,以提高找到缺陷的概率。

下一篇:用 Nix 找到 xpdf 中一个未修复的缺陷

至此,我已经展示了如何使用 Nix 和 honggfuzz 对 xpdf 这个 PDF 阅读器进行基本的模糊测试。

在后续文章中,我将展示如何:

  • 将完整的模糊测试工作流自动化。
  • 收集更容易导致崩溃的高难度 PDF 文件。
  • 在最新版本的 xpdf 中找到一个未修复的缺陷。

请继续阅读:


感谢 Antonio Morales(安东尼奥·莫拉莱斯)创建了本工作所基于的 Fuzzing101 教程系列

原文由 Michael Lynch 发布

本文章由 stealth/ox-alpha 进行翻译