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

Michael Lynch

使用 Nix 对 PDF 解析器进行模糊测试(上)

原文由 Michael Lynch 发布,订阅该博客

模糊测试是一种自动发现软件缺陷的技术。问题在于,它的配置过程非常繁琐。随便打开一篇模糊测试教程,第一步往往就是花上一个小时从源码编译各种工具,再去处理层层嵌套的依赖问题。

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

尽管我对 Nix 和模糊测试都还是新手,我还是用这套 Nix 工作流在一个 PDF 渲染器中发现了一个尚未修复的缺陷。

方案预览

先来预览一下最终效果:只需一条命令,就能开始对一个开源 PDF 阅读器进行模糊测试:

nix run gitlab:mtlynch/fuzz-xpdf

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

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

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

运行上面这条命令时,背后会完成以下所有步骤:

  1. Nix 会下载 PDF 阅读器和测试工具链所需的所有工具与依赖。
  2. Nix 会从源码编译 PDF 阅读器,并加上适用于模糊测试的插桩。
  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 也是一个构建工具,类似于 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";

它的意思是,当我需要引入软件包时,将从软件包仓库的 2024 年 5 月分支拉取,也就是本文写作时最新的稳定分支。

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

指定源码压缩包

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

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

{
  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 有一个令人烦恼的坑:只有纳入 git 版本控制的文件才会被 Nix 识别。如果你遇到“找不到文件”的报错,请检查是否已将文件添加到 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

作为测试,我从美国国税局网站下载了 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 的自动化行为不符合你的需求,你也可以轻松地自定义或覆盖各个构建阶段。

“标准环境”,摘自 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 无需我手把手指导就能搞定构建过程,十分神奇;但如果失败了,就得透过 Nix 不透明的抽象层去调试问题。

使用 honggfuzz 编译 xpdf

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

honggfuzz 是由 Google 维护的模糊测试工具。它是一款覆盖率引导的模糊测试器,意味着它会追踪特定测试输入会触发目标二进制文件的哪些部分执行。当它发现某个输入导致二进制文件执行了新的代码路径时,就会生成更多与之相似的输入,因为这意味着有更大几率触及未经测试的行为。

honggfuzz 自带了 C 和 C++ 编译器,因此使用 honggfuzz 编译 xpdf 应该就像让 Nix 指向 honggfuzz 的编译器、而非 Nix 默认编译器一样简单。为此,我首先修改 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 应该看起来像这样

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

我已经使用 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)再加上 wget,后者是我仅在开发环境中用于下载 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 教程系列

本文章由 muse-spark-1.2-contributor 进行翻译

评论