技术文章

PDF 修订到哪里结束?签名之后的伪造更新

合同签完,又多了一份更新,审计脚本却把这份更新并进已签修订,或把被重写的页面当成验证材料放行。只要修订终止于 startxref 指向 /Prev 链真正到达的那个节、且被重写对象保留它早先的类别,这两件事都不会发生——这就是 PDFlibPas 修订图背后的两条规则

为什么 %%EOF 是切文件的错误位置

每份增量更新都以自己的文件尾标记收尾(ISO 32000-1 §7.5.6),所以在每个 %%EOF 处切开文件看着像找修订的显然办法。但标记只有五个字节,没什么能阻止这些字节出现在内容流、嵌入文件或字体程序内部。Linearized 文件又添一层麻烦:附件 F(§F.3.4)允许首页 trailer 在靠近文件开头处用自己的 startxref 和 %%EOF 收尾,远早于主交叉引用表。逢标记就切会凭空造出没有任何交叉引用节背书的修订。猜哪些标记是假的更糟——这个猜测正是伪造者可以塑形的形状。PDFlibPas 修订图反着来:先沿 /Prev 链走,记下链真正读到的每个交叉引用节的字节偏移,然后才扫描 startxref N … %%EOF 序列,只有 N 是那些记录在案的偏移之一时才保留边界。流内部的游离标记想要算数,前面得有一个 startxref 指名一个真实的节

Delphi 中 PDFlibPas 修订图沿 /Prev 链并比对 startxref 偏移来定位增量更新修订边界,忽略流内部与 linearized 首页 trailer 中的游离 %%EOF 标记
只有 /Prev 链真正读到的 startxref 偏移才算修订终点,流内部的游离 %%EOF 字节对 PDFlibPas 在哪里切文件毫无发言权

这三个记号之间的字节远没有 §7.5.5 画得那么整齐。规范把 startxref、偏移和标记画在连续三行上,但 §7.2.2(Table 1)的行尾标记和其他空白字符都可以重复,没有规则限制次数。漏掉一个填充出来的标记,两个修订就黏成一体:已签的那个看起来终止在没人签过的字节之后。PDFlibPas 修订图按记号到操作数再到标记的顺序走,不管中间隔多少空白、填充落在文件哪个位置,而且每个关键字只计一次。trailer 关键字也有个同样的松关节:<< 是分隔符(§7.2.2,Table 2),§7.5.5 从不要求 trailer 后面换行,所以一行写 trailer<< 是合规语法。逐行读的话,这一行看着像交叉引用子节头——链条走到一半就断了

用 PDFlibPas 走一遍修订图

修订访问器声明在 TPDFlib 上,实现在 Small Business 版里:从 PDFlibSmallBusiness 单元创建 TPDFlibSmallBusiness(Enterprise 派生自它并带有同样的调用)。在裸 TPDFlib 上它们返回各自的失败值(0、7 或 -1),并把 LastErrorCode 设为 PDFLIB_ERROR_SMALLBUSINESS_REQUIRED。修订从 0 编号,即原始文档。每个调用都接收文件名和密码,从磁盘字节构建修订图,所以答案描述的是文件存储时的样子,绝不是你恰好加载并编辑过的那份文档。GetDocumentRevisionKind 报告修订里最深远的那类改动。一个既添加 OCSP 响应又重写页面的更新,会被报告为页面重写:

类别含义典型来源
0仅整理性改动:不改变阅读器所见任何内容的对象交叉引用流、新的对象流容器
1验证材料文档安全存储(DSS)、VRI、证书、CRL、OCSP 响应
2文档时间戳一份 /DocTimeStamp 签名及承载它的字段
3签名接收到值的既有签名字段
4表单字段字段值或其外观
5注释widget 之外的任何注释
6文档内容任何能改变文档显示内容的东西
7无法判定修订里有读不出来的东西;按不安全处理
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // 每个对象一行 'objnum,change,class,detail'
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // 条目从 1 数起
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

信任其余调用之前,先读 GetDocumentRevisionsComplete。只有链条一路走回原始节它才返回 1;断裂或成环的 /Prev、或深过 256 节的链,会让最老的更新看不见,此后每个计数和索引都只是下限。256 的深度给真实文件的保存与签名历史留足了空间,而指回自己的链也几乎立刻终结。留心循环里的索引:GetStringListItem 从 1 数起,修订却从 0 数起,内层循环从 0 开始会静悄悄丢掉每个修订的第一个改动对象,不报任何错

伪造更新如何把自己伪装成验证材料

PAdES 长期验证本来就合法地在最后一份签名之后追加更新:文档安全存储(/Type /DSS)、每签名一份的 /VRI 字典、证书与吊销流、文档时间戳。认证级别放行它们,这让验证材料成了更新能穿的最贵标签,也是伪造起来最便宜的。/Type 写什么全看更新乐意。渲染页面的查看器从不看内容流、资源字典或字体的 /Type,所以一个更新可以重写第 3 页的内容流、在字典上加 /Type /DSS,所有渲染器照样画出新文字,而相信 /Type 的分类器看到的是整理性改动。/VRI、/Sig 和 /DocTimeStamp 同理

PDFlibPas 修订图无视更新给自己重写的对象贴的标签。被重写对象算作验证材料、时间戳或签名的前提,是同一对象的早先版本本来就是。证书、CRL 和 OCSP 响应流自己不带类型,靠指向它们的文档安全存储来识别——而且只有正在判定的修订或更早修订里写出的存储才算数。后加的存储不能回溯为已签修订写出的对象作保。凡改变阅读器解析结果的结构性改动都按内容计:

PDFlibPas GetDocumentRevisionKind 把重写页面内容流的伪造 /Type /DSS 更新报告为文档内容,因为修订图按对象的早先版本分类被重写对象
给被重写的页面内容流贴上 /Type /DSS 标签对渲染器毫无影响;PDFlibPas 依然把该更新报告为文档内容,因为这个对象此前就是页面内容
  • 替换既有的对象流,因为它内部的每个压缩对象现在都解析到替换者(全新容器仍算整理性改动,每个成员有自己的条目、在那里被判定)
  • 添加 catalog 或页面
  • trailer 的 /Root 指向不同的 catalog,哪怕更新没有重定义签名覆盖的任何东西——换根等于换掉整个文档,而一个被签对象都不用碰
  • 字典与旧版逐字节相同的被重写流:间接 /Length 或填充到同尺寸的替换体让字典不变而画面变了,所以仅凭这一证据,只有没有流体(stream body)的对象才被视为未变

对象流成员没有头可剥

PDF 1.5 起,更新可以把被重写对象存进对象流,而 §7.5.7 对格式说得很直白:只存对象值,不得使用 obj 和 endobj 关键字。在对象位置读取并用搜索字符串 obj 的方式剥头的代码,在成员开头什么也找不到,还会一路搜进它的身体。给签名后重写的页面字典塞一个貌似无辜的字符串,比如 (obj<</Type/DSS>>),搜索就停在字符串内部。解码器拿到的是 <</Type/DSS>>——一份该修订从未写出的存储字典。PDFlibPas 修订图从非压缩对象的开头解析 N G obj,核对数字确实是它预期的那个对象,把缺失或不匹配的头当作不可读,不再继续搜

交叉引用流自带一层字段级的歧义。按 §7.5.8.3(Table 18),只有 type 1 条目持有字节偏移;type 2 条目的第二个字段是承载对象流的编号、第三个是流内索引,而 type 0 条目的第二个字段是下一个空闲对象号。把那个对象号当偏移读,压缩条目就落进恰好跨着这些小字节位置的修订——几乎总是原始文档,重写就此藏进被签的字节里。修订图按声明条目的那个交叉引用节安放每个条目。同一条款还说,0、1、2 之外的条目类型是对 null 对象的引用,这类条目既不添加也不释放任何东西,留在图外

签名审计什么时候该拒答?

只要文件对自己历史的一部分有所保留。修订图用三种方式报告这一点:GetDocumentRevisionsComplete 返回 0、GetDocumentRevisionKind 对签名后的某个修订返回 7、或 GetSignatureRevisionIndex 返回 -1。最后那个值涵盖不在修订边界收尾的 ByteRange——有字节落在签名声称覆盖的区间内部——同样涵盖不存在的字段、没有签名值的字段,以及位于读不出来文件里的字段。三种情况都连同修订索引和 GetDocumentRevisionChanges 行转人工审查,让打开文件的人从对象号出发

// 按 DocMDP 风格的策略检查一个签名之后的更新。
// Kind 按严重度排序,所以每个策略级别都是一个天花板:
// P 1 容忍整理性改动、验证材料与时间戳(0..2),
// P 2 加上签名与填表(..4),P 3 再加注释(..5)。
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // 历史有一部分从未被看到
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // 不在修订边界上、没有这个已签
                                 // 字段,或文件读不出来
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 高于一切天花板;写给读者看的
  end;
  Result := True;
end;

天花板跟着 ISO 32000-2 §12.8.2.2(Table 257)走:P 1 不允许任何改动,2 允许填表和签名,3 加上注释,而只添加 DSS 数据或文档时间戳的增量更新在所有级别都不算改动。ISO 32000-1 §12.8.2.2 定义了同样三个值但没有那条豁免。策略是你自己的而不是认证者的时候,循环自己写——比如一份不管文件带什么 P 都拒收带注释合同的收件规则。策略应当来自文件时,同类上的 GetSignatureModificationVerdict 读取认证级别,套用检测 FieldMDP 锁定表单字段的编辑一文所述的逐字段锁,并对越界情形返回 5。这两者回答的问题与对照你留存的文档副本给改动分类不同:修订图只需要眼前的这个文件

Delphi 中 PDFlibPas 的 DocMDP 风格签名审计:用 GetDocumentRevisionKind 天花板执行 P 1、P 2 与 P 3,先检查 GetDocumentRevisionsComplete 与 GetSignatureRevisionIndex
每个 DocMDP 级别变成修订类别上的一个天花板:P 1 容忍 0 到 2、P 2 到 4、P 3 到 5,无法判定的修订在任何级别都不通过

追加走同一条链。增量更新的 trailer 必须带 /Prev 条目,给出前一个交叉引用节的位置(§7.5.6),而只能靠全量扫描重建交叉引用表才能打开的文档没有这种位置可给。AppendToFile 对它返回 0,目标文件原样不动。改为完整重存再签干净的副本——完整重写不会把既有签名带过去

对已签合同的归档,在追加任何新东西之前,每个签名字段跑一遍 UpdatesWithinPolicy。后续修订读出 kind 7 的文件应先到人手里,再谈任何会把这个读不出的修订埋进又一个更新之下的 PDFlibPas 签名或长期验证步骤