Fuzzing 艺术:如何通过覆盖率引导挖掘结构化文件漏洞
字数 3865
更新时间 2026-03-18 13:03:38

Fuzzing 艺术:通过覆盖率引导挖掘结构化文件漏洞实战教学

摘要

本文档以Xpdf PDF阅读器的一个真实漏洞(CVE-2018-16369,与Parser::getObj函数中的无限递归相关)为案例,系统性地阐述了如何使用AFL++(American Fuzzy Lop plus plus)这一覆盖率引导的模糊测试(Fuzzing)工具,对解析结构化文件(如PDF)的应用程序进行漏洞挖掘。内容包括Fuzzing核心概念、目标程序分析、环境配置、AFL++的实战部署、Fuzzing执行、崩溃分析与调试,以及初步的漏洞根因与修复思路探讨。本教程旨在提供一套可复现、可操作的漏洞挖掘方法学。

第一章:核心概念与工具

1.1 Fuzzing(模糊测试)技术概述

Fuzzing是一种自动化的软件安全测试技术。其核心思想是通过程序自动生成大量非法的、异常的、随机的输入数据,并将这些数据喂给目标程序,同时监控程序是否出现崩溃、断言失败或内存泄漏等异常行为。其内在逻辑在于,开发者在编写代码时,通常只考虑了正常的、符合预期的用户输入。而Fuzzer(模糊测试器)则专门模拟各种“乱来”的操作(例如,向一个设计容量为1MB的输入框塞入10GB的数据,或篡改文件头部结构),以探测程序在极端、非预期输入下的健壮性,从而发现潜在的安全漏洞。

1.2 AFL++ 的核心机制

AFL++是AFL的一个高级分支,它不仅仅是“随机”喂数据,而是一个“智能”的探测引擎。其核心在于覆盖率引导(Coverage-Guided Fuzzing)。它通过仪器化编译(Instrumentation)在目标程序的代码中插入探针。当程序处理输入时,AFL++能够感知到这组输入触发了哪些代码行、执行了哪些分支路径。如果一组新生成的输入数据让程序走出了之前所有测试用例都未曾走过的新执行路径,AFL++就会将这组数据判定为“有趣”的,并将其保留到“语料库”(Corpus)中,作为后续进一步变异(Mutation)的基础。通过这种方式,Fuzzer能够像“进化算法”一样,引导测试数据逐步深入程序的复杂、深层逻辑,从而高效地发现隐藏漏洞。

1.3 目标程序:Xpdf

Xpdf是Foo实验室开发的一款开源PDF阅读器。本次分析的目标版本是Xpdf 3.02。该版本中的一个已知安全漏洞位于Parser.cc文件的Parser::getObj()函数。攻击者可以借助一个精心构造的恶意PDF文件,诱使该函数陷入无限递归,最终导致栈空间耗尽,引发拒绝服务(Denial of Service, DoS)。

第二章:环境配置与构建

2.1 下载目标程序

wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz
tar -xvzf xpdf-3.02.tar.gz

可以使用pdfinfo工具来查看PDF文件的元信息。

2.2 安装AFL++

AFL++支持在本地环境或Docker容器中运行。

本地环境安装:

  1. 安装依赖

    sudo apt-get update
    sudo apt-get install -y build-essential python3-dev automake git flex bison libglib2.0-dev libpixman-1-dev python3-setuptools
    sudo apt-get install -y lld-11 llvm-11 llvm-11-dev clang-11 || sudo apt-get install -y lld llvm llvm-dev clang
    sudo apt-get install -y gcc-$(gcc --version|head -n1|sed 's/.* //'|sed 's/\..*//')-plugin-dev libstdc++-$(gcc --version|head -n1|sed 's/.* //'|sed 's/\..*//')-dev
    
  2. 构建与安装AFL++

    cd $HOME
    git clone https://github.com/AFLplusplus/AFLplusplus
    cd AFLplusplus
    export LLVM_CONFIG="llvm-config-11"
    make distrib
    sudo make install
    

Docker环境(简化部署):

  1. 安装Docker。
  2. 运行AFL++容器,并将宿主机目录挂载到容器内:
    docker run -ti -v $HOME:/home aflplusplus/aflplusplus
    
  3. 在容器内设置路径,使其与外部挂载点匹配:
    export HOME='/home'
    

2.3 编译插桩版本的目标程序

这是覆盖率引导Fuzzing的关键一步。必须使用AFL++提供的编译器(如afl-clang-fastafl-gcc等)来编译目标程序,以插入覆盖率收集代码。

  1. 进入Xpdf源码目录。
  2. 使用AFL++的编译器替换默认的编译器,通常需要修改Makefile中的CCCXX变量,例如:
    CC=afl-clang CXX=afl-clang++ ./configure
    make
    
  3. 编译完成后,会得到经过仪器化、可供AFL++进行Fuzzing的目标二进制文件(例如pdftotext)。

第三章:Fuzzing 实战过程

3.1 准备输入种子

创建一个输入目录(例如$HOME/fuzzing_xpdf/pdf_examples/),并放入一些正常的、结构简单的PDF文件作为初始种子。AFL++会以这些文件为基础进行变异。

3.2 启动Fuzzer

执行以下命令启动AFL++模糊测试:

afl-fuzz -i $HOME/fuzzing_xpdf/pdf_examples/ -o $HOME/fuzzing_xpdf/out/ -s 123 -- $HOME/fuzzing_xpdf/install/bin/pdftotext @@ $HOME/fuzzing_xpdf/output
  • -i:指定输入种子目录。
  • -o:指定AFL++的输出目录,用于存放Fuzzing状态、崩溃用例、新发现的路径等。
  • -s:设置随机数种子。
  • --:分隔符,后面是要Fuzzing的目标程序命令行。@@是一个占位符,AFL++在运行时会用当前生成的测试文件路径替换它。后续的$HOME/fuzzing_xpdf/outputpdftotext工具的输出文件参数。

3.3 监控Fuzzing状态

启动后,AFL++会在终端显示一个状态界面,需要关注以下关键指标:

  • cycles done:当前测试周期数。当此数值变为绿色,例如从0变为1,表示AFL++已经完成了一轮完整的语料库变异和测试。
  • total paths:发现的总的唯一执行路径数。
  • last new path:距离上次发现新路径的时间。
  • pending favs:待处理的、能触发新路径的“有趣”测试用例数量。
  • pending total:等待被测试的总输入文件数。
  • stage progress:当前正在执行的变异策略和进度。
  • saved crashes最重要的指标之一。记录导致目标程序崩溃的独特测试用例数量。在本次测试中,此数值从0增长到了6,表明已经发现了多个可导致程序崩溃的输入文件。这些崩溃文件保存在输出目录的crashes/子文件夹中。

关键观察:在Fuzzing过程中,levels(层级)指标从3快速增长到30。这表明AFL++成功地“进化”出了嵌套层级极深、结构异常复杂的PDF测试用例。深层嵌套结构正是触发后续“无限递归”漏洞的关键。

第四章:崩溃分析与调试

4.1 复现崩溃

从AFL++输出的crashes/目录中选取一个崩溃文件(例如id:000000,...),手动运行目标程序,确认漏洞可触发:

$HOME/fuzzing_xpdf/install/bin/pdftotext $HOME/fuzzing_xpdf/out/default/crashes/id:000000* $HOME/fuzzing_xpdf/output

预期程序会因段错误(Segmentation fault)而崩溃,这是栈溢出的典型表现。

4.2 使用GDB进行动态调试

  1. 启动调试
    gdb --args $HOME/fuzzing_xpdf/install/bin/pdftotext <崩溃文件路径> output
    
  2. 运行与定位:在gdb中执行run,程序会崩溃。使用backtrace(或bt)命令查看崩溃时的调用栈。调试信息会明确指出崩溃发生在Parser::getObj函数中,并给出具体的代码行号。
  3. 分析调用栈:观察调用栈会发现,Parser::getObj函数在反复、递归地调用自身,形成了一个闭环。在每次递归调用中,程序都会在栈上创建新的局部变量(如各种Object对象),持续占用栈空间,直至栈指针($rsp)最终超出系统分配给程序的栈内存边界,导致段错误。
  4. 深入函数调用:进一步分析调用栈和代码逻辑,可以梳理出导致无限递归的函数调用链。其核心流程如下:
    • 入口是Parser::getObj(),它负责解析PDF对象。
    • 当它遇到一个类型为objRef(对象引用)的对象时,它会调用XRef::fetch()函数去获取这个引用所指向的实际对象。
    • XRef::fetch()函数在执行过程中,可能又需要解析新的对象,因此它会再次调用Parser::getObj()
    • 如果PDF文件被精心构造,使得Parser::getObj解析出的对象又指向了自身或形成了一个循环依赖的引用链,这个A (Parser) -> B (XRef) -> A (Parser)的调用就会无限循环下去,形成逻辑闭环,最终耗尽栈空间。

调试技巧:可以使用GDB的telescope命令(如果支持)或在崩溃前检查栈内存,观察递归深度和栈空间消耗情况。

第五章:漏洞原理与修复思路

5.1 漏洞根因

Xpdf 3.02版本的Parser::getObj()函数在处理恶意构造的PDF文件时,缺乏对对象引用深度解析循环的有效检测与限制。当PDF文件中包含一个指向自身或形成环状引用的对象结构时,解析逻辑会陷入无限递归,每次递归都在栈上分配新的Object等数据结构,最终导致栈溢出,引发拒绝服务。

5.2 修复方案

修复的核心思想是为解析逻辑添加一个“熔断”(Circuit Breaker)机制,防止逻辑无限循环。

具体实现:可以在xpdf/parser.cc文件的Object *Parser::getObj(...)函数入口处,添加一个递归深度计数器

  1. 在函数开始时,检查一个全局或静态的深度变量是否已超过某个安全阈值(例如1000层)。
  2. 如果未超过,则深度加1,然后执行正常的解析逻辑。
  3. 在函数返回前,或将深度减1。
  4. 如果深度超过阈值,则立即终止解析并返回一个错误状态,而不是继续递归。

通过这种方式,即使遇到恶意构造的循环引用PDF,程序也会在达到安全深度后优雅地失败并报错,而不是崩溃,从而修复了拒绝服务漏洞。

总结

本教程通过一个完整的实战案例,演示了覆盖率引导模糊测试(AFL++)在挖掘复杂文件解析器漏洞中的强大能力。关键在于:1) 正确使用仪器化编译;2) 准备有效的初始种子;3) 理解并监控Fuzzing状态,引导其探索深层路径;4) 对发现的崩溃进行科学的根因分析。掌握这套方法论,可以有效地应用于对图像处理器、文档解析器、网络协议解析器等众多处理结构化输入的程序进行安全测试。

相似文章
相似文章
 全屏