用 Nix 对 PDF 解析器进行模糊测试(第一部分)
模糊测试(Fuzz Testing,模糊测试)是一种自动发现软件缺陷的技术。问题在于它的搭建过程非常麻烦。随便读一篇模糊测试教程,第一个任务往往就是花一个小时从源码构建工具,还要追查一层又一层的依赖。
我最近发现 Nix 能省去模糊测试中大量的繁琐工作。我创建了一个 Nix 配置,只需一条命令就能启动整个模糊测试工作流。唯一的依赖就是 Nix 和 git。
我用这个 Nix 工作流在一个 PDF 渲染器中发现了一个尚未修复的缺陷——尽管我在 Nix 和模糊测试两方面都只是初学者。
最终效果预览
先看看我的最终成果:你可以用一条命令开始对一个开源 PDF 阅读器进行模糊测试:
nix run gitlab:mtlynch/fuzz-xpdf这条命令应该能在任何安装了 Nix 的 Linux 系统上运行,也许 MacOS 也可以。经过几分钟的构建后,你应该会看到类似这样的终端界面:

Nix 让我能用一条命令安装所有依赖并开始模糊测试。
运行上面的命令时会发生以下所有事情:
- Nix 下载 PDF 阅读器和测试工具链所需的全部工具和依赖。
- Nix 从源码编译 PDF 阅读器,并加入适合模糊测试的插桩(instrumentation)。
- Nix 下载一组边界情况 PDF 文件,用于生成测试输入。
- Nix 自动生成新的 PDF 文件,把它们喂给 PDF 阅读器,并报告哪些输入导致阅读器崩溃。
如果你想修改模糊测试选项或测试不同版本的 PDF 阅读器,只需编辑一个文件即可。
接下来我会一步一步分享我是如何创建这个模糊测试工作流的。你可以用同样的方法在其他项目中寻找缺陷。
如果你等不及了,可以直接跳到文末查看我的最终成果。
什么是模糊测试?
模糊测试或“fuzzing”是一种通过随机生成输入数据、检查输入是否导致目标应用崩溃来发现软件缺陷的方法。
例如,要测试一个调整 JPEG 图片尺寸的程序,工作流程大致如下:
- 准备一组有效和/或畸形的 JPEG 文件。
- 随机选择其中一个输入文件并进行随机变异(翻转一些比特位、添加一些数据、删除一些数据)。
- 把变异后的输入文件喂给图片缩放程序。
- 如果变异后的输入导致程序崩溃或挂起,就保存该输入以供后续分析。
- 回到第 (2) 步。
什么是 Nix?
Nix 是一个复杂的工具,功能繁多,其中很多我自己也搞不明白。
就本文而言,你只需要了解 Nix 的两点就够了:
- Nix 是一个包管理器,类似于
apt或yum。Nix 拥有超过 10 万个可在 Nix 环境中运行的软件包。 - Nix 是一个构建工具,类似于
make或Docker。Nix 允许你定义一组构建步骤以及它们之间的依赖关系。当你向 Nix 请求一次构建时,它会执行所有必要的步骤来生成你要的结果。
环境要求
要跟着本文操作,你只需要两样东西:
- Nix(启用 flakes 特性)
- 我推荐使用 Determinate Systems 安装器,它默认启用 flakes。
- git
选择模糊测试目标
我要进行模糊测试的 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:
我用 pname 和 version 变量来指定 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/binNix 的这种行为是一把双刃剑。当它正常工作时,感觉非常神奇——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 都遵循 CC 和 CXX 环境变量,它们分别指定要使用的 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,我把 CC 和 CXX 变量指向正确的编译器路径:
{
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 软件包的全部 nativeBuildInputs(cmake 和 honggfuzz),再加上 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 教程系列。
随机一篇博客