跳转到内容
getnextpdf.com

清理不受信任的 PDF:内容解除与重建

Spec: ISO 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.4)。一个兑现该格式的查看器会乐于运行它们。那正是攻击者的立足点:一份同时完美有效、又完美恶意的文件。

按文件扩展名过滤在此处毫无作用。威胁就在一份格式良好的 PDF 内部,因此唯一真正的防御是打开它、理解它,并在它抵达任何渲染器之前剥除那套活跃的机器。这正是 OWASP 的文件上传与输入验证指引所描述的输入验证姿态:永不信任字节,且偏好重建一个已知良好的产物,而非扫描一份充满敌意的产物以寻找已知不良的特征。

  • CDR 假定输入充满敌意,并产生一份文件,而非修补旧的那一份。
  • CdrEngine::sanitize() 运行六个阶段:解析、准入控制、威胁侦测、过滤、引用清理、重建。
  • 它返回一个 CdrResult,告诉你什么被移除了、文档是否甚至被准入,以及——若没被准入——它为何被拒绝。
  • 输出是一个安全投影。它明确地不是一份证据副本、一次哈希匹配,或一份封存产物。那是一份契约,而非一句免责声明。
  • 这是 Enterprise 专属。NextPDF core 不执行 CDR。

引擎自己的文档以三个词陈述了那条设计红线:Security Projection Layersanitize() 是一次破坏性的、不可逆的转换。它被允许把字节丢掉。它不被允许做的,是假装结果是同一份文档。

这条流水线是被刻意排序的。每个阶段都在下一个阶段对它动作之前收窄信任。

  1. ParsePdfReader builds the object graph from the raw bytes. A parse failure is a rejection, not a best-effort guess.
  2. Admission controlResource limits are checked first — object count, page count, decoded-stream size, per-stream inflation ratio. Over-budget input is rejected, never sanitised.
  3. DetectThreatDetector walks the graph and records each dangerous feature as a DetectedThreat with its object number and type.
  4. FilterObjects are partitioned into safe and removed. Catalog-level keys are stripped in place so the document catalog itself survives.
  5. Reference scrubPointers to removed objects are cleaned so the rebuilt file has no dangling references.
  6. RebuildCdrRebuilder serialises only the safe objects into a new PDF. The output is fresh structure, not an edited original.
The six phases of CdrEngine::sanitize(), in order. The document is parsed, then admitted or rejected against resource limits, then scanned for threats; dangerous objects are filtered out, dangling references are scrubbed, and only the surviving safe objects are serialised into a brand-new PDF. The original bytes never carry through.

准入先于解除。 在移除任何一个威胁之前,引擎会问这份文档是否甚至值得处理。CdrPolicy 携带那些限制——maxObjectsmaxPageCountmaxDecodedStreamBytes,以及一个防御解压炸弹的 maxInflationRatio。一份冲破它们的文档会被拒绝,而 CdrResult 会以 admitted: false 与一个 rejectionReason 那样陈述。这个区分是承重的:“我们清理了它”与“我们拒绝了它”是不同的结果,而结果类型让它们彼此分开,因此你的错误报告也能如此。

威胁被命名,而非被猜测。 ThreatDetector::detect() 把每一个危险的构造映射到一个 ThreatType——JavaScriptLaunchActionOpenActionAdditionalActionsRemoteGoToSubmitFormImportDataEmbeddedFilesRichMediaNamedJavaScript,以及更多——各自系于一个特定的 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 发票载荷。它们默认关闭,正是因为那个原因。请在知情的情况下打开它们。
  • 签名无法在一次重建后存活。 重建那份文件会改变它的字节,因此任何原始数字签名都不再与它的字节范围相符。一份被解除的文档相对于源件是未签名的。如果你需要一份已签名的产物,请把那份干净的输出作为一次新的行为去签名。
Content Disarm & Reconstruction (CDR) — edition availability
EditionAvailability
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 CdrEngine. The disarmed output is a security projection of the source — it must not be treated as an evidentiary, hash-comparable, or archival copy of the original.

  • CDR(内容解除与重建,Content Disarm & Reconstruction) — 一种清理策略,它解析一份不受信任的文件、移除活跃或危险的组件,并从安全的剩余部分重建一份干净的文件,而非扫描以寻找已知不良的特征。
  • 安全投影 — 一份被清理的输出,它保留了足够的源件以堪用,同时保证移除了那些威胁。它在设计上不是字节忠实的,且不适用于证据、哈希,或封存。
  • 准入控制 — 那道在任何解除工作开始之前、拒绝超出资源限制(对象数、页数、解码流大小、膨胀比)之文档的预清理关卡。
  • 动作(action) — 一个让某事在某个触发条件下发生的 PDF 构造,例如文档打开或一个字段变更;那套动作类型目录(Spec: ISO 32000-2, §12.6.4)包含 JavaScript 与 Launch action,典型的 CDR 威胁面。
  • 悬空引用 — 一个指向某个在过滤后不再存在之对象的指针。引用清理阶段会移除这些,以让重建出的文件保持结构一致。