当前位置: 首页 > news >正文

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-ltoafl-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核心并行编译,加快速度。

编译完成后,进入安装目录,你应该能看到pdftotextpdfinfo等可执行文件。我们的Fuzzing目标就是pdftotext,它负责从PDF文件中提取文本。

实操心得:在configuremake阶段,你可能会看到关于缺少xpdfpdftoppm的警告,这通常是缺少某些图形库(如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-endobjstreamendstream等标记的字典,帮助变异器更快地构造出语法上有效的文件。 你可以创建一个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::fetchDict::lookup等。这强烈暗示了无限递归的可能性——函数不断调用自身,直到耗尽栈空间,最终导致段错误。

4.2 漏洞原理:Xpdf中的对象引用循环

仅仅知道是无限递归还不够,我们需要理解这个递归是如何被触发的。这需要结合源码进行静态分析。回顾一下我们在编译时使用的Xpdf 3.02源码。

问题的核心在于PDF对象(Object)的解析和引用机制。在PDF文件中,对象可以通过编号(num)和生成号(gen)被间接引用。Xpdf使用XRef(交叉引用表)来管理这些对象。Object::fetch(XRef*, Object*)方法就是根据一个引用,去XRef表中查找并获取实际的对象内容。

漏洞触发路径可以简化为以下链条:

  1. 入口pdftotext尝试解析PDF页面内容时,会调用Page::displaySlice
  2. 获取内容流:在displaySlice中,会通过contents.fetch(xref, &obj)获取页面的内容流对象。这里的contents是一个类型为objRefObject,它内部保存了一个引用,比如(num=7, gen=0)
  3. 解析对象fetch方法调用xref->fetch(7, 0, obj)。在XRef::fetch中,它发现第7号对象是一个“未压缩”的流对象(xrefEntryUncompressed),于是创建一个Parser来解析这个流。
  4. 解析流字典Parser::getObj开始解析这个流。流对象以字典形式开始,getObj会初始化一个字典对象,并调用makeStream来创建流。
  5. 关键的一步:在Parser::makeStream中,程序需要从流字典中查找Length键,以确定流数据的长度。它调用dict->dictLookup(“Length”, &obj)
  6. 致命的循环Dict::lookup找到了Length键,但其对应的值(val)不是一个直接的整数(objInt),而是另一个对象引用objRef)。而且,这个引用的编号碰巧也是7(即(num=7, gen=0))。
  7. 递归触发lookup方法在找到引用后,会尝试通过val.fetch(xref, obj)去获取这个引用的实际值。于是,程序又回到了第3步,试图去获取编号为7的对象。
  8. 无限循环:由于第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的源代码。主要修改两个文件:

  1. Dict.hDict.cc:在Dict类中添加lookupLength方法的声明和实现。
  2. Object.hObject.cc:在Object类中添加dictLookupLength内联方法的声明。
  3. 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.ccmakeStream函数中找到相应行并修改。

修改完成后,重新编译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.patched

5.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很有成就感,但我们的学习不应止步于此。可以思考几个更深层次的问题:

  1. 漏洞的普遍性:这种“对象自引用”导致的无限递归,是否在其他PDF解析库(如Poppler、PDFium)中也存在?尝试用类似的思路和Fuzzing方法去测试一下。
  2. Fuzzing的局限性:AFL++通过代码覆盖率引导,能高效发现使程序执行新路径的输入。但对于这个漏洞,触发路径其实很单一(就是那条递归链)。是否有可能存在其他更复杂的引用循环(比如A->B->C->A),而我们的Fuzzing没有触发?这引出了对Fuzzing种子质量和变异策略的思考。
  3. 防御性编程:除了打补丁,在代码层面如何避免此类问题?例如,可以在Object::fetchXRef::fetch中设置一个递归深度上限,超过阈值则视作错误。或者,在解析过程中维护一个“已访问对象”的集合,检测循环引用。

这次实战,我们完整走过了模糊测试驱动漏洞研究的闭环:环境搭建 -> 目标插桩 -> 启动Fuzzing -> 捕获崩溃 -> 调试分析 -> 理解原理 -> 修复验证。每一个环节都有其门道和技巧。掌握这个流程,你就拥有了挖掘未知漏洞的基本能力。Xpdf的CVE-2019-13288只是一个开始,网络上还有无数等待被测试的软件。将这套方法应用到新的目标上,才是真正的挑战和乐趣所在。记住,耐心和细致是安全研究员最重要的品质,尤其是在分析那些令人抓狂的崩溃调用栈时。

http://www.jsqmd.com/news/1109472/

相关文章:

  • AI智能体实战:核心技术解析与业务落地
  • WorkBuddy 飞书账号切换(权限问题)与重装指南
  • 免费解锁Audacity专业AI音频处理:OpenVINO插件终极指南
  • 【学习记录】Week9(二):UAF漏洞利用与堆块伪造——从Double Free到Tcache Poisoning
  • 终极惠普游戏本性能控制工具:OmenSuperHub开源项目深度解析
  • 2026哈尔滨黄金回收白银回收铂金回收旧料回收怎么选?五家高实价铂金白银线下门店测评清单 + 联系方式
  • 诊所AI智能搜索:从MCP Function Calling到三级降级检索的完整实现过程
  • AI Orchestration实战:MuleSoft+LangChain企业级AI编排架构
  • MuleSoft+LangChain企业级AI编排实战:让大模型安全嵌入业务流程
  • 2026海口黄金回收白银回收铂金回收旧料回收怎么选?五家高实价铂金白银线下门店测评清单 + 联系方式
  • Hide Mock Location:Android模拟位置隐藏的完整解决方案
  • AI赋能非技术行业实战:我用DeepSeek+混元整理了2026年山西省高考志愿填报完整指南
  • 嵌入式精确计时系统设计与优化实践
  • 8大网盘直链下载终极解决方案:告别限速,一键获取真实下载地址
  • STM32与74HC32实现2x2键盘矩阵的GPIO优化方案
  • AI论文平台的合规秘籍:什么程度算学术不端?
  • 嵌入式条码扫描系统开发:LV30与PIC18F26K42实战
  • Windows 10/11终极指南:让老款PL2303芯片重获新生
  • 模板驱动文档自动化:从填空题到智能装配流水线
  • 重庆会议音响厂家哪家靠谱?答案即将为你揭晓!
  • 从零实现国密流密码ZUC:原理、代码与安全实践
  • 点线面体与抽象思维的数学钥匙
  • GPT-4稀疏激活真相:万亿参数下的MoE动态路由与工程落地
  • PIC18LF4550与IS31FL3731打造LED矩阵控制系统
  • 如何用MetaTube智能插件轻松管理Jellyfin媒体库元数据
  • springboot各种配置文件及位置的优先级是什么
  • 如何用ncmdump解锁加密音乐:三步实现NCM格式自由转换
  • STM32F411RE与TPS65263的三重降压电源方案设计
  • 计算机视觉、图像采集、计算机视觉入门
  • ncmdump终极指南:3分钟搞定NCM格式解密转换