技术文章

重建损坏的PDF交叉引用表:Delphi恢复扫描

当PDF交叉引用表已经不可用时,正确的做法是彻底无视它,从文件主体重新构建。PDFlibPas Delphi PDF Library的做法是用一个单遍token扫描器,记录它见到的每一个真正的间接对象头,然后恢复trailer字典,把重建好的表交给正常的加载流程

PDF损坏时最先坏掉的是什么

交叉引用表是PDF中最脆弱的部分,因为它是唯一存储绝对字节偏移量的部分。ISO 32000-1 §7.5.4把这些条目定义为从文件起始处算起的十位数偏移量,§7.5.5则把startxref关键字放在文件末尾附近,指向这张表本身。任何一次移动了字节的编辑都会让这些数字全部失效。一次以文本模式运行、把CRLF转换掉的FTP传输,一次被截断的下载,共享盘上一个坏掉的扇区,一个没有正确写入增量更新就做了追加操作的批处理工具:这些情况都会让对象数据依然完好可读,而索引却指向了垃圾

这就是为什么"文件已损坏,正在修复"这种对话框如此常见。字节几乎总是还在,丢失的是地图。所以重建并不是对丢失数据的取证式恢复,而是重建一个本来就可以从文件主体推导出来的索引,而且它成功的概率远高于用户的预期,因为真正昂贵的内容——页面树、字体和图像——都完好无损

为什么扫描N 0 obj会找到假匹配?

一个朴素的重建做法是在原始字节中搜索"整数,整数,obj"这种模式,并记录每一次命中。它会找到太多东西。PDF是一种容器格式,文件中有三个区域对对象语法来说是不透明的:注释(§7.2)、字符串(§7.3.4)和流数据(§7.3.8)。这三者中的任何一个都可能包含读起来跟对象头一模一样的字节,而它们没有一个真的是对象头。字面量字符串里的一段说明文字、遗留下来的调试注释,或者两兆字节的Flate或DCT输出,都很可能凑巧生成一串看起来像99 0 obj的字节

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

每一个假条目都要付出双重代价。它会用一个根本不存在的对象号污染重建出来的表,还可能把文件后面真正出现的、同一编号的真实对象给遮蔽掉。因此PDFlibPas根本不做模式匹配,而是做词法分析(tokenise):它始终清楚光标下的字节是代码还是载荷,载荷会被跳过而永远不会被拿去解释

基于64 KiB数据块的单遍状态机

PDFlibPas以64 KiB为单位,把整个文件恰好扫描一遍,状态机建立在ISO 32000-1 §7.2的token规则和§7.3.10的间接对象语法之上。一个token在遇到空白或某个界定符字符时结束,只有当扫描器看到完整的一串"正的对象号、非负的代数号、裸的obj关键字"序列时,才会记录一个对象头。记录下来的偏移量是对象号这个token的起始位置,这正是交叉引用条目必须指向的位置,而不是obj关键字所在的位置

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

重要的细节在于,token状态和字符串状态能够跨越数据块边界存活下来。一个横跨65536字节分界线的对象头依然能被识别出来,因为未完成的token、待定的整数对,以及"是否处于字符串内"这些标志都会被带入下一个数据块。缓冲区大小是固定的:扫描用64 KiB,可能用到的最长token用32字节,唯一随文件增长的数组是对象号、代数号和64位偏移量这几个列表,它们的大小与真实对象数量成正比,而不是与文件大小成正比。实际运行时,这个扫描过程对整份文档只发出顺序读取,加上最多两次显式seek,这正是它能在直接访问式合并与拆分一文所讨论的数百兆字节级输入上可行的原因

为什么不能信任流一定在endstream处结束?

因为流数据是任意字节,而任意字节完全可能碰巧拼出endstream。一个在stream关键字之后开始的流,必须被当作不透明数据跳过,直到它真正结束,但结束关键字的第一次出现只能算是一个候选。PDFlibPas通过要求佐证来解决这个问题:只有当紧随其后的下一个非空白token是一个独立的endobj时——也就是§7.3.8对流对象前后要求的那个序列——一个endstream token才会被认定为流的真正结束。压缩数据里偶然出现的命中几乎从来不会带着这样的后续,所以扫描器会留在流内继续前进。还有两条更小的规则同样重要。stream关键字只有在作为裸关键字出现时才会进入流状态,所以字典里一个名为/stream的名称对象绝不会触发它。而objtrailer token只有在没有超过32字节上限、且开头不是斜杠的情况下才会被采信。没有这两道保护,一个键名恰好凑巧的资源字典就足以让整个扫描跑偏,这正是安全解析不可信PDF笔记中所讨论的那一类对抗性输入

找到trailer字典真正的结尾

恢复对象只完成了一半工作,因为加载器仍然需要一个trailer才能找到/Root。PDFlibPas会记住扫描过程中发现的最后64个trailer关键字位置,并按从新到旧的顺序反向校验,让最新的可用trailer胜出;一个后面不是字典的孤立关键字会直接校验失败,转而尝试上一个候选。每个候选都以1 MiB为上限读取,字典的结尾通过跟踪嵌套的<<>>深度、以及字面量字符串转义、十六进制字符串和注释来定位

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

深度跟踪不是纸上谈兵。一个被截断、丢掉了/Encrypt的trailer,会把一份本可恢复的加密文档变成一份根本打不开的文档;丢掉/Info或某个自定义子字典,则会悄无声息地丢失下游系统可能依赖的元数据。如果文件是加密的,恢复出来的trailer正是让正常的凭据校验流程得以运行的东西,其重试语义与加密文档加载一文所述的完全相同

重建挽回不了什么

重建是一种尽力而为的手段,诚实说明它的局限,也是发布这项功能的一部分。有三种情况会彻底失败。打包在对象流内部(§7.5.7)的对象,对字节扫描来说是不可能单独可见的,所以如果一个容器幸存下来,但它的交叉引用流(§7.5.8)没有幸存,那么这个容器所持有的对象就不会被重建过程索引到。一份文件主体真正损坏(而不仅仅是索引出了问题)的文档,会产生内容已经无法解析的对象头。而一份既没有可恢复的trailer关键字、也没有可读目录的文件,不管找到了多少个对象头,都没有东西可以把文档树挂上去

重复的对象号是个有意思的中间情况。一份经过增量更新的文件,合理地包含同一个对象号的多个代次,而存活下来的交叉引用链是唯一记录"哪个才是当前版本"的地方。重建过程没有这条链可用,所以它按文件中出现的顺序记录每一个看到的对象头,事后再按对象号解析。通常是较晚的版本胜出,这通常也是对的,但一份先被更新、后又被部分回滚过的文档,恢复出来的结果可能与原始xref所描述的存在微妙差异。线性化文件在另一个方向上也有同样的注意事项:一旦索引被重新生成,首页布局和提示表就毫无意义了,所以修复后的文件应当被当作一份普通的、非线性化的文档来对待

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

这个回退路径是自动触发的:只要交叉引用链无法读取,PDFlibPas就会运行原始字节扫描,另外当每一个在用条目都声称自己的偏移量是零时也会触发——这正是一张写出了框架却从未真正被填入内容的表的典型特征。GetDocumentRepaired在这条路径被执行时返回1,这个值值得记录而不是忽略,因为一份靠重建才加载成功的文档,应该被重新另存为一份干净的文件,而不是像什么都没发生过一样继续留在流程里。重新保存会写出一张全新、一致的交叉引用表,这是对下游所有消费者来说成本最低的修复方式

这里展示的重建路径、GetDocumentRepaired标志位以及流式加载器,都是PDFlibPas Delphi PDF Library的一部分,与本博客其他文章介绍的解析、渲染和签名API同属一个体系