技术文章

Delphi 中的 PDF 解压炸弹:HotPDF 过滤器链预算

一份 20 KB 的 PDF 就能拖垮一个服务进程,直到被 OOM killer 杀掉,这不是你代码里的 bug,而是一枚解压炸弹。面向 Delphi 和 C++Builder 的原生 VCL PDF 组件 HotPDF,用 DecodeBudgetBytes 来约束这种情况——这是一个按过滤器链设定的上限,默认值为 268435456 字节,把每一个解码阶段的开销都记到同一份共享预算上

那份吃掉一个工作进程的 20 KB 文件

这类事故的模式总是一样的。一个负责渲染缩略图的队列工作进程接到一份上传文件,两秒之内常驻内存就飙过 12 GB,进程消失,连堆栈跟踪都没留下。这份文件只有 20 KB。它只有一页,一个内容流,一个带五个条目的 /Filter 数组。数组里的每个名字都是规范定义的过滤器,每个阶段解码都不报错,文件里没有任何格式错误的东西。这正是这一类输入棘手的地方:没有一个损坏的字节可供拒绝

这和"正确解码单个过滤器"是两个不同的问题。把 LZWDecode/DecodeParms 预测器处理对,本身就是一个独立话题,在已加载文档上的 LZW、预测器与 DecodeParms 详解里有讲。这里的每个解码器本身都是对的。真正出问题的是,把五个正确的解码器首尾相连跑一遍、又没有人统计总量时会发生什么。ISO 32000-1 §7.4 明确说 /Filter 可以是单个名字,也可以是名字数组,数组会按顺序依次应用,先第一个条目。但它完全没提到某个阶段能把输入膨胀多少,也没提到整条链的累计效应。ASCIIHexDecode 阶段大致把输入缩小一半,听起来无害。FlateDecode 阶段作用于一串零字节时,膨胀比能达到千倍量级。把它们串起来,算术效应是乘法级的:20 KB 变成 20 MB,再变成 20 GB,而其中每一个步骤单独看都是对一段合法流的合规解码

为什么按过滤器逐个设限拦不住解压炸弹

因为按过滤器设限的做法,会在 /Filter 数组的每一个元素上重新充能。一条五个阶段的链,如果每个阶段的上限是 256 MiB,那就等于放行了 1.25 GiB,而且不管前面四个阶段产出了多少,最后一个阶段依然拿到一份全新的额度。这个限制被诚实地执行着,却什么关键问题都没约束住。HotPDF 在 v2.447.0 之前正是这个样子,而且还有第二个缺口与之并存。LZW 解压器带有一个 MaxOutputBytes 上限,图像预测器路径也会核算自己的行数,所以这两处是局部有界的。FlateDecodeASCIIHexDecodeASCII85DecodeRunLengthDecode 则完全没有上限:每一个都会往一个 TMemoryStream 里写,直到输入耗尽或分配器放弃为止。所以一条恶意构造的链有两条路可走:要么用一个完全不设防的过滤器,要么用设了防的过滤器,但直接多加几个

还有第三个细节,一个粗糙的修复方案会漏掉。你真正该关心的数字,不是最终解码结果的大小,而是峰值,而峰值通常出现在某个中间缓冲区里。一条最终只输出 4 MB 内容流的链,可能在第三阶段分配了 8 GB,最后交出来的结果却看起来完全合理。事后检查结果的长度,对那个把进程干掉的分配行为什么都说明不了

每条过滤器链一个预算跟踪器

HotPDF v2.447.0 的修复方案,是把核算范围从"单个阶段"扩大到"整条链"。每条过滤器链都会构造一个 THPDFDecodeBudgetTracker,每个解码器都通过一个包裹着真正目标流的 THPDFBudgetWriteStream来写入。这个包装器在转发任何一个字节之前,都会先调用 Budget.Consume(Count),所以拒绝发生的时机,是目标流还停留在旧尺寸的那一刻。这个先后顺序才是关键所在:一个在缓冲区已经膨胀之后才执行的检查,只是一份诊断记录,起不到防御作用

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

那些局部上限并没有消失,它们变成了共享预算的投影。LZW 阶段现在会设置 Decoder.MaxOutputBytes := Budget.RemainingBytes,所以它自己的上限就是这条链还剩下的额度,而不是一份独立的配额。图像预测器阶段用 BeginFilter 开场,在分配之前先通过 Consume 把自己的行数需求记到账上,这意味着预测器的输出会和喂给它的通用过滤器算到同一份预算里。这一点在图像这条路径上尤其重要,因为过滤器链和预测器本就是同一个操作的两半,详情见通过解码过滤器从已加载文档中提取图像一文

预算拒绝时,调用方会看到什么

在调用栈最底层,一次拒绝会抛出 EHPDFDecodeBudgetError。往上一层,具体表现取决于调用方 API 本来的契约。原本通过 Falsenil 报告失败的高层读取方法,依然照旧这么做,因为把一个有明确文档说明的布尔结果改成异常,会破坏那些原本就在正确处理畸形输入的调用方代码。已加载页面内容这条路径是刻意留出的例外:它会重新抛出 EHPDFDecodeBudgetError,而不是任由一份被截断的内容流被渲染成一个"只是恰好为空"的页面。这种设计意味着单独一个 False 本身是含糊的,所以预算机制会连同它一起发布一份诊断记录:THotPDF.GetLastDecodeBudgetInfo 返回该实例最近一次解码的链的状态

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

把这几个字段放到一起读,就能区分出两种攻击形态。当 PeakStageBytes 接近 DecodedBytes 时,说明是某一个阶段单独造成了全部损害,你面对的是一个膨胀比极高的单一过滤器。当 PeakStageBytes 只是 DecodedBytes 的一小部分、而 FilterCount 又很高时,说明没有哪一个阶段单独离谱,而是整条链累积着突破了上限,这恰恰是按过滤器逐个设限的方案看不见的那种情况。有一点值得写进你的处理逻辑:在该实例至少解码过一个过滤器之前,GetLastDecodeBudgetInfo 会返回 False,所以它返回 False 并不能证明这份文档是干净的

预算在何处重置,以及"零"何时是诚实的答案

DecodeBudgetBytes 约束的是一条流链,而不是一份文档,这个边界是刻意为之的,但也容易被误解。每一个内容流、每一份嵌入文件、每一个交叉引用流、每一个对象流,都会从一份全新的 256 MiB 开始计算。因此一份 4000 页的文档就有 4000 次独立的机会把上限花光,而对象流会进一步放大这个数字,因为每一个对象流本身就是一个装着许多对象的压缩容器,详情见关于对象流与增量更新的笔记。如果你真正需要的是限制进程的总内存占用,这个属性只是构成那个目标的一个输入,不是全部,它应该被放在作业级或容器级上限之后作为补充

零表示不限,这是一个正当的设置,而不是一个逃生舱口。当你完全掌控输入内容时可以这样设置:比如对自家系统生成的文档做归档重处理的流水线,或者在光栅化步骤里,一条单独的 600 dpi 彩色扫描链确实需要超过任何你敢硬编码的上限。负值会在一开始就被 ERangeError 拒绝,因为负数预算没有任何自洽的含义,悄悄把它钳制成别的值只会掩盖一处配置错误

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

挑选这个数值值得花的心思,通常比人们实际花的要多,因为一份设得太低的预算是自找的一次故障。用默认值跑一遍你现有的语料库,记录每条链的 PeakStageBytesDecodedBytes,把上限设在实际观测到的最大值之上,并留出真实的余量。一个只是听起来安全的整数,会在最不合时宜的时候拒绝一份正当的大型扫描件,而这次失败在你的日志里看起来会和一次攻击一模一样

那次不再发生的复制

让每个阶段都经过一个预算包装器,结果反而让整条链变得更省成本,而不是更贵。当一个流带有过滤器时,第一个阶段现在会直接读取源流,而不是先把编码字节复制进一个暂存缓冲区,从那时起同时存活的缓冲区只有两个:当前的输入和正在写入的阶段输出。原始复制在确实需要它的两种情况下依然保留,也就是完全没有过滤器的流,以及调用方希望保留最后一层编码的图像,因为这两种情况都要交还一个调用方自己拥有、能够独立寻址的流。这段代码在没有防护的版本里,分配得更多,约束得却更少,这也是两者之间常见的关系。不过有一点值得明说:这一切并不能让任意一份 PDF 变得可以放心加载。它只是关闭了一个特定的、代价极低的拒绝服务向量,也就是一份小文件通过嵌套过滤器换来一次巨大分配的那种向量。字节核算中的整数溢出是另外单独防护的,而"在不信任内部偏移量的前提下解析恶意文档"这个更大的问题,属于另一套纪律。解码预算只是众多约束中的一个,它的价值在于,它是你能在碰文件之前,靠设置一个属性就定下来的那一个

这套按链计费的预算机制、它的诊断记录,以及它所保护的已加载文档解码路径,全都作为组件本身的一部分随附提供,不需要配置或打补丁任何外部解压依赖。如果你正在评估如何在 Delphi 或 C++Builder 服务里约束不可信的 PDF 输入,HotPDF Delphi PDF 组件页面列出了这些限制所适用的已加载文档工具集