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
分析器在给后续变更分级之前,先给每个对象分配一组角色位:页面、注释、表单和验证材料。根对象的角色来自自己的字典,角色随后扩散到它们引用的一切。旧的传播链条是这样走的:
- 签名 widget 是带
/FT /Sig的/Subtype /Widget,于是拿到注释角色 - widget 的
/P把注释角色推上页面字典,而后者本就有页面角色 - 页面把两种角色推进
/Contents、/Resources,并经/Parent一路上溯 Pages 树、横跨到每个兄弟页面 - 像
<< /Length 812 >>这样的内容流字典没有/Type,分类器只好退回角色位,并且先查注释角色、后查页面角色
被改的内容流于是以 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, /Annots | ISO 32000-1 §7.7.3 |
| 注释或 widget | /P | ISO 32000-1 §12.5.2 |
| widget 或字段字典 | /Parent | ISO 32000-1 §12.7.3 |
全局按键名过滤会挖出新洞。字体或 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 注释规则,变更保持允许
有个报告细节对闸门代码很要紧。共享场景报的是 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 里交付