1. 项目概述:从Fuzzing101到CVE-2019-13288
如果你对软件安全、漏洞挖掘感兴趣,那么“Fuzzing101”这个系列绝对是你绕不开的实战宝典。它不是什么高深的理论课程,而是一套手把手教你如何用模糊测试(Fuzzing)技术,去真实地挖掘历史漏洞的练习集。今天我们要啃下的第一块硬骨头,就是Xpdf阅读器中那个经典的无限递归漏洞(CVE-2019-13288)。这个漏洞本身并不复杂,但它完美地展示了模糊测试如何像一把精准的手术刀,切入一个看似正常的软件内部,找到那些在常规测试中极难触发的逻辑缺陷。整个实战过程,从环境搭建、目标编译、种子准备,到AFL++的启动、崩溃分析,再到最后的漏洞原理剖析和修复,是一条完整的漏洞研究流水线。无论你是刚入门安全的新手,还是想系统提升Fuzzing技能的老兵,跟着走完这一趟,你收获的将不仅仅是一个CVE编号,更是一套可复用于其他目标的实战方法论。我们用的核心工具是AFL++,它是经典模糊测试器AFL的“威力增强版”,在速度、稳定性和漏洞发现能力上都有显著提升。接下来,我们就一步步拆解,看看如何用AFL++让Xpdf“原形毕露”。
2. 环境准备与目标构建
工欲善其事,必先利其器。在开始Fuzzing之前,一个稳定、高效的实验环境是成功的一半。这里我强烈建议使用一个干净的Linux系统,Ubuntu 20.04/22.04 LTS或者Debian都是不错的选择。虚拟机或物理机均可,但请确保为AFL++分配足够的CPU核心和内存(建议至少4核8GB),因为模糊测试是个计算密集型任务。
2.1 AFL++的安装与配置
首先,我们需要获取并编译AFL++。直接从GitHub克隆最新版本是最好选择,因为社区一直在积极修复问题和添加新特性。
# 1. 安装必要的编译依赖 sudo apt-get update sudo apt-get install -y build-essential python3-dev automake cmake git flex bison libglib2.0-dev libpixman-1-dev clang clang++ lld # 2. 克隆AFL++仓库 git clone https://github.com/AFLplusplus/AFLplusplus.git cd AFLplusplus # 3. 编译并安装。这里我们选择安装所有组件,包括LLVM模式(afl-clang-lto)等。 make distrib sudo make install安装完成后,你可以通过运行afl-fuzz --help来验证安装是否成功。AFL++提供了多种编译器包装器,我们本次实战将使用afl-clang-lto和afl-clang-lto++。LLVM链接时优化(LTO)模式能提供更精准的插桩和更快的执行速度,是当前的首选。
注意:如果你在较新的系统上编译遇到问题,可以尝试切换到
stable分支 (git checkout stable) 后再进行编译。同时,确保你的clang版本在12以上,以获得对LTO的最佳支持。
2.2 目标程序Xpdf的获取与编译
我们的目标是Xpdf 3.02版本,这个版本包含了我们要挖掘的CVE-2019-13288漏洞。编译的关键在于使用AFL++的编译器来“插桩”,这样AFL++才能监控程序的执行路径,进行反馈式模糊测试。
# 1. 下载Xpdf 3.02源码 wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz tar -zxvf xpdf-3.02.tar.gz cd xpdf-3.02 # 2. 配置编译环境,使用afl-clang-lto进行插桩编译 CC=afl-clang-lto CXX=afl-clang-lto++ ./configure --prefix="$HOME/fuzzing_xpdf/install" --disable-shared # 3. 编译并安装 make -j$(nproc) make install这里有几个细节需要解释一下:
CC=afl-clang-lto CXX=afl-clang-lto++: 这两个环境变量告诉configure脚本,使用AFL++的Clang LTO编译器来替代默认的GCC。这会在编译过程中自动插入用于代码覆盖率跟踪的桩代码。--prefix: 指定安装目录,将编译好的程序集中存放,方便管理。--disable-shared: 强制编译静态库,这可以避免因动态链接库路径问题导致fuzzer运行时出错,让目标程序更加“自包含”。-j$(nproc): 使用所有可用的CPU核心并行编译,加快速度。
编译完成后,进入安装目录,你应该能看到pdftotext、pdfinfo等可执行文件。我们的Fuzzing目标就是pdftotext,它负责从PDF文件中提取文本。
实操心得:在
configure或make阶段,你可能会看到关于缺少xpdf或pdftoppm的警告,这通常是缺少某些图形库(如X11)导致的。对于我们的Fuzzing目标pdftotext来说,这些警告可以忽略,不影响核心功能的编译。但如果后续你想Fuzz其他组件,可能需要安装相应的开发库。
2.3 测试用例(种子)准备
模糊测试不能从零开始,它需要一些初始输入作为“种子”,来引导变异的方向。对于PDF解析器,我们自然需要一些正常的PDF文件。一个好的种子集应该小而精,覆盖不同的文件结构。
# 在fuzzing工作目录下创建输入文件夹 mkdir -p ~/fuzzing_xpdf/inputs cd ~/fuzzing_xpdf/inputs # 下载几个简单、典型的PDF文件作为初始种子 wget -q https://github.com/mozilla/pdf.js-sample-files/raw/master/helloworld.pdf wget -q http://www.africau.edu/images/default/sample.pdf wget -q https://www.melbpc.org.au/wp-content/uploads/2017/10/small-example-pdf-file.pdf # 验证种子文件是否可以被目标程序正常处理 ~/fuzzing_xpdf/install/bin/pdftotext helloworld.pdf /dev/null && echo “种子文件测试通过”这些PDF文件都很小,结构简单,能帮助AFL++快速建立起PDF文件的基本语法模型。将种子文件放在独立的inputs目录下,是一个好习惯。
3. AFL++实战:启动Fuzzing与监控
环境就绪,目标程序也已插桩,种子文件也已到位,是时候启动我们的“漏洞挖掘机”了。AFL++的运行有很多参数可以调整,对于新手,我们先从一个基础但有效的配置开始。
3.1 基础Fuzzing命令与参数解析
在Fuzzing工作目录下,执行以下命令:
cd ~/fuzzing_xpdf afl-fuzz -i inputs/ -o out -s 123 -- ./install/bin/pdftotext @@ -这条命令是本次实战的核心,我们来拆解每一个参数:
-i inputs/: 指定输入种子目录。-o out: 指定输出目录,AFL++会将所有发现(如独特路径、崩溃、超时用例)都存放在这个目录下。-s 123: 设置一个随机数种子(这里是123)。这能确保模糊测试的变异过程在多次运行时是可复现的,对于调试和分享案例非常重要。--: 分隔符,表示后面是目标程序的命令行。./install/bin/pdftotext @@ -: 这是我们的目标命令。@@是AFL++的占位符,在运行时会被当前生成的测试文件路径替换。-是pdftotext的参数,表示将输出内容送到标准输出(stdout)。我们选择输出到stdout而不是文件,是为了避免因频繁的文件I/O操作影响Fuzzing速度,同时也能捕获到向stdout输出时可能发生的崩溃。
启动后,AFL++会打开一个基于ncurses的UI界面。别被它花花绿绿的界面吓到,我们只需要关注几个核心指标:
| 区域 | 指标 | 含义与健康状态 |
|---|---|---|
| process timing | run time | 已运行时间。 |
| last new path | 上次发现新路径的时间。如果长时间(如半小时)没更新,可能意味着Fuzzing停滞了。 | |
| cycle progress | stages done | 完成的变异阶段轮数。数字增长是好事。 |
| map coverage | map density | 路径覆盖密度。达到100%很难,缓慢增长即可。 |
| count coverage | 位图计数覆盖率。 | |
| stage progress | now trying | 当前正在使用的变异策略,如“havoc”、“splice”等。 |
| findings in depth | saved crashes | 关键!已保存的导致崩溃的测试用例数量。我们的目标就是让这个数字从0变成大于0。 |
| saved hangs | 已保存的导致程序超时(挂起)的测试用例数量。 |
3.2 提升Fuzzing效率的技巧与策略
基础命令能跑起来,但要想更快、更深地挖洞,还需要一些策略。根据我多年的经验,以下几点能显著提升效率:
1. 并行Fuzzing:一台多核机器只跑一个Fuzzer实例是巨大的浪费。我们可以启动一个主实例(-M)和多个从实例(-S),让它们协同工作。
# 终端1:启动主Fuzzer afl-fuzz -i inputs/ -o out -M master -- ./install/bin/pdftotext @@ - # 终端2:启动从Fuzzer1 afl-fuzz -i inputs/ -o out -S slave01 -- ./install/bin/pdftotext @@ - # 终端3:启动从Fuzzer2(如果你的CPU核心足够多) afl-fuzz -i inputs/ -o out -S slave02 -- ./install/bin/pdftotext @@ -多个实例会共享out目录下的队列(queue),互相学习对方发现的独特路径,实现“众人拾柴火焰高”。
2. 使用字典:AFL++支持提供字典文件,里面包含目标文件格式的“关键词”或“魔术字节”。对于PDF,我们可以提供一个包含%PDF-、endobj、stream、endstream等标记的字典,帮助变异器更快地构造出语法上有效的文件。 你可以创建一个pdf.dict文件,然后运行:
afl-fuzz -i inputs/ -o out -x pdf.dict -s 123 -- ./install/bin/pdftotext @@ -3. 优化系统配置:
- 切换到性能模式:
sudo cpufreq-set -g performance - 关闭核心转储:
ulimit -c 0(或在/etc/security/limits.conf中设置) - 检查系统状态:运行
afl-system-config脚本(AFL++自带),它会提示你还需要优化哪些系统设置。
在我的测试环境中,使用基础命令,大约在5-10分钟内,AFL++就开始报告“saved crashes”了。速度可能因机器性能而异,但通常不会等待太久。一旦发现崩溃,我们就可以进入下一阶段——分析。
常见问题排查:如果AFL++长时间(比如30分钟)没有发现任何新路径或崩溃,首先检查目标程序是否真的被插桩。可以运行
file ./install/bin/pdftotext,如果输出中包含“afl”或“AFL”字样,说明插桩成功。其次,检查种子文件是否真的能被目标程序处理。最后,尝试简化目标命令,比如去掉-参数,直接输出到一个临时文件(./install/bin/pdftotext @@ /tmp/output.txt),看看是否是输出方向导致了问题。
4. 崩溃分析与漏洞原理深度剖析
当AFL++的界面上出现“saved crashes”时,你的心跳可能会加速——我们挖到“矿”了!但别急,这只是一个开始。out目录下的crashes文件夹里保存着能导致程序崩溃的测试文件。现在,我们需要化身侦探,搞清楚这个PDF文件到底对pdftotext做了什么。
4.1 复现与定位崩溃点
首先,让我们手动复现崩溃,确认问题存在。
# 切换到输出目录,通常第一个崩溃文件是 id:000000,sig:11 cd ~/fuzzing_xpdf/out/default/crashes ~/fuzzing_xpdf/install/bin/pdftotext id:000000,sig:11,src:000000,op:havoc,rep:2 - > /dev/null你应该会看到类似“Segmentation fault (core dumped)”的错误。sig:11就是SIGSEGV,段错误,通常意味着内存非法访问。
接下来,我们需要一个调试器来定位崩溃现场。GDB或LLDB都可以,这里我用GDB演示。
# 使用GDB加载目标程序和崩溃文件 gdb --args ~/fuzzing_xpdf/install/bin/pdftotext id:000000,sig:11,src:000000,op:havoc,rep:2 - # 在gdb中运行 (gdb) run # 程序崩溃后,查看调用栈 (gdb) backtrace # 或者更简洁的栈帧信息 (gdb) backtrace full通过回溯栈帧(backtrace),你可能会发现崩溃点在一个深层递归调用中,函数名反复出现,比如Object::fetch、Dict::lookup等。这强烈暗示了无限递归的可能性——函数不断调用自身,直到耗尽栈空间,最终导致段错误。
4.2 漏洞原理:Xpdf中的对象引用循环
仅仅知道是无限递归还不够,我们需要理解这个递归是如何被触发的。这需要结合源码进行静态分析。回顾一下我们在编译时使用的Xpdf 3.02源码。
问题的核心在于PDF对象(Object)的解析和引用机制。在PDF文件中,对象可以通过编号(num)和生成号(gen)被间接引用。Xpdf使用XRef(交叉引用表)来管理这些对象。Object::fetch(XRef*, Object*)方法就是根据一个引用,去XRef表中查找并获取实际的对象内容。
漏洞触发路径可以简化为以下链条:
- 入口:
pdftotext尝试解析PDF页面内容时,会调用Page::displaySlice。 - 获取内容流:在
displaySlice中,会通过contents.fetch(xref, &obj)获取页面的内容流对象。这里的contents是一个类型为objRef的Object,它内部保存了一个引用,比如(num=7, gen=0)。 - 解析对象:
fetch方法调用xref->fetch(7, 0, obj)。在XRef::fetch中,它发现第7号对象是一个“未压缩”的流对象(xrefEntryUncompressed),于是创建一个Parser来解析这个流。 - 解析流字典:
Parser::getObj开始解析这个流。流对象以字典形式开始,getObj会初始化一个字典对象,并调用makeStream来创建流。 - 关键的一步:在
Parser::makeStream中,程序需要从流字典中查找Length键,以确定流数据的长度。它调用dict->dictLookup(“Length”, &obj)。 - 致命的循环:
Dict::lookup找到了Length键,但其对应的值(val)不是一个直接的整数(objInt),而是另一个对象引用(objRef)。而且,这个引用的编号碰巧也是7(即(num=7, gen=0))。 - 递归触发:
lookup方法在找到引用后,会尝试通过val.fetch(xref, obj)去获取这个引用的实际值。于是,程序又回到了第3步,试图去获取编号为7的对象。 - 无限循环:由于第7号对象字典中的
Length键指向了自己,这就形成了一个自引用循环。每次解析到Length时,都会触发一次新的fetch(7,0),而新的fetch又会解析到同一个字典和同一个Length引用,如此往复,直至栈溢出。
用更直白的类比:就像一本字典,在解释“苹果”这个词时,写着“参见:苹果”。你不停地翻找,永远找不到真正的定义。
4.3 漏洞根因与补丁分析
那么,为什么程序会陷入这个循环?根本原因在于Dict::lookup函数的设计。它在查找到键值对时,无条件地对值(val)调用fetch,试图解析出最终内容。这在大多数情况下是正确的,因为值可能是一个间接引用。然而,它没有检查这种引用是否会造成循环。
一个健壮的实现应该在fetch过程中加入循环检测,或者,更简单且符合PDF规范的做法是:对于流字典的Length键,其值必须是一个直接整数(objInt),而不应该是一个间接对象引用。PDF规范明确规定了这一点。
因此,修复方案就清晰了。社区提供的补丁思路是:为流字典的Length查找创建一个特例。不是调用通用的dictLookup,而是调用一个新的方法dictLookupLength。这个新方法在找到值后,不调用fetch去解析引用,而是直接返回值的副本(copy)。如果值本身是整数,就返回整数;如果是引用,就返回引用本身(而不是去解析它)。这样,当Length的值是一个指向自身的引用时,makeStream会收到一个objRef类型的对象,随后在if (obj.isInt())检查中失败,报错并返回NULL,从而安全地终止处理,而不是陷入递归。
这个修复在Parser::makeStream中只改动了一行,将dict->dictLookup(“Length”, &obj)替换为dict->dictLookupLength(“Length”, &obj),既解决了崩溃问题,又对性能影响极小,是一个优雅的修复。
调试心得:在分析此类漏洞时,使用调试版本(
-O0 -g编译)至关重要。默认的-O2优化会内联函数、重组代码,使得调用栈不清晰,变量值难以观察。在编译Xpdf时加上CFLAGS=”-O0 -g” CXXFLAGS=”-O0 -g”,能让你在GDB中获得准确的源码行信息和完整的栈帧,极大降低分析难度。
5. 从理论到实践:漏洞复现与修复验证
理解了原理,我们最好亲手验证一下。这不仅是为了确认漏洞,更是为了体验完整的漏洞研究流程——发现、分析、修复、验证。
5.1 构造PoC与稳定性测试
AFL++给我们的崩溃文件是一个有效的概念验证(PoC)。但我们可以尝试简化它,用一个最小的PDF文件来触发这个漏洞。通过分析崩溃文件,我们发现其核心是构造一个特殊的交叉引用表(xref)和一个内容流字典。
一个极简的、能触发漏洞的PDF结构可能如下:
%PDF-1.1 1 0 obj <</Type /Page /Contents 2 0 R>> endobj 2 0 obj <</Length 2 0 R>> % 关键:Length键的值指向自身(2号对象) stream (任意流数据) endstream endobj xref 0 3 0000000000 65535 f 0000000010 00000 n 0000000050 00000 n trailer <</Size 3 /Root 1 0 R>> startxref 100 %%EOF这个PDF中,2号对象是一个流字典,其Length键的值是2 0 R,即指向自己。当解析器试图获取这个流的长度时,就会陷入我们之前分析的无限递归。
我们可以用Python脚本快速生成这个PoC,并用编译好的有漏洞的pdftotext测试,确认其能稳定触发段错误。同时,用打过补丁的程序测试,应该能正常报错(如“Bad ‘Length’ attribute in stream”)而不会崩溃。
5.2 应用补丁与重新编译
现在,让我们尝试手动应用修复。我们需要修改Xpdf的源代码。主要修改两个文件:
Dict.h和Dict.cc:在Dict类中添加lookupLength方法的声明和实现。Object.h和Object.cc:在Object类中添加dictLookupLength内联方法的声明。Parser.cc:将makeStream函数中对dictLookup的调用改为dictLookupLength。
具体代码改动可以参考原始漏洞报告或社区提交的补丁。这里简述关键部分:
在Dict类定义中(Dict.h)添加:
class Dict { public: // ... 其他方法 Object *lookup(char *key, Object *obj); Object *lookupLength(char *key, Object *obj); // 新增 };在Dict.cc中实现:
Object *Dict::lookupLength(char *key, Object *obj) { DictEntry *e; // 关键区别:使用 copy 而不是 fetch return (e = find(key)) ? e->val.copy(obj) : obj->initNull(); }在Object类中(Object.h)添加:
inline Object *dictLookupLength(char *key, Object *obj) { return dict->lookupLength(key, obj); }最后,在Parser.cc的makeStream函数中找到相应行并修改。
修改完成后,重新编译Xpdf(记得仍然使用AFL++的编译器插桩):
cd xpdf-3.02 make clean CC=afl-clang-lto CXX=afl-clang-lto++ make -j$(nproc) cp xpdf/pdftotext ~/fuzzing_xpdf/install/bin/pdftotext.patched5.3 修复效果验证
现在,我们有两个pdftotext:原始有漏洞的版本和我们打过补丁的版本。进行对比测试:
# 测试原始版本(应崩溃) ~/fuzzing_xpdf/install/bin/pdftotext ./crash_poc.pdf - 2>&1 | grep -E “Segmentation fault|Aborted” # 测试修复版本(应输出错误信息,而非崩溃) ~/fuzzing_xpdf/install/bin/pdftotext.patched ./crash_poc.pdf - 2>&1 | grep -i “bad length”如果修复成功,原始版本会因段错误而崩溃,而修复版本则会打印类似“Bad ‘Length’ attribute in stream”的错误信息并安全退出。你还可以用正常的PDF文件测试,确保修复没有引入功能回归(即正常文件依然能正确转换)。
5.4 深入思考与扩展
成功修复一个CVE很有成就感,但我们的学习不应止步于此。可以思考几个更深层次的问题:
- 漏洞的普遍性:这种“对象自引用”导致的无限递归,是否在其他PDF解析库(如Poppler、PDFium)中也存在?尝试用类似的思路和Fuzzing方法去测试一下。
- Fuzzing的局限性:AFL++通过代码覆盖率引导,能高效发现使程序执行新路径的输入。但对于这个漏洞,触发路径其实很单一(就是那条递归链)。是否有可能存在其他更复杂的引用循环(比如A->B->C->A),而我们的Fuzzing没有触发?这引出了对Fuzzing种子质量和变异策略的思考。
- 防御性编程:除了打补丁,在代码层面如何避免此类问题?例如,可以在
Object::fetch或XRef::fetch中设置一个递归深度上限,超过阈值则视作错误。或者,在解析过程中维护一个“已访问对象”的集合,检测循环引用。
这次实战,我们完整走过了模糊测试驱动漏洞研究的闭环:环境搭建 -> 目标插桩 -> 启动Fuzzing -> 捕获崩溃 -> 调试分析 -> 理解原理 -> 修复验证。每一个环节都有其门道和技巧。掌握这个流程,你就拥有了挖掘未知漏洞的基本能力。Xpdf的CVE-2019-13288只是一个开始,网络上还有无数等待被测试的软件。将这套方法应用到新的目标上,才是真正的挑战和乐趣所在。记住,耐心和细致是安全研究员最重要的品质,尤其是在分析那些令人抓狂的崩溃调用栈时。