跳到內容
getnextpdf.com

清理不受信任的 PDF:內容除害與重建

Spec: ISO 32000-2, §12.6.4

一份從外界抵達的 PDF,既是文件、也同樣是程式碼。它能攜帶 JavaScript、一個 Launch 動作、一個內嵌的可執行檔,以及一套精心打造來絆倒剖析器的結構。內容除害與重建(Content Disarm & Reconstruction,CDR)把那份檔案視為不受信任、只保留安全的部分,再從倖存者重建一份乾淨的 PDF。

本頁說明 NextPDF 企業版的 CdrEngine 如何做到這點——以及每一家 CDR 廠商都應當公開說出的那一件誠實的事:重建後的檔案是原始檔案的一個安全性投影,而非它的一份忠實副本。

PDF 中危險的部分並不奇特。它們是標準功能。規格定義了一整套動作的目錄——文件開啟時、頁面顯示時、欄位變更時各自會發生什麼——而那套目錄包含 JavaScript 與 Launch 動作(Spec: ISO 32000-2, §12.6.4)。一個兌現該格式的檢視器會樂於執行它們。那正是攻擊者的立足點:一份同時完全有效、又完全惡意的檔案。

依副檔名篩選在這裡毫無作用。威脅就在一份格式良好的 PDF 之內,因此唯一真正的防禦,是把它開啟、理解它,並在它觸及任何繪製器之前剝除其中那些活躍的機械裝置。這正是 OWASP 的檔案上傳與輸入驗證指引所描述的那種輸入驗證姿態:永遠不信任那些位元組,並寧可重建一份已知良好的產物,也不掃描一份充滿敵意的檔案去比對已知不良的特徵。

  • CDR 假設輸入充滿敵意,並產生一份全新的檔案,而非修補舊的那一份。
  • CdrEngine::sanitize() 執行六個階段:剖析、入場控制、威脅偵測、篩除、參照清洗、重建。
  • 它回傳一個 CdrResult,告訴你什麼被移除了、文件是否甚至獲准入場,以及——若否——它為何被拒絕。
  • 輸出是一個安全性投影。它明確地不是一份證據用副本、一次雜湊相符,也不是一份封存產物。那是一份契約,而非一句但書。
  • 這是企業版才有的。NextPDF 核心不執行 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 動作、那個機器不該執行的內嵌酬載。它們有相反的成功判準。一次塗黑,在敏感內容消失、其餘部分逐字保留時才算正確。一次除害,在威脅消失時才算正確,而它非常樂於為了達成這點而改動良性的結構。請使用與你意圖相符的工具;別拿起其中一個,卻指望它具備另一個的保證。

第二個誤解是,一份乾淨的重建證明了原始檔案是乾淨的。它對原始檔案什麼也沒證明。它只證明輸出不含任何已偵測到的威脅。輸入或許曾是一件武器;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) — 一種清理策略:剖析一份不受信任的檔案、移除活躍或危險的元件,並從安全的餘部重建一份乾淨的檔案,而非掃描以比對已知不良的特徵。
  • 安全性投影 — 一份被清理過的輸出,它保留了足夠的來源以維持有用,同時保證移除威脅。它刻意地不忠於位元組,且不適合用於證據、雜湊或封存。
  • 入場控制 — 那道清理前的關卡,在任何除害工作開始之前,拒絕超出資源上限(物件數量、頁面數量、解碼後串流大小、膨脹比)的文件。
  • 動作 — 一個 PDF 構件,在諸如文件開啟或欄位變更等觸發條件下使某件事發生;動作型別目錄(Spec: ISO 32000-2, §12.6.4)包含 JavaScript 與 Launch 動作,即標準的 CDR 威脅面。
  • 懸空參照 — 一個指向某物件、而該物件在篩除之後已不復存在的指標。參照清洗階段移除這些,好讓重建後的檔案維持結構一致。