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

Michael Lynch

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

这是关于使用 Nix 自动化模糊测试工作流的文章的下半部分。

到目前为止,我已经可以对 pdftotext 运行 honggfuzz,但启动过程还需要一些手动操作。我在上篇中承诺过,要把所有安装和模糊测试的步骤缩减到一条命令。

下载棘手的 PDF 文件

在我之前的临时模糊测试中,我是手动从 IRS(美国国税局)网站下载的一个 PDF。现在先把这个步骤自动化。

既然要自动化,我大概可以做得比只用一个 PDF 更好。对于模糊测试来说,我的目标是拥有丰富多样的 PDF,以覆盖 PDF 文件格式的不同部分。

Adobe 曾经有一批看起来很有意思的测试 PDF 语料库,但他们已经将其下线了。

我找到的最好的难以解析的 PDF 集合在 Mozilla 的 pdf.js 项目里。它包含 700 个 PDF,这些文件都曾在他们的工具中引发解析 bug,所以这些 PDF 很可能同样会让其他 PDF 解析器出问题。

我在 Nix flake 中新建了一个构建步骤,用于从 Mozilla 的 pdf.js 项目下载所有 PDF:

{
    packages = rec {
        ...
        sample-pdfs = pkgs.stdenv.mkDerivation rec {
          pname = "sample-pdfs";
          version = "4.7.76";
          src = pkgs.fetchzip {
            url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
            hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
          };
          buildCommand = ''
            mkdir -p $out
            cp $src/test/pdfs/*.pdf $out
          '';
        };

此时,flake.nix 应该是这样的

我用以下命令运行新的 sample-pdfs 构建步骤:

nix build .#sample-pdfs

接着,我验证 Nix 是否成功下载了 700 个 PDF:

$ ls ./result | head -n 5
160F-2019.pdf
alphatrans.pdf
annotation-border-styles.pdf
annotation-button-widget.pdf
annotation-caret-ink.pdf

$ ls ./result | wc --lines
700

新的构建步骤为我提供了一个由边缘情况 PDF 组成的初始语料库,希望它们能覆盖任何 PDF 解析代码中较少被用到的代码路径。

下载更多棘手的 PDF 文件

除了 PDF 本身之外,pdf.js 仓库中还包含几百个 .link 文件,其中存放着外部 PDF 的 URL。

我一开始想不明白如何从 .link 文件中的 URL 下载 PDF,因为 mkDerivation 步骤会阻止网络访问。我可以使用 fetchUrl 命令,但那得写上几百个。

Anton Mosich(安东·莫西奇)向我展示了一种优雅的从所有 .link 文件下载 PDF 的方法。他告诉我,如果你指定了 outputHashoutputHashModeoutputHashAlgo,Nix 就会放宽规则,允许在构建期间访问网络。

我最初尝试用 curl 下载每个文件,但速度慢得难以接受,因为它是逐个顺序下载的。我找到了一个叫 aria2c 的工具,它可以并行下载 URL,让整个过程快了很多:

{
    packages = rec {
        ...
        sample-pdfs = pkgs.stdenv.mkDerivation rec {
          pname = "sample-pdfs";
          version = "4.7.76";
          src = pkgs.fetchzip {
            url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
            hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
          };
          nativeBuildInputs = [
            pkgs.aria2
          ];
          SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
          buildPhase = ''
            # Extract the URLs and filenames from .link files into an input
            # file of URLs for aria2c.
            url_file=$(mktemp)
            for pdf in $src/test/pdfs/*.pdf.link; do
              url=$(sed 's/\r$//' "$pdf")
              filename=$(basename "$pdf" .link)
              echo "$url" >> "$url_file"
              echo "  out=$filename" >> "$url_file"
            done
            aria2c \
                --input-file="$url_file" \
                --max-tries=5 \
                --retry-wait=20 \
                --auto-file-renaming=false \
                --max-concurrent-downloads=5 \
                --max-connection-per-server=1 \
                --dir=.
            cp $src/test/pdfs/*.pdf .
          '';
          installPhase = ''
            mkdir -p $out
            cp -r . $out
          '';
          # We need to specify the output hash so that Nix allows Internet
          # access during the build.
          outputHash = "sha256-lcPF6AQNVsXH2RIiyGZQpp5VjcaBhtolQxmbqSduCNs=";
          outputHashMode = "recursive";
          outputHashAlgo = "sha256";
        };

我用 nix build 运行更新后的 sample-pdfs 步骤:

nix build .#sample-pdfs

检查 ./result 目录时,我发现它现在比之前只复制仓库内 PDF 文件时多了 437 个文件:

$ ls ./result | wc --lines
1137

自动化模糊测试运行

在本系列的第一部分中,我展示了如何在 Nix dev shell 中手动运行 honggfuzz。通过在我的 Nix flake 中定义一个模糊测试器的启动命令,我可以让这个过程更加简单。

首先,我添加一个新的 shell 脚本来启动 honggfuzz:

{
    packages = rec {
      xpdf = pkgs.stdenv.mkDerivation rec {
        ...
      }
      fuzz-xpdf = pkgs.writeShellScriptBin "fuzz-xpdf" ''
        readonly CORPUS_DIR='fuzz-corpus'
        mkdir -p "$CORPUS_DIR"
        # Copy the source corpus into a new directory for active fuzzing.
        cp --force ${sample-pdfs}/*.pdf "$CORPUS_DIR"
        ${pkgs.honggfuzz}/bin/honggfuzz \
          --input "$CORPUS_DIR" \
          --instrument \
          --timeout 10 \
          -- ${xpdf}/bin/pdftotext ___FILE___
      '';

我用 nix build 构建这个 shell 脚本:

nix build .#fuzz-xpdf

随后 Nix 会在 ./result/bin/fuzz-xpdf 创建一个 bash 脚本:

$ cat ./result/bin/fuzz-xpdf
#!/nix/store/1xhds5s320nfp2022yjah1h7dpv8qqns-bash-5.2p32/bin/bash
readonly CORPUS_DIR='fuzz-corpus'
mkdir -p "$CORPUS_DIR"
# Copy the source corpus into a new directory for active fuzzing.
cp --force /nix/store/gncc6jy3cry5lwbkd2b54h1dg46wfkdc-sample-pdfs-4.7.76/*.pdf "${CORPUS_DIR}"
/nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/honggfuzz \
  --input "$CORPUS_DIR" \
  --instrument \
  -- /nix/store/pixq8qiqyy6iwsc4wisb1vrmgy7l1kas-xpdf-4.05/bin/pdftotext ___FILE___

在这个 shell 脚本中,Nix 把所有 Nix 变量替换成了绝对路径。而 bash 变量(CORPUS_DIR)仍保持为符号,以便 bash 能在脚本运行时对其进行解释。

如果我运行这个 shell 脚本,它就会启动一个新的模糊测试会话:

./result/bin/fuzz-xpdf

这条命令应该会显示类似这样的画面:

现在我可以从我的启动脚本运行 honggfuzz 了

这能行,但我每次执行 shell 脚本前都得记得先运行 nix build 命令。Nix 提供了一个更简单的方案,那就是它的 apps 功能。

我在 Nix flake 中 packages 部分之后添加一个 apps 定义:

{
    packages = rec {
        ...
    };
    apps = {
      default = self.apps.${system}.fuzz-xpdf;
      fuzz-xpdf = {
        type = "app";
        program = "${self.packages.${system}.fuzz-xpdf}/bin/fuzz-xpdf";
      };
    };

有了 fuzz-xpdf 这个 app,我只需一条命令就能启动模糊测试:

nix run .#fuzz-xpdf

由于我把 fuzz-xpdf 声明为这个 flake 的默认 app,所以实际上还可以用更简单的命令:

nix run

此时,flake.nix 应该是这样的

现在,模糊测试工作流已经完成了。

我可以把这个 Nix flake 放到一个全新的目录中,当我运行 nix run 时,它会下载所有棘手的 PDF、编译 xpdf,并开始模糊测试。我可以让 honggfuzz 无限运行下去,看看它能发现什么崩溃。

在 Nix flake 中加入 fuzz-xpdf app 后,我就拥有了一个完整的模糊测试工作流。我可以无限期地运行模糊测试器,让它去发现 bug。

用 ASAN 把隐蔽的内存错误变成响亮的崩溃

到这里,我的模糊测试工作流已经可以运行了,但我还能让它更高效。

在模糊测试中,只有当 bug 导致目标应用程序崩溃时,你才知道自己发现了一个有意思的 bug。问题在于,让程序行为异常而不崩溃的方式有很多种。

最著名的不会引发崩溃的安全漏洞之一,是 2014 年 OpenSSL 中的 Heartbleed(心脏出血)漏洞。它允许攻击者从 web 服务器中提取敏感信息,却不会导致服务器崩溃。诱使程序读取或写入超出预期边界的内存,并不总会使其崩溃。

好消息是,有一个工具可以强制那些原本不会导致崩溃的内存错误立即让程序崩溃。Address Sanitizer(地址消毒器,ASAN)会在程序的内存读取和写入中添加额外的安全检查,如果程序试图读取或写入超出某个变量内存位置的范围,程序就会崩溃并输出调试信息。

在我的模糊测试工作流中加入 ASAN,能让我发现比原本更多的内存 bug。要在启用 ASAN 的情况下编译 xpdf,我在 xpdf 的编译步骤中添加 -fsanitize=address

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            export CC=${pkgs.honggfuzz}/bin/hfuzz-clang
            export CXX=${pkgs.honggfuzz}/bin/hfuzz-clang++
            # Use address sanitizer (ASAN).
            export CFLAGS="$CFLAGS -fsanitize=address"
            export CXXFLAGS="$CXXFLAGS -fsanitize=address"
          '';
        };

此时,flake.nix 应该是这样的

现在,我终于可以启动我的模糊测试器,让它为我找出一些 bug 了。有了我的 Nix flake,这简单到只需运行:

nix run

发现第一个崩溃

我让 honggfuzz 运行了两个小时,回来看时发现它发现了第一个崩溃:

honggfuzz 把导致崩溃的 PDF 保存在了一个名为以下内容的文件中:

  • SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz

为了复现这个崩溃,我运行了以下命令:

# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Rebuild pdftotext in the result folder
nix build

# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null

程序确实崩溃了,ASAN 报告说它捕获到了一个缓冲区溢出:

==1259902==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x557c3230bd58 bp 0x7ffd7f070cf0 sp 0x7ffd7f070ce8
READ of size 1 at 0x60200002228f thread T0
    #0 0x557c3230bd57  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
    #1 0x557c3231f6ea  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
    #2 0x557c32327a0d  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)
    #3 0x557c3232733c  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ff33c)
    #4 0x557c322bfaa7  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x397aa7)
...
SUMMARY: AddressSanitizer: heap-buffer-overflow (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
Shadow bytes around the buggy address:
  0x602000022000: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fa
  0x602000022080: fa fa fd fd fa fa fd fd fa fa 00 01 fa fa fd fd
  0x602000022100: fa fa 00 03 fa fa fd fa fa fa 00 00 fa fa fd fa
  0x602000022180: fa fa fd fa fa fa fd fd fa fa 00 02 fa fa fd fa
  0x602000022200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa 00 00
=>0x602000022280: fa[fa]00 fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000022300: fa fa fd fa fa fa fd fa fa fa 03 fa fa fa fd fa

ASAN 的错误信息有点晦涩,但它是在告诉我:ASAN 捕获到 pdftotext 试图读取其代码为缓冲区分配的范围之外的一个字节。

那么,我该如何深入探究导致这个崩溃的原因呢?

改进调试符号

pdftotext 崩溃时,我希望能看到一个包含源文件名和行号的堆栈跟踪。然而,输出中只有二进制偏移量,这让调试变得更困难:

#0 0x557c3230bd57  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
#1 0x557c3231f6ea  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
#2 0x557c32327a0d  (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)

说来奇怪,整个过程中最费劲的部分,竟是搞清楚如何让调试符号正常工作,以便我能在崩溃转储中看到源码信息。

首先,我偶然注意到 nix run 的日志输出中提到了从二进制文件中剥离调试信息的内容。原来 Nix 有一个dontStrip 选项,默认为 false,也就是说它会自动剥离调试信息。

我还注意到 xpdf 的编译说明中提到了一个 CMAKE_BUILD_TYPE 选项。它没有文档说明,但在源代码中搜索后发现它接受 Debug 这个值

为了在我的 xpdf 二进制文件中保留调试符号,我在 xpdf 包定义的末尾添加了这些选项:

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            ...
          '';
          cmakeFlags = [
            "-DCMAKE_BUILD_TYPE=Debug"
          ];
          # Don't strip debug information from binaries, as the debug symbols
          # are usefule during crash analysis.
          dontStrip = true;
        };

此时,发生了一件奇怪的事。我得到了包含文件名和行号的丰富堆栈跟踪,但几个小时后它们又莫名其妙地失效了。我至今仍不知道原因。

为了让丰富的堆栈跟踪稳定工作,我不得不使用一个名为 llvm-symbolizer 的工具,我以前从未听说过它。幸运的是,llvm-symbolizer 随广受欢迎的 llvm_18 Nix 包一起提供,所以我把这个包加入了我的 Nix flake,并添加了一个名为 ASAN_SYMBOLIZER_PATH 的环境变量来指向该二进制文件。

我对 Nix flake 的修改很难展示,因为它们涉及文件中几个互不相关的部分,所以最简单的方式是查看这个 diff

由于对 Nix flake 做了这些修改,我需要进入 Nix dev shell 才能看到丰富的堆栈跟踪,因为 dev shell 会设置正确的 ASAN_SYMBOLIZER_PATH 环境变量值。

# Enter the nix dev shell.
nix develop

# Rebuild pdftotext in the result folder
nix build

# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null

然后,我终于应该能看到带文件名的堆栈跟踪了:

=================================================================
==1461608==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /build/source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /build/source/xpdf/GfxFont.cc:553:20
    #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /build/source/xpdf/GfxFont.cc:1732:3
    #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /build/source/xpdf/GfxFont.cc:190:16
...
    #22 0x7ffff7a7110d in __libc_start_call_main (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a10d) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
    #23 0x7ffff7a711c8 in __libc_start_main@GLIBC_2.2.5 (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a1c8) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
    #24 0x555555698a04 in _start (/nix/store/x59ccyx8gz0ap74zapdi7k8ssgypmipm-xpdf-4.05/bin/pdftotext+0x144a04)

成功了!现在我可以看到源文件名、行号和函数名了。

但还有一个问题。看看其中任何一个文件的路径。

/build/source/xpdf/GfxFont.cc
^^^^^^^^^^^^^
  Where is this coming from?

xpdf 的源文件都指向一个名为 /build/source 的根目录,但我的系统上并不存在这个路径:

$ ls /build/source
ls: cannot access '/build/source': No such file or directory

我不确定 /build/source 这个路径是 Nix 的特性还是 xpdf 构建配置的特性。更正:这个前缀来自 Nix 的 sandbox-build-dir 选项,它定义了从源码构建时的根目录。感谢 Dionysis Grigoropoulos(迪奥尼修斯·格里戈罗普洛斯)的澄清。)

我唯一能修复 /build/source 前缀的方法,是一个半丑陋的 hack:在编译时用 clang 的 -fdebug-prefix-map 标志把错误的路径替换为正确的路径:

{
    {
      packages = rec {
        xpdf = pkgs.stdenv.mkDerivation rec {
          ...
          preConfigure = ''
            ...
            # For some reason, without these flags, the debug symbols point to
            # source files at the base filesystem /build/source, so we
            # manually fix the source path.
            export CXXFLAGS="$CXXFLAGS -fdebug-prefix-map=/build/source=${xpdf.src}"
          '';

如果我重新运行我的 nix build 流程,堆栈跟踪终于正确了:

==1498830==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
    #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:1732:3
    #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:190:16
    #4 0x55555594a065 in GfxFontDict::load(char*, GfxFontDictEntry*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:2393:12

如果我把那个文件路径放进 sed,它就会打印出文件的内容:

$ sed -n '548,558p' /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc
      i -= 2;
    } else if (i > 7 && !strncmp(name->getCString() + i - 7, "Oblique", 7)) {
      flags |= fontItalic;
      i -= 7;
    }
    char c = name->getChar(i-1);
    if (!((c >= 'A' && c <= 'Z') ||
          (c >= 'a' && c <= 'z') ||
          (c >= '0' && c <= '9'))) {
      --i;
    }

此时,flake.nix 应该是这样的

理解这个崩溃

现在我有一个会稳定导致崩溃的越界内存读取。我已经掌握了理解这个 bug 所需的全部调试信息,是时候深入源码了。

堆栈跟踪的顶部指向这一行

// goo/GString.h

// Get <i>th character.
char getChar(int i) { return s[i]; }

好,s 是一个 char 缓冲区,所以它很可能包含一个 C 风格字符串。getChar 函数没有做任何边界检查来确保调用者为 i 传入的是合法值,因此该函数读取了为 s 分配的缓冲区之外的内存。

我退回上一层,看看崩溃前一刻 getChar 是如何被调用的。堆栈跟踪的下一行指向这里:

// xpdf/GfxFont.cc

void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
  ...
  // scan font name for bold/italic tags and update the flags
  if (name) {
    i = name->getLength();
    if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
      i -= 2;
    }
    ...
    char c = name->getChar(i-1); // <<< CRASH

好,这其实是一个相当简单的 bug。

readFontDescriptor 检查了 name 不为 NULL,但它假设 name 的长度至少为 1。如果 name 是空字符串(长度为 0),那么 getChar 调用就会变成 name->getChar(-1)。然后,getChar 返回 s[-1],也就是为 s 分配的内存缓冲区之前的 1 个字节。

崩溃的调试输出支持了我的假设:

==241578==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002360f at pc 0x55555592fff5 bp 0x7fffffffa650 sp 0x7fffffffa648
READ of size 1 at 0x60200002360f thread T0
    #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
    #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
...
SUMMARY: AddressSanitizer: heap-buffer-overflow /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32 in GString::getChar(int)
Shadow bytes around the buggy address:
...
=>0x602000023600: fa[fa]00 fa fa fa fd fd fa fa fd fa fa fa fd fa

ASAN 说这是一次大小为 1 的非法读取,这很合理,因为 getChar 试图读取单个 1 字节的字符。

ASAN 还展示了它读取包含字节 fa 的内存地址处的内存布局。该字节紧挨在一个包含 00 的地址之前。变量 s 包含一个空字符串,而 C++ 在内存中将其表示为 00,所以 pdftotext 试图读取的正是空字符串之前的那个字节。这次读取是非法的,因为那个字节包含的数据并未分配给 s 变量。

修复这个 bug

如果我的假设正确,这个 bug 应该很容易修复。在 readFontDescriptor 中,我可以把这行:

  if (name) {

改成这样:

  if (name && (name->getLength() > 0)) {

这确保函数把空字符串和空指针同等对待,不再继续处理它。

为了验证我的假设,我会创建一个包含此修改的补丁,重新编译 xpdf,然后针对同一个 PDF 重新运行 pdftotext,看看我的修复是否能阻止同样的崩溃。

xpdf 在开源项目中有点特别,它不发布 git 仓库,只定期发布 tarball。不过没关系。我会创建一个自己的临时 git 仓库,作为编辑代码的工作区:

ORIGINAL_SRC="$(nix eval --raw .#xpdf.src.outPath)"
MODIFIED_SRC="$(mktemp --directory)"

pushd "${MODIFIED_SRC}" && \
  cp --recursive --verbose $ORIGINAL_SRC/* . && \
  chmod -R u+w . && \
  git init && \
  git add --all && \
  git commit --message "Dummy base commit"

现在我在一个全新的 git 仓库中有了一份 xpdf 源代码的副本。我编辑 GfxFont.cc 来实施修复:

vim xpdf/GfxFont.cc

编辑完成并保存文件后,我调用 git diff 生成一个补丁文件,内容如下:

diff --git a/xpdf/GfxFont.cc b/xpdf/GfxFont.cc
index c3db4e8..7074354 100644
--- a/xpdf/GfxFont.cc
+++ b/xpdf/GfxFont.cc
@@ -535,7 +535,7 @@ void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
   obj1.free();
   // scan font name for bold/italic tags and update the flags
-  if (name) {
+  if (name && (name->getLength() > 0)) {
     i = name->getLength();
     if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
       i -= 2;

我把补丁复制回我的模糊测试目录:

# Create a patch file for the fix.
git diff > check-font-name-length.patch

# Get back to fuzz-xpdf git repo.
popd

mv "${MODIFIED_SRC}/check-font-name-length.patch" .

补丁文件和 flake.nix 放在同一目录后,我更新 Nix flake,告诉它在编译 xpdf 时应用我的自定义补丁:

{
  xpdf = pkgs.stdenv.mkDerivation rec {
    ...
    src = pkgs.fetchzip {
      url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
      extension = "tar.gz";
    };
    # Add a custom patch to fix the font name length bug.
    patches = [
      ./check-font-name-length.patch
    ];

此时,flake.nix 应该是这样的

我准备好测试我的修复了。我带着补丁重新编译 xpdf,并针对之前导致崩溃的那个 PDF 运行它:

# Specify the path to the crashing PDF.
$ CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'

# Rebuild pdftotext in the result folder
$ nix build

# Run pdftotext with the crashing PDF.
$ ./result/bin/pdftotext "${CRASHING_PDF}"
Syntax Error: Couldn't read xref table
Syntax Warning: PDF file is damaged - attempting to reconstruct xref table...
Syntax Error (2800): Bad dynamic code table in flate stream
Syntax Error (2800): Bad block header in flate stream
Syntax Error (2158): Dictionary key must be a name object
Syntax Error: Unterminated string
Syntax Error: Leftover args in content stream

瞧!pdftotext 报告了很多错误,但程序再也没有崩溃。我的修复起作用了。

找到修复方案之后,我发现就在我发现这个崩溃的几周前,已经有人报告过同样的漏洞,但这仍然是一次很有价值的练习。

总结

我发现 Nix 是构建模糊测试工作流的绝佳工具。

搞清楚 Nix 在模糊测试中的各种细枝末节花了一些功夫,但现在换用另一个 PDF 解析器或另一个模糊测试器应该都很容易了。Nix 的美妙之处在于它具有良好的组合性,因此在工作流中替换不同组件非常简单。

这个项目最终成为同时学习模糊测试和 Nix 的绝佳方式。过去一年里我一直在浅尝 Nix,而以这种方式使用 Nix 帮助我把许多原本模糊的概念变得清晰了。

源代码

我的模糊测试工作流的完整源代码可以在 Gitlab 上获取:


本文中 xpdf 的摘录依据 GPLv3 许可证使用。感谢 Anton Mosich 对本文的帮助。

原文由 Michael Lynch 发布

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