技术文章

PDFium Component DocMDP:Widget /P 如何藏住页面编辑

v3.126.2 之前的 PDFium Component 构建里,TPdf.AnalyzeSignatureRevisions 会把一次真实的页面内容编辑,在 DocMDP P=3 下判成允许的注释变更,因为它的修订角色图把签名 widget 指回其页面的 /P 反向引用当成了所有权。从 v3.126.2 起,PDFium Component 把导航边与 owned-payload 边分开,页面内容就还是页面内容。这个修复背后的 bug 报告纸面上毫无杀伤力:一份认证过的合同允许注释,对手方追加了一次增量保存,分析器说后面的变更统统允许。直到有人对渲染后的页面做了 diff,第 2 页的付款金额变了

本文是签名后修订变更分析综述的攻击者视角续篇,所以跳过修订重建与 DocMDP 分级的基础,直奔对象图:所有权当初怎么建模、一条边的方向为何决定安全判定、v3.126.2 改了什么、以及怎么审计你自己的接受逻辑

页面编辑为什么能以注释变更的身份混过 DocMDP P=3?

页面编辑能过,是因为旧角色图把字典里的每条间接引用都当成「被引用对象归属于引用者」来跟随,而签名 widget 恰好指回它的页面。注释字典带着 /P——一条指向它所附着的页面对象的间接引用(ISO 32000-1 §12.5.2)。那个条目只是导航提示。widget 不拥有页面;是页面经自己的 /Annots 数组拥有 widget

分析器在给后续变更分级之前,先给每个对象分配一组角色位:页面、注释、表单和验证材料。根对象的角色来自自己的字典,角色随后扩散到它们引用的一切。旧的传播链条是这样走的:

  1. 签名 widget 是带 /FT /Sig 的 /Subtype /Widget,于是拿到注释角色
  2. widget 的 /P 把注释角色推上页面字典,而后者本就有页面角色
  3. 页面把两种角色推进 /Contents、/Resources,并经 /Parent 一路上溯 Pages 树、横跨到每个兄弟页面
  4. 像 << /Length 812 >> 这样的内容流字典没有 /Type,分类器只好退回角色位,并且先查注释角色、后查页面角色
PDFium Component 的 v3.126.2 之前 DocMDP 角色图示意:签名 widget 的 /P 反向引用把注释角色推上页面字典,页面经 /Contents 把它扩散到没有 /Type 条目的内容流上,分类器输出 prckAnnotation,P=3 分级返回 prdAllowed
v3.126.2 之前角色图把每条间接引用都当所有权,widget 的 /P 条目把注释角色推上页面,一次真实的页面编辑就这样以允许的注释变更离开了分析器

被改的内容流于是以 prckAnnotation 出炉。按 ISO 32000-1 §12.8.2.2,DocMDP P=3 允许注释变更,判定便是 prdAllowed,报告汇总成 prasAllowed。同一份文件在 P=2 下被拒,但那纯属巧合:P=2 禁止注释变更,被贴错标签的流因错误的理由遭了殃。写死四趟的传播循环还埋着第二个弱点:经间接数组到达的 payload,或经对象号倒着排的长链到达的 payload,可能一个角色都收不到

签名验证器为什么必须问「这个对象归谁」?

签名验证器必须问对象归谁,因为 PDF 增量更新(ISO 32000-1 §7.5.6)允许任何人追加一个重定义既有对象号的修订,而重定义的实体不会自报家门。签名照样验得过,因为它只覆盖自己那个修订的字节。于是每一道针对签名后篡改的防线,都取决于把每个变更对象映射到使用它的结构,再问签名者是否允许那个结构变更

好几个公开的攻击类恰恰打这个空档。增量保存攻击追加一个替换页面内容的修订,赌验证器只查签名字节区间。影子攻击在签名前埋下隐藏内容,签名后用一次小小的、看似无害的变更把它激活。针对认证文档的攻击钻的是 P=2 和 P=3 明文允许部分后续编辑的空子,然后把一次被禁的编辑打扮成允许的。按 /Type /Annot 这类标签分类对象、或按任何碰巧到达它们的引用路径分类的验证器,暴露在第三类之下:攻击者只需要一条能到达被禁结构的允许结构

所以问题不是哪些对象变了,而是它们归谁。经 /Contents 从页面到达的内容流,无论还有什么别的东西指着它,都是页面内容。经 /P 指回页面的注释说的是自己住哪儿,不是自己拥有什么

PDFium Component v3.126.2 怎么建模所有权?

PDFium Component v3.126.2 把反向引用当导航、挡在角色传播之外,并且按持有字典的结构角色、而不是单凭键名来决定哪些键算导航。表格汇总了不再携带所有权的导航键:

所有者字典按导航处理的键规范出处
页面或 Pages 节点/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
注释或 widget/PISO 32000-1 §12.5.2
widget 或字段字典/ParentISO 32000-1 §12.7.3
PDFium Component v3.126.2 对象图,把 /Contents 与 /Annots 这类扩散页面和注释角色的 owned-payload 边,与 widget /P 这类不带角色的导航边分开,按所有者字典列出导航键,即使 /Type 被伪造内容流也保持 prckPageContent
v3.126.2 把反向引用挡在角色传播之外:角色只经真正的所有权流动,内容流保持页面内容身份,/P 提示决定不了任何事

全局按键名过滤会挖出新洞。字体或 XObject 资源完全可以合法地叫 /P、/Parent 或 /Annots,而一个把 /P 条目从传播里剔除的 /Resources 字典,会让攻击者把页面私有的 XObject 藏在一个无辜的资源名后面。v3.126.2 里,导航过滤只在持有字典确实是页面、Pages 节点、注释、widget 或字段时生效。若这类字典带着重复的导航键——比如 widget 里两条 /P——分析器不猜查看器会用哪份;角色构建失败,签名变成 Indeterminate

另几条规则堵住了剩下的改签路径:

  • Pages 节点本身就是页面角色根,从 Pages 树继承的资源(ISO 32000-1 §7.7.3.4)经真正的所有权进入页面上下文,而不是从子页面沿 /Parent 走上来
  • 到达 catalog、Pages 节点、页面、注释或字段字典的注释或表单角色就地停住,因为这些结构对象自立角色,外来的 payload 角色不得覆盖
  • 分类时页面角色说了算:页面私有的对象就是 prckPageContent,哪怕后续修订用伪造的 /FT、一个 /Type /Annot 标签改写它,或让它与某条外观流共享
  • 只当字段或注释外观使用的 Form XObject 保留其表单或注释类别,于是表单填写后寻常的外观再生成仍按常规许可规则分级
  • 没有自己 /FT 的 widget 沿 /Parent 链解析继承的字段类型,链解不开就让角色构建失败,而不是默认注释
  • 后续每个修订的角色位都并进被覆盖修订的角色,后来的更新就无法先把一条流摘下来、再编辑它,借此抹掉更早的页面所有权关系

用不动点替代写死的趟数

v3.126.2 的角色可达性按工作队列运转,迭代到没有对象再获得新角色位为止,这是真正的不动点,不管链多深、对象号怎么排。存成独立对象的间接数组(如 /Contents 数组)同样会被走一遍。每个对象至多获得四种角色位,队列按对象号上限每号四条;超预算抛 prrResourceLimitExceeded。指向 free 对象的引用、代数不匹配或损坏的对象头抛 prrMalformedRevisionChain,压缩对象流里的 payload 抛 prrCompressedObjectUnresolved。这些失败一律以 prasIndeterminate 收场,绝无 allowed 判定,而失败若发生在构建被覆盖修订角色时,签名根本不报 Changes

下面这段例程列出能在这套分析下存活下来的页面内容编辑。prckPageContent 变更永远不会被判 prdAllowed:DocMDP P=1、2 或 3 都判 prdDisallowed,无 DocMDP 的签名判 prdSuspicious

uses
  SysUtils, TypInfo, PDFium, FPdfPades;

procedure ListPageContentEdits(const FileName: string);
const
  ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  Sig: TPadesSignatureRevisionAnalysis;
  Change: TPadesRevisionObjectChange;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;   // 是 record,没什么可释放的
    for i := 0 to High(Report.Signatures) do
    begin
      Sig := Report.Signatures[i];
      if not (prrPageContentChanged in Sig.Risks) then
        Continue;
      Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
        [Sig.SignatureIndex, Sig.DocMdpPermission]));
      for j := 0 to High(Sig.Changes) do
      begin
        Change := Sig.Changes[j];
        if Change.Kind <> prckPageContent then
          Continue;
        Writeln(Format('  revision %d  object %d %d R  %s%s',
          [Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
           GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
           ShadowTag[Change.IsAuthoritative]]));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

FieldMDP 与注释共享一个对象时会怎样?

字段的 /V 与注释的 /Contents 指向同一个间接对象时,v3.126.2 让 FieldMDP 锁继续生效,哪怕变更被归类为注释编辑。这个场景手工就能搭出来:签名者用 FieldMDP 锁住 Total 字段(ISO 32000-1 §12.8.2.4),攻击者让一个文本注释的 /Contents 引用持有字段值的同一个字符串对象。P=3 下注释编辑是允许的,所以修复之前,改写那个共享字符串就能以 allowed 判定改动一个被锁字段的值

该对象现在同时带注释与表单角色,只要签名带 FieldMDP 变换,注释判定就会复查表单一侧:

  • P=2 下注释变更直接不允许,与从前一样
  • FieldMDP All 时所有字段都被锁,共享变更判 prdDisallowed
  • FieldMDP Include 或 Exclude 时,分析器无法把共享标量追溯到一个字段名,判定是 prdIndeterminate 而不是瞎猜
  • 没有 FieldMDP 就适用 P=3 注释规则,变更保持允许
PDFium Component 的 FieldMDP 判定示意图:被锁 Total 字段的 /V 与注释 /Contents 引用同一间接对象,按 DocMDP P=2、FieldMDP All、FieldMDP Include 或 Exclude、无 FieldMDP 分支,对同一次共享编辑给出 prdDisallowed、prdIndeterminate 或 prdAllowed 判定
一个间接对象同时带注释与表单角色时,注释判定会复查 FieldMDP 锁,同一次编辑可以从允许到不允许再到不确定

有个报告细节对闸门代码很要紧。共享场景报的是 Kind = prckAnnotation 配 Decision = prdIndeterminate,而 prrFieldMdpUnresolved 只对归类为表单字段的变更加进风险集。一个只搜 prrFieldMdpUnresolved、不看 Status 的闸门会把这个场景整个漏掉

Delphi 代码该怎么在修订分析上默认拒绝?

Delphi 代码只有在分析状态为 prasNoLaterChanges 或 prasAllowed、且无结构性风险时才该接受签名文档,并把 prasIndeterminate 和 prasSuspicious 当不可信处理,而不是记条日志放行。Indeterminate 意味着分析器证明不了后续修订是被允许的;对攻击者来说,如果你的代码放行,一个稳定产出 Indeterminate 的输入与产出 Allowed 的同样好用。全局 AnalyzePadesSignatureRevisions 接受任意 TStream 并从位置 0 读起,正适合从不需要渲染文档的上传处理器

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // 重复定义会记录在案,但不拉低 Status
  BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
    prrResourceLimitExceeded];

function SignedRevisionsAcceptable(const FileName: string;
  out Reason: string): Boolean;
var
  Source: TFileStream;
  Report: TPadesRevisionAnalysisReport;
begin
  Result := False;
  Reason := '';
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Report := AnalyzePadesSignatureRevisions(Source);
  finally
    Source.Free;
  end;
  if Report.SignatureCount = 0 then
  begin
    Reason := 'no signature anchors the analysis';
    Exit;
  end;
  if Report.Risks * BlockingRisks <> [] then
  begin
    Reason := 'structural risk in the revision chain';
    Exit;
  end;
  case Report.Status of
    prasNoLaterChanges, prasAllowed:
      Result := True;
  else
    // prasIndeterminate 与 prasSuspicious 是拒绝,不是警告
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

两条边界值得明说。TPadesRevisionAnalysisReport 对 CMS 完整性和证书信任只字未提,所以这道闸门与密码学和信任校验并肩而立,不是替代它们。而且正确的所有权图也不意味着 P=3 对每种工作流都安全。P=3 是真允许注释的,一个带不透明外观的注释可以盖住已签名的文字而不碰任何内容流。如果你的认证文档是合同而不是审阅副本,要么用 P=2 认证,要么把允许的注释变更转给人工,如这个辅助函数:

function AllowedAnnotationEditsUnderP3(
  const Report: TPadesRevisionAnalysisReport): Integer;
var
  i, j: Integer;
begin
  Result := 0;
  for i := 0 to High(Report.Signatures) do
    if Report.Signatures[i].DocMdpPermission = 3 then
      for j := 0 to High(Report.Signatures[i].Changes) do
        if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
           (Report.Signatures[i].Changes[j].Decision = prdAllowed) then
          Inc(Result);
end;

签名修订审计清单

用这张清单检查你的验证管线是否曾经暴露、现在是否默认拒绝:

  • v3.126.2 之前的 PDFium Component 构建可能对 DocMDP P=3 文档里的页面内容编辑报 prasAllowed;对老构建放行过的认证 P=3 文件重跑 TPdf.AnalyzeSignatureRevisions
  • 带 FieldMDP 锁的 P=3 文档里,字段值与注释可能共享间接对象的,复查一遍
  • 只接受 prasNoLaterChanges 和 prasAllowed;把 prasIndeterminate 和 prasSuspicious 当不可信
  • Report.Risks 和 Report.Status 都要测,因为 prrDuplicateObjectDefinition 单靠自己不改状态
  • 签名状态是 Indeterminate 时,别把空的 Changes 数组当成干净结果;角色构建失败时什么都不报
  • 别只靠 prrFieldMdpUnresolved 抓 FieldMDP 问题,共享注释场景只经判定与状态浮出水面
  • 在你的工作流里决定 P=3 下允许的注释变更是否需要人工复核
  • 分析原始文件字节;被 SaveAs 重写的文档已不含修订链

修订分析只是签名检查的一层。字典与 baseline level 配PDF 数字签名与 PAdES 级别检查,JavaScript、launch 动作和嵌入文件配更全面的 PDF 安全风险审计。TPdf.AnalyzeSignatureRevisions、AnalyzePadesSignatureRevisions 和这里讲的所有权感知角色图,都在面向 Delphi、C++Builder 和 Lazarus 的 PDFium Component 里交付