PDF 不仅仅是纸张。它是一个容器,可以携带在文件打开时运行的脚本、启动外部程序的链接、连接到 Web 服务器的链接、嵌套在文件内部的文件,以及声明文档自有人担保以来没有发生更改的签名。当文件来自您不控制的源时,最安全的第一步不是渲染它。它是读取文件关于其自身的内容,并构建它可能尝试做的所有事情的清单,以便人类可以决定它是否完全属于您的工作流。
本文将介绍使用适用于 Delphi 和 Lazarus 的 PDFium 组件对该风险面进行静态、只读的审计过程。审计绝不会绘制页面。它解析文档结构,枚举携带行为的文件部分,并写入纯文本报告。这就像是要求陌生人在门口掏空口袋,与仅仅因为他们微笑就信任他们之间的区别。
什么是审计,什么不是审计
弄清边界。沙箱预览在严格的限制下渲染文件,以便用户可以查看它,而文件不会触及机器的其余部分。审计在此之前进行。它是一种免渲染的检查,其唯一的输出是对威胁面的描述:存在哪些脚本、哪些操作连接到链接、文件是否已签名以及签名有多紧密,以及附加了什么。当文档越过信任边界时,在来自电子邮件、上传表单或合作伙伴提供的入口处,在任何后续阶段真实打开它之前,运行它。
该组件以与任何其他操作相同的方式加载文档以进行审计。您设置文件名并激活它,这将解析交叉引用数据和文档目录,而不渲染任何单个页面。下面的所有内容都从该加载的、未渲染的状态读取。
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'Incoming_Invoice.pdf';
Pdf.Active := True; // parses structure, renders nothing
// audit the loaded document here
finally
Pdf.Free;
end;
end;
名称树中的文档 JavaScript
首先要枚举的是代码。PDF 可以携带文档级 JavaScript:不附加到任何页面或字段而附加到文档本身的脚本,存储在 /Names 树中的 /JavaScript 条目下。符合规范的查看器在打开时运行这些。这是一长串 PDF 恶意软件背后的机制,因为它允许文件在用户双击它的瞬间执行逻辑,在此之前他们甚至还没读过一个字。
审计员想知道关于每个此类脚本的两个事实:它存在,以及它包含什么。组件公开了数量,并允许您将每个操作读取为保存脚本名称及其完整代码体的记录。读取代码体很重要。名为 Doc.0 的脚本什么也告诉不了您,但其文本可能调用 app.launchURL 或组装字符串并将其传递到不该去的地方。拉出源代码以便审查员可以阅读它是标记在打开时运行代码的文件的全部重点。
var
I: Integer;
Action: TPdfJavaScriptAction;
begin
if Pdf.JavaScriptActionCount > 0 then
WriteLn('WARNING: document runs ', Pdf.JavaScriptActionCount,
' script(s) on open');
for I := 0 to Pdf.JavaScriptActionCount - 1 do
begin
Action := Pdf.JavaScriptAction[I];
WriteLn(' script "', Action.Name, '":');
WriteLn(Action.Script); // full body, for a human to read
end;
end;
具有零个文档脚本的文件并不是自动安全的,因为页面和字段脚本也存在,但具有文档脚本的文件总是值得再次审视。仅存在数量就是一个有用的关卡,而代码体则是将关卡转化为判断的要素。
启动与 URI 操作
要盘点下一个行为存在于链接和批注上。对审计员来说最重要的两种操作类型。当触发链接时,启动操作启动外部程序或打开本地文件。URI 操作打开 Web 目标。查看可疑文档的审查员应该能够看到(无需点击任何内容),第三页上的按钮被连接来启动 cmd.exe 或打开与页面上的品牌不匹配的 URL。
该组件对它找到的链接进行分类,并公开每个链接的操作类型和目标路径,因此审计可以列出每个启动和 URI 操作及其目的地。这是报告,而不是执行。审计员从结构中读取操作并将其写下来。它绝不遵循它。
渲染文档的查看器控件是发生遵循操作的地方,它的默认姿态被故意设为谨慎。TPdfView 控件具有 LinkOptions 集合,决定点击时哪些链接类型自动触发。其默认值是 [loAutoGoto, loAutoOpenURI],这意味着文档内跳转和网页 URL 可以打开,但 loAutoLaunch 不存在,因此启动操作永远不会自动运行。对于审计工作流,您可以更进一步,完全清除该集合,以便在您仍在决定是否信任文件时,完全没有任何内容会自动触发。
// Audit posture for the viewer: nothing auto-runs, nothing auto-opens.
View.LinkOptions := [];
// The shipped default already withholds launch:
// default = [loAutoGoto, loAutoOpenURI]
// loAutoLaunch is NOT in the default set, so external programs
// are never started on a stray click out of the box.
默认拒绝启动背后的推理很简单。文档内的跳转是无害的,URL 是可见且可取消的,但是点击启动任意外部程序是 PDF 链接可能请求的最危险的事情,因此它是关闭的,除非您选择加入。审计员甚至选择退出安全行为,因为工作是查看,而不是行动。
数字签名 MDP 权限级别
签名改变了问题。普通的签名证明了签署时的字节。认证签名(使用文档修改检测与预防规则创建的那种)走得更远:它声明在文档认证后什么可以合法更改,并且合规的查看器会在触及该许可之外的任何内容时发出警告。读取该权限级别可以告诉审计员文件是否已认证,如果已认证,它被设计为锁定的严格程度。
MDP 权限是具有三个定义值的整数。级别 1 意味着根本不允许任何更改;任何修改都会破坏认证。级别 2 允许表单填写和签名,这是旨在完成并签署但不能以其他方式更改的合同的常见情况。级别 3 此外还允许在表单填写和签名之上进行批注。了解该级别可以让您的接收逻辑推断意图:在级别 1 认证但仍携带表单字段或脚本的文档是自相矛盾的,该矛盾值得标记。
该组件读取签名数并将每个公开为记录,其 Permission 字段携带该 MDP值,该值直接从底层的 FPDFSignatureObj_GetDocMDPPermission 调用填充。零权限意味着签名不是认证(DocMDP)签名,因此没有文档级锁定可报告。
var
I: Integer;
Sig: TPdfSignature;
begin
if Pdf.SignatureCount = 0 then
WriteLn('document is not signed')
else
for I := 0 to Pdf.SignatureCount - 1 do
begin
Sig := Pdf.Signature[I];
case Sig.Permission of
1: WriteLn('certified: no changes allowed');
2: WriteLn('certified: form fill and signing allowed');
3: WriteLn('certified: form fill, signing and annotations allowed');
else
WriteLn('signed, but not a DocMDP certification');
end;
end;
end;
审计在这里不验证签名的密码学;验证证书链是独立的问题。它报告的是声明的意图:此文件表示它在此级别被锁定。这正是审查员判断后续更改或活动内容的仅存在是否与作者密封文档的方式一致所需的上下文。
表面的其余部分:嵌入文件与 XFA
还有两项静态分析痕迹完善了完整的清单。嵌入式文件是作为附件携带在 PDF 内部的完整文档,它们是经典的递送载体,因为看起来温和的报告可以在其附件树中运送可执行文件或第二个恶意 PDF。该组件公开了附件数和每个附件的名称,因此审计可以列出带有什么,而不提取或打开其中的任何内容。
XFA 存在是另一个标志。XFA 表单用基于 XML 的表单架构取代了静态的 AcroForm,该架构带来了其自身的渲染和脚本模型,这是一个比普通表单更大、更复杂的风险面。您不需要处理 XFA 就可以注意到它的存在;它的仅存在就是一个信号,表明文件携带了值得仔细观察的更丰富的交互层。组件将其报告为单个布尔值。
var
I: Integer;
begin
if Pdf.XFA then
WriteLn('NOTE: document contains an XFA form layer');
if Pdf.AttachmentCount > 0 then
begin
WriteLn('embedded files: ', Pdf.AttachmentCount);
for I := 0 to Pdf.AttachmentCount - 1 do
WriteLn(' - ', Pdf.AttachmentName[I]);
end;
end;
一个写入报告的只读例程
将这些组合在一起,审计是一个单一的过程,它加载文档、枚举其脚本和代码体、列出其启动和 URI 目标、报告签名 MDP 级别、记录附件和 XFA,并将发现写入日志。它不渲染任何内容,因此开销低,并且它不会被欺骗来显示敌意的页面内容。输出是扁平的、人类可读的记录,审查员或下游规则可以根据其采取行动。
在实践中行之有效的方式是将每个发现收集为一行,在前缀中标记真正危险的发现,以便它们排序到审查队列的顶部,并将整个内容持久化在文件旁边。没有脚本、没有启动操作、没有附件、没有 XFA 且没有签名或有连贯认证的文档会默默通过。一次触发多个标志的文档是一个人在任何后续阶段打开它之前应该看到的。审计不为您做出信任决定。它确保决定是知情的而不是盲目的。
一旦文件通过审计且您确实需要查看它,请在限制下进行,而不是在默认的查看器中。在我们在 Delphi 中构建安全 PDF 预览的演练中的方法展示了在受控查看期间如何防止链接自动处理和活动内容发挥作用。要将此枚举折叠到具有审查员工具的完整接收流水线中,请参见 PDF 接收和审查工作台文章。两者都基于相同的只读、免渲染基础构建,并作为适用于 Delphi 和 C++Builder 的 PDFium 组件的一部分提供,与本博客其他地方介绍的渲染、文本、表单和签名 API 一起交付。