清理不受信任的 PDF:内容解除与重建
Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4
一份从外部世界抵达的 PDF,既是一份文档,也同样是代码。它能携带 JavaScript、一个 Launch action、一个内嵌的可执行文件,以及一个精心构造来绊倒解析器的结构。内容解除与重建(Content Disarm & Reconstruction,CDR)把那份文件当作不受信任的来对待,只保留安全的部分,并从幸存者中重建一份干净的 PDF。
本页解释 NextPDF Enterprise 的 CdrEngine 如何做到那一点——以及每一个 CDR 供应商都应当大声说出口的那一件诚实的事:重建出的文件是原件的一个安全投影,而非它的一份忠实副本。
为何这很重要
标题为“为何这很重要”的章节PDF 中那些危险的部分并不奇异。它们是标准功能。规格定义了一整套*动作(action)*目录——文档打开时、一页被显示时、一个字段变更时会发生什么——而那套目录包含 JavaScript 与 Launch action(Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4)。一个兑现该格式的查看器会乐于运行它们。那正是攻击者的立足点:一份同时完美有效、又完美恶意的文件。
按文件扩展名过滤在此处毫无作用。威胁就在一份格式良好的 PDF 内部,因此唯一真正的防御是打开它、理解它,并在它抵达任何渲染器之前剥除那套活跃的机器。这正是 OWASP 的文件上传与输入验证指引所描述的输入验证姿态:永不信任字节,且偏好重建一个已知良好的产物,而非扫描一份充满敌意的产物以寻找已知不良的特征。
简短版本
标题为“简短版本”的章节- CDR 假定输入充满敌意,并产生一份新文件,而非修补旧的那一份。
CdrEngine::sanitize()运行六个阶段:解析、准入控制、威胁侦测、过滤、引用清理、重建。- 它返回一个
CdrResult,告诉你什么被移除了、文档是否甚至被准入,以及——若没被准入——它为何被拒绝。 - 输出是一个安全投影。它明确地不是一份证据副本、一次哈希匹配,或一份封存产物。那是一份契约,而非一句免责声明。
- 这是 Enterprise 专属。NextPDF core 不执行 CDR。
NextPDF 如何处理
标题为“NextPDF 如何处理”的章节引擎自己的文档以三个词陈述了那条设计红线:Security Projection Layer。sanitize() 是一次破坏性的、不可逆的转换。它被允许把字节丢掉。它不被允许做的,是假装结果是同一份文档。
这条流水线是被刻意排序的。每个阶段都在下一个阶段对它动作之前收窄信任。
- ParsePdfReader builds the object graph from the raw bytes. A parse failure is a rejection, not a best-effort guess.
- Admission controlResource limits are checked first — object count, page count, decoded-stream size, per-stream inflation ratio. Over-budget input is rejected, never sanitised.
- DetectThreatDetector walks the graph and records each dangerous feature as a DetectedThreat with its object number and type.
- FilterObjects are partitioned into safe and removed. Catalog-level keys are stripped in place so the document catalog itself survives.
- Reference scrubPointers to removed objects are cleaned so the rebuilt file has no dangling references.
- RebuildCdrRebuilder serialises only the safe objects into a new PDF. The output is fresh structure, not an edited original.
准入先于解除。 在移除任何一个威胁之前,引擎会问这份文档是否甚至值得处理。CdrPolicy 携带那些限制——maxObjects、maxPageCount、maxDecodedStreamBytes,以及一个防御解压炸弹的 maxInflationRatio。一份冲破它们的文档会被拒绝,而 CdrResult 会以 admitted: false 与一个 rejectionReason 那样陈述。这个区分是承重的:“我们清理了它”与“我们拒绝了它”是不同的结果,而结果类型让它们彼此分开,因此你的错误报告也能如此。
威胁被命名,而非被猜测。 ThreatDetector::detect() 把每一个危险的构造映射到一个 ThreatType——JavaScript、LaunchAction、OpenAction、AdditionalActions、RemoteGoTo、SubmitForm、ImportData、EmbeddedFiles、RichMedia、NamedJavaScript,以及更多——各自系于一个特定的 PDF 功能。一个根本无法被解析的对象会成为它自己的威胁类型 UnparseableObject,因为一个清理器读不了的对象就是一个它无法为其作担保的对象。每一个发现项都是一个携带冒犯对象编号的 DetectedThreat,因此移除是精确的。
移除保护的是文档,而不只是载荷。 某些危险的键住在文档目录(Root)上——例如一个在打开时触发的 /OpenAction。为了杀掉一个键而移除整个 Root 对象会摧毁目录并悄悄弄坏文件。引擎转而把这些当作目录上的就地键剥除来处理,并作为一道失败封闭守护,它拒绝发出一份完全失去其 /Root 的重建文档。一个产生出结构损坏文件却上报成功的清理器,正是这道守护之所以存在所要防止的那种失败模式。
实际示例
标题为“实际示例”的章节下面的写法是真正的入口点。你把原始字节与一份策略交给引擎;你拿回一个对所发生之事诚实的 CdrResult。
<?php
declare(strict_types=1);
use NextPDF\Enterprise\Security\Cdr\CdrEngine;use NextPDF\Enterprise\Security\Cdr\CdrPolicy;
$engine = new CdrEngine();
// Standard policy removes the known active threats — JavaScript, Launch// actions, remote go-to, form submit/import, and the rest — while leaving// the lossy opt-in "Strip*" cases off by default.$result = $engine->sanitize($untrustedPdfBytes, CdrPolicy::standard());
if (!$result->admitted) { // Rejected by admission control (e.g. object/page limit, zip bomb). // This is NOT a sanitised document. Do not serve it; report the reason. throw new \RuntimeException($result->rejectionReason);}
if ($result->hadThreats()) { // The disarmed bytes are safe to render. Each removed threat carries its // type and object number for your audit log — never silently. foreach ($result->removedThreats as $threat) { error_log(\sprintf( 'CDR removed %s in object %d', $threat->type->value, $threat->objectNumber, )); }}
$cleanBytes = $result->sanitizedPdf; // The security projection. Not the original.没有任何一条路径会让这段代码悄悄地交回一份仍然危险的文件。要么文档被准入并解除,要么它以一个陈述出来的理由被拒绝。removedThreats 清单意味着这次解除是可审计的,而非魔法般的。
常见的误解
标题为“常见的误解”的章节“CDR 不过是多了几步的涂黑。”
它不是,而把这两者混为一谈是危险的。涂黑移除的是信息——名字、账号、一个人不可看见的内容。CDR 移除的是能力——一个机器不可运行的 JavaScript、Launch action、内嵌载荷。它们有相反的成功标准。一次涂黑当敏感内容消失、其余被逐字保留时才正确。一次解除当威胁消失时才正确,而它完全乐意为达此目的而更改良性的结构。请用与你意图相符的那个工具;别去拿其中一个、却指望另一个的那些保证。
第二个误解是,一次干净的重建证明了原件是干净的。它对原件什么也证明不了。它只证明输出不含已侦测到的威胁。输入可能曾是一件武器;CDR 的工作是确保你向下游转发的东西不是。
限制与边界
标题为“限制与边界”的章节这是宣传册会跳过的部分,所以我们会把它直白地说出来。
- 输出是一个安全投影,而非一份证据副本。
CdrEngine源码把这一点作为一条架构红线携带。被清理的 PDF 绝不可被用于法律证据保全、用于与原件作哈希比较,或作为一份封存副本。这次转换在设计上是破坏性且不可逆的。 - 侦测有其范畴。 CDR 移除它知道如何命名的那些威胁。它是纵深防御栈中一个强健、可审计的层——而非一份文件免于每一种可能的未来技术的保证。请把它置于 OWASP 文件上传指引所描述的同一套上传验证、内容类型检查,以及最小权限处理之后。
- 某些策略是有意有损的。 那些选择性启用的
Strip*案例会移除内嵌文件、签名、表单字段、图层,以及 3D 媒体。它们很强大,而且它们会删除合法内容——比方说,一份 ZUGFeRD/Factur-X 发票载荷。它们默认关闭,正是因为那个原因。请在知情的情况下打开它们。 - 签名无法在一次重建后存活。 重建那份文件会改变它的字节,因此任何原始数字签名都不再与它的字节范围相符。一份被解除的文档相对于源件是未签名的。如果你需要一份已签名的产物,请把那份干净的输出作为一次新的行为去签名。
| Edition | Availability |
|---|---|
| Core | Not available. NextPDF core does not perform CDR. It parses, renders, and writes PDFs; it does not threat-detect or rebuild untrusted input. |
| Pro | Not available in the Pro edition. |
| Enterprise | Available via |
相关文档
标题为“相关文档”的章节- PDF 加密实际如何运作 — 处理敏感 PDF 的另一半:保护内容对比移除能力。
- 错误作为一项功能 — CDR 的准入控制拒绝与
/Root守护所体现的那套失败封闭哲学。 - 一个拒绝猜测的 API — 为何一个区分已清理与已拒绝的结果类型,胜过一次无声的尽力而为。
词汇表
标题为“词汇表”的章节- CDR(内容解除与重建,Content Disarm & Reconstruction) — 一种清理策略,它解析一份不受信任的文件、移除活跃或危险的组件,并从安全的剩余部分重建一份干净的文件,而非扫描以寻找已知不良的特征。
- 安全投影 — 一份被清理的输出,它保留了足够的源件以堪用,同时保证移除了那些威胁。它在设计上不是字节忠实的,且不适用于证据、哈希,或封存。
- 准入控制 — 那道在任何解除工作开始之前、拒绝超出资源限制(对象数、页数、解码流大小、膨胀比)之文档的预清理关卡。
- 动作(action) — 一个让某事在某个触发条件下发生的 PDF 构造,例如文档打开或一个字段变更;那套动作类型目录(Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4)包含 JavaScript 与 Launch action,典型的 CDR 威胁面。
- 悬空引用 — 一个指向某个在过滤后不再存在之对象的指针。引用清理阶段会移除这些,以让重建出的文件保持结构一致。