AFL++实战:从Fuzzing101到Xpdf无限递归漏洞CVE-2019-13288挖掘

📅 2026/7/2 14:33:42
AFL++实战:从Fuzzing101到Xpdf无限递归漏洞CVE-2019-13288挖掘
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进行插桩编译 CCafl-clang-lto CXXafl-clang-lto ./configure --prefix$HOME/fuzzing_xpdf/install --disable-shared # 3. 编译并安装 make -j$(nproc) make install这里有几个细节需要解释一下CCafl-clang-lto CXXafl-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 timingrun time已运行时间。last new path上次发现新路径的时间。如果长时间如半小时没更新可能意味着Fuzzing停滞了。cycle progressstages done完成的变异阶段轮数。数字增长是好事。map coveragemap density路径覆盖密度。达到100%很难缓慢增长即可。count coverage位图计数覆盖率。stage progressnow trying当前正在使用的变异策略如“havoc”、“splice”等。findings in depthsaved 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它内部保存了一个引用比如(num7, gen0)。解析对象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即(num7, gen0)。递归触发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 CCafl-clang-lto CXXafl-clang-lto make -j$(nproc) cp xpdf/pdftotext ~/fuzzing_xpdf/install/bin/pdftotext.patched5.3 修复效果验证现在我们有两个pdftotext原始有漏洞的版本和我们打过补丁的版本。进行对比测试# 测试原始版本应崩溃 ~/fuzzing_xpdf/install/bin/pdftotext ./crash_poc.pdf - 21 | grep -E “Segmentation fault|Aborted” # 测试修复版本应输出错误信息而非崩溃 ~/fuzzing_xpdf/install/bin/pdftotext.patched ./crash_poc.pdf - 21 | 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只是一个开始网络上还有无数等待被测试的软件。将这套方法应用到新的目标上才是真正的挑战和乐趣所在。记住耐心和细致是安全研究员最重要的品质尤其是在分析那些令人抓狂的崩溃调用栈时。