PDF 不是你打开的文档。它是你运行的小程序。每个嵌入的字体都是一个等待 charstrings 的基于堆栈的解释器,每个图像都是一个被输入了由文件选择的宽度、高度和位深度字段的解码器,而每个流都包裹在文件设置参数的过滤器中。这些数字没有一个是你的。它们来自生成该文件的人(在实际工作负载中,可能是客户的发票或来自未知发件人的附件)。将这些字节转换为像素和字形的解码器是攻击面,而信任其输入的解析器只需要一个畸形文件就会发生崩溃或更糟的事情。
PDFlibPas 经历了一次强化流程,将整个解码路径视为敌对路径,涵盖字体程序(TrueType、Type1、CFF 和 CMap 表)、图像解码器(PNG、GIF、TIFF、JBIG2 以及 CCITT Group 3 和 Group 4)和流过滤器(LZW、ASCII85 和 Flate 预测器)。以下是它关闭的五个缺陷类别,每个类别都基于使其成为可能的特定 Delphi 行为。它们在当前版本中已得到修复,并且在任何解析不受信任输入的 Pascal 代码中都会重复出现相同的形式。
将尺寸过小的缓冲区交还给你的整数溢出
图像解码器中经典的内存安全错误是维度乘积发生回绕。解码器读取宽度、高度、分量数 and 位深度,将它们相乘以确定其输出的大小,分配这么多字节,然后以其真实维度写入图像。如果乘法是在 32 位算法中完成的,即使每个单独的因子都在合理的范围内,乘积也可能回绕为一个很小的值,从而使分配成功但分配的内存过小,解码过程就会超出其末尾。这就是 CWE-190(整数溢出),并在一步之后导致堆越界写入(CWE-787)。
共享图像路径已经将每个维度限制在 65535 以内;而独立的解码器并没有完全继承该限制。即使你为结果分配的变量很宽,在 Delphi 中,当两个操作数都是 32 位整数时,诸如 ByteCount * FHeight 的行字节数乘以高度表达式,或诸如 FWidth * Components * BitDepth 的每像素表达式都是 32 位乘积。对于大型扫描,60000 的宽度和高度都是合理的,但它们以字节为单位的乘积超出了有符号 32 位范围,长度结果会变小。相同的陷阱也存在于 ZLib 预测器步长 BitsPerComponent * Colors * Columns 中。
修复方法是将至少一个操作数设为 Int64,使整个表达式在 64 位中评估,然后在收窄回来调用 SetLength 之前与 MaxInt 进行比较并拒绝文件。
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
使这成为 Delphi 特有防线而不是通用问题的原因是静默收窄。将过宽的表达式分配给 32 位目标是编译器默认不会警告的合法转换,并且范围检查无法捕获在值用作索引之前发生的绕回。将乘积保留在 32 位,语言就会悄悄地给你一个谎报了解码即将触及内存大小的长度。
导致防护无法触发的字段类型
TIFF 文件是图像文件目录(IFD)的链,每个目录都携带下一个目录的字节偏移量。恶意文件可以将该链指向其自身,没有停止条件遍历它的读取器将永远运行下去。这就是由攻击者控制的输入驱动的无限循环(CWE-835),防范方法是一个在计数超过任何合法文件都不会达到的限制时停止的计数器。
页面计数器被声明为 Word,在 Delphi 中它保存 0 到 65535。循环包含一个形式为“当页面计数超过 65535 时停止”的终止防护,这读起来是正确的,直到你注意到操作数和快照阈值共享一个上限。Word 永远不可能大于 65535,因此这种比较在结构上总是 False:当计数器达到 65535 时,下一次递增会将其绕回 0,防护永远不会看到高于上限的值,循环的 IFD 链让读取器一直在旋转。
修复方法是加宽字段,以便防护可以表达计数器实际可以容纳的值。通过将 TPDFTIFF.FPageCount 声明为 Integer,相同的 FPageCount > 65535 比较就变得可达,循环随之终止,公共 PageCount 属性也更改了类型以进行匹配,而不会破坏任何调用者。每当边界检查具有 Value > MaxValueOfType(Value) 的形式,且操作数的类型恰好是该最大值时,该条件就是一个恒定的 False:加宽类型,或者针对最大值测试相等性以便它可以触发。
在热路径上关闭了范围检查
在开启范围检查的情况下,Delphi 在每个数组和字符串索引上插入边界检查,这便是超出范围的索引引发可捕获的 ERangeError,与该索引读取或写入不属于该结构的内存之间的本质区别。热路径有时会使用本地 {$R-} 指令来禁用它,在索引不再可信之前这都是合理的。
字体解释器所依赖的列表访问器 TPDFlibStringList.Get 恰好就是这样一条路径。在 Windows 上,它是在关闭范围检查的情况下编译的,直接索引其底层存储,因此超出范围的索引不是错误,而是原始内存访问。这在索引始终有效时是没问题的,但当索引可以来自文件时,在 CFF 或 Type2 charstring 解释器内部这就不灵了。从空栈中弹出操作数的 charstring 会产生负 1 的索引;字形标识符与字形计数偏差 1 会索引超出末尾的一个槽位。在关闭范围检查的情况下,这两者都会变成真正的越界访问,而不是可捕获的异常,并且因为槽位保存着引用计数的 AnsiString 值,一次野读取也可能损坏字符串的引用计数。
强化并没有为热路径重新开启范围检查。它先让索引可以被证明是有效的:在获取操作数栈顶之前,解释器检查栈是否非空,并且每个索引防护都被写为针对计数的严格小于,而不是允许差一的“小于或等于”。该指令将边界的责任从编译器转移到了你身上,它移除的验证必须在每个入口点手动加回去。
charstring 解释器中的无限制递归
Type2 charstring 可以调用子例程,而子例程本身也是可以调用另一个子例程的 charstring,因此局部和全局子例程调用操作符能让文件决定它能走多深。直接或通过循环调用自身的子例程会无限递归,直到本机堆栈耗尽且进程死亡。这就是 CWE-674(无控制的递归)。
Type1 解释器已经防范了这一点。它携带了一个调用深度计数器和一个上限 PLType1MaxCallDepth,并且拒绝下降超过它(这反映了 Type1 规范本身命名的深度限制)。后来添加的且在结构上类似的 Type2 解释器并没有携带相同的防护,调用其自身编号的子例程的硬编码字体直接穿过静态的检查,导致堆栈溢出。
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
修复方法是给 Type2 路径提供与其 Type1 兄弟已有的相同受限深度。对受攻击者控制的结构(无论是字体子例程、嵌套数组还是交叉引用链)进行的任何递归下降,都需要一个输入无法抬高的深度上限。
泄露到输出中的未初始化内存
最微妙的缺陷是将堆内容泄露到了解密后的输出中,原因在于 SetLength 一个容易被遗忘的属性。当你使用 SetLength 增大 AnsiString 时,Delphi 会分配字节但不会将其归零,因此新区域保存了该堆内存中之前存在的内容。如果随后写入了每个字节,这完全没有关系;如果某条路径使缓冲区的一部分未写入,然后将其作为数据返回,这些陈旧的字节就会随结果一起传出。这就是 CWE-457(使用未初始化的内存),当结果跨越信任边界时,它就变成了信息泄露。
AES-CBC 解密路径恰好遇到了这个问题。输出缓冲区的大小由 SetLength 确定,解密器一次处理一个 16 字节的密文块。当密文长度不是 16 的倍数时(这是攻击者可以选择的长度),最后的局部块就永远不会被写入,因此这些最后的字节保留了 SetLength 留下的堆内容,并且该缓冲区作为文档对象的解密明文交回。补救措施是两个防护,单独一个都不够:解密入口点现在拒绝任何长度不是块大小倍数的密文,并且作为后备,输出在使用前通过 FillChar 进行清理,以便任何未能写入区域的路径都返回零,而不是堆残留物。
这一关给你留下了什么
这五个缺陷是不同的错误,但它们押韵。回绕乘积的整数宽度、将防护固定为恒定 False 的字段类型、在索引不再安全时关闭的范围检查、没有底线的递归以及语言拒绝归零的缓冲区。在其中的每一个中,Delphi 都准确地履行了它所定义的职责,因为语言给了你会回绕的算术运算、静默的收窄、可以关闭的范围检查、没有内置限制的递归以及不进行初始化的分配。这就是契约,Pascal 解析器在文件控制的每个边界处通过手动掌管这四件事来满足契约:整数宽度、范围检查、递归深度和缓冲区初始化。
在当前的 PDFlibPas(Delphi 和 C++Builder 的引擎)版本中,这些缺陷都已关闭。如果你的工作也涉及文件如何声称受保护,关于审核加密和权限以及 PDF/A 和 PDF/UA 预检的配套说明涵盖了相同解析器的分析方面,所有这些都作为 PDFlibPas Delphi PDF Library 的一部分提供,同时还包括本博客其他地方介绍的加载、渲染和签名 API。