技术文章

在 Delphi 中安全地解析 PDF 字典:防范名称 Token 陷阱

子字符串搜索(例如 Pos('/Length', Dict))是读取 PDF 字典的错误工具,因为 PDF 名称键共享前缀: /Length/Length1 的前缀,而 /Encrypt/EncryptMetadata 的前缀;ISO 32000-1 §7.3.5 将名称定义为仅在定界符或空白处结束的 Token,因此只有在其后的字节是这些字符之一时,键才算作被找到;跳过这单个检查的字典读取器,最终会从一个完全有效的文件中读取到错误的值

让我们吸取教训的 bug 看起来完全不像词法分析器问题;一项合规性扫描开始报告 FontFile流已损坏:解压后的字体程序是一个片段,在表中间被截断了;该文件在每个查看器中都能正常打开;磁盘上的流数据完好无损;根本原因在于我们共享字典读取器的这一行代码中: Pos('/Length', ...) 匹配到了 /Length1(这是 FontFile 流字典根据 ISO 32000-1 表 127 携带的标准键),读取器将 /Length1 之后的整数作为流长度;该特定文件的编写器恰好在 /Length 之前序列化了 /Length1,它完全可以自由地这样做,因为根据 §7.3.7 字典条目是无序的;流被截断为垃圾字节数,消耗它的每个下游检查都默默地视而不见了

为什么子字符串匹配会破坏 PDF 字典解析?

子字符串匹配之所以会破裂,是因为 PDF 名称空间中充满了刻意的前缀家族,并且字典条目顺序未指定;ISO 32000-1 表 127 为嵌入式字体流定义了 /Length1/Length2/Length3,它们全部与同一个字典中的 /Length 并排坐着;加密字典将 Trailer 中的 /Encrypt 与其内部的 /EncryptMetadata 配对;短键更糟糕:以 Pos('/' + Key, ...)Key = 'N' 构建的查找会高兴地降落在 /Name/Nums 上;这些冲突都不需要文件畸形;将 /Length1 排在 /Length 之前的写入器完全是合规的,这意味着子字符串 bug 并不是针对损坏输入的健壮性缺陷 —— 而是针对有效输入的正确性缺陷

其失效模式也是安静的那种;错误的 /Length 并不引发异常,它交还给您的字节切片比流实际占用的要短或长;如果该切片喂给了字体子集检查、CMap 解析或元数据扫描,消费者看到的是垃圾,并且通常什么也不报告,因为半个 zlib 流纯粹是无法解压,代码随即继续前进;我们交付了正是这类的缺陷,并在对 ISO 32000-1 §7.2–§7.3 进行逐条审计后,在共享读取器的 v2.14.3 中修复了它,该审计将每个 Pos 样式的键查找都标记为可疑

ISO 32000-1 §7.3.5 实际将名称定义为什么

第 7.3.5 节简短而精确:名称对象是一个斜杠后跟一系列常规字符,并且该 Token 仅终止于第一个定界符或空白字符;定界符是八个括号字符加上斜杠和百分号 —— ( ) < > [ ] { } / % —— 空白是空字符(Null)、制表符、换行符、换页符、回车符和空格(§7.2.2–§7.2.3);该终止规则就是全部的故事; /Length1 并不是“/Length 后面跟着一个 1”,它是一个单一的、不可分割 of Token,正像 Pascal 中 LengthOneLength 是不同的标识符一样;任何通过原始字节搜索寻找键的读取器,都是在删除终止规则的情况下重新实现词法分析器

下面是缺陷的形态(已简缩至其要点);该版本可以通过编译,能通过针对那些将 /Length 排在最前方的写入器的文件测试,但会损坏那些不这么做的写入器的流:

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

完整 Token 匹配:检查键之后的字节

该断言直接源自 §7.3.5:仅当紧随其后的字符是定界符、空白或缓冲区结尾时,候选匹配才是真实的键;其他一切都是仅仅共享前缀的更长名称,因此搜索必须继续越过它,而不是放弃;我们读取器中的修复用基于该规则的单个共享例程替换了每个原始的 Pos 查找:

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

该循环中的两个细节具有分量;首先,它会继续搜索,而不是在第一次前缀冲突时返回失败,因为 /Length1 120 /Length 4076 是合法的顺序,而真正的键仍在前方;其次,缓冲区结尾情况也算作终止符,因为字典片段完全可以在名称之后合法结束;在您自己的代码中值得审计的一个更微妙的点:如果您的搜索字符串不包含斜杠,那么匹配的左侧也适用相同的规则,否则 Pos('Length', ...) 会降落到 /PieceLength 内部;像上面那样用领先的 / 锚定搜索字符串就处理了左边缘,因为 / 本身就是结束前驱 Token 的定界符

恶意的 PDF 如何将解析器 bug 转化为千兆字节的内存分配?

畸形或恶意的文件会将这些词法错误升级为资源耗尽,因为字典中的整数经常喂给分配大小;我们的审计在对象流扩展中发现了正是这种形态的链;ObjStm 字典的 /N 条目说明了流持有多少个压缩对象,扩展代码在由其决定大小的数组上调用了 SetLength;然而,整数解析器在失败时没有触及其 Out 参数,却仍将其返回 —— 导致非数字的 /N 交给 SetLength 一个未初始化的栈值;在仅仅扫描一个您甚至还没同意信任的文档时,由几字节的损坏输入触发的此处垃圾正整数,就意味着数 GB 的分配请求

该修复有两个独立的部分,两者都可以推广;解析器现在在失败时返回显式的 0,绝对不返回未初始化的内存;消费者不再盲信免于算术检查的 /N/First 之前的 ObjStm 头部区域为每个压缩对象存储一对整数 —— 对象号和偏移量,每对包括分隔符在内至少占用四个字节;因此,高于 FirstVal div 4 + 1 的任何 /N 对于声明的头部大小在物理上都是不可能的,在任何分配发生前就被拒绝了;该边界仅花费一次比较,且是从已手头的数据推导出来的,这就是要寻找的模式:文件本身证明的上限,而不是任意的常量:

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

在我们的读取器中,另外两个上限完成了防御边界(都在 v2.12.0 中发布);流读取器在分配结果缓冲区前,会拒绝比整个文件还要大的任何 /Length —— 流不可能比承载它的容器还要大,因此该检查没有任何误报;解压路径将解压后的输出限制在 256 MiB,这关闭了经典的 zlib 炸弹(即几 KB 的输入无限膨胀);该限制对于任何真实的 PDF 流都是宽容的,同时能保持最坏情况可存活;这三者的主题是相同的:每种文件声明的大小都是一个声称,解析器在向其分配内存之前,都会对照其可以测量的内容来验证每个声称;同样的审计姿态也适用于绑定边界的下一层,这在Delphi 中硬化 PDFium ABI 与内存安全中进行了介绍

仅有完整 Token 规则还不够的地方

坦诚的限制,免得您过度信任上面的例程;完整 Token 匹配修复了键识别,但是字典跨度上的扁平字节搜索仍无法判断匹配是否位于嵌套字典、字面量字符串或注释内部 —— 如果您交给他过宽的跨度,页面对象上的 FindDictKey 可能会落在其 /Resources 子字典内的键上;我们的读取器首先将跨度限制在单个对象主体内,并将字符串和注释上下文视为单独的、仍开放审计的项;子字符串安全是梯子的一阶,而不是整个梯子:交叉引用一致性有其自身的规矩(在验证对象和交叉引用流中涵盖),对非您创作的文档的更广泛威胁目录在审计 PDF 安全风险中有所述

如果您在 Delphi 或 Lazarus 中维护手动编写的字典读取器,本次事件的清单很短;在代码库中搜索每个 Pos('/,并将匹配结果导向一个完整 Token 辅助函数;列出您的键参与的前缀家族 —— /Length/Encrypt/N/Type/Type1 都出现在真实文件中;然后遍历到达 SetLengthGetMem 或复制循环的每个整数,并询问是什么限制了它:文件大小、源自头部的上限,还是毫无限制;这里描述的解析层是我们的 PDFium Component 的基石,其字节级读取器和渲染引擎在它们触及的每个文档上互相交叉检查