清理不受信任的 PDF:內容除害與重建
Spec: ISO 32000-2, §12.6.4ISO 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.4ISO 32000-2 §12.6.4)。一個兌現該格式的檢視器會樂於執行它們。那正是攻擊者的立足點:一份同時完全有效、又完全惡意的檔案。
依副檔名篩選在這裡毫無作用。威脅就在一份格式良好的 PDF 之內,因此唯一真正的防禦,是把它開啟、理解它,並在它觸及任何繪製器之前剝除其中那些活躍的機械裝置。這正是 OWASP 的檔案上傳與輸入驗證指引所描述的那種輸入驗證姿態:永遠不信任那些位元組,並寧可重建一份已知良好的產物,也不掃描一份充滿敵意的檔案去比對已知不良的特徵。
簡短版本
標題為「簡短版本」的區段- CDR 假設輸入充滿敵意,並產生一份全新的檔案,而非修補舊的那一份。
CdrEngine::sanitize()執行六個階段:剖析、入場控制、威脅偵測、篩除、參照清洗、重建。- 它回傳一個
CdrResult,告訴你什麼被移除了、文件是否甚至獲准入場,以及——若否——它為何被拒絕。 - 輸出是一個安全性投影。它明確地不是一份證據用副本、一次雜湊相符,也不是一份封存產物。那是一份契約,而非一句但書。
- 這是企業版才有的。NextPDF 核心不執行 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 動作、那個機器不該執行的內嵌酬載。它們有相反的成功判準。一次塗黑,在敏感內容消失、其餘部分逐字保留時才算正確。一次除害,在威脅消失時才算正確,而它非常樂於為了達成這點而改動良性的結構。請使用與你意圖相符的工具;別拿起其中一個,卻指望它具備另一個的保證。
第二個誤解是,一份乾淨的重建證明了原始檔案是乾淨的。它對原始檔案什麼也沒證明。它只證明輸出不含任何已偵測到的威脅。輸入或許曾是一件武器;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) — 一種清理策略:剖析一份不受信任的檔案、移除活躍或危險的元件,並從安全的餘部重建一份乾淨的檔案,而非掃描以比對已知不良的特徵。
- 安全性投影 — 一份被清理過的輸出,它保留了足夠的來源以維持有用,同時保證移除威脅。它刻意地不忠於位元組,且不適合用於證據、雜湊或封存。
- 入場控制 — 那道清理前的關卡,在任何除害工作開始之前,拒絕超出資源上限(物件數量、頁面數量、解碼後串流大小、膨脹比)的文件。
- 動作 — 一個 PDF 構件,在諸如文件開啟或欄位變更等觸發條件下使某件事發生;動作型別目錄(Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4)包含 JavaScript 與 Launch 動作,即標準的 CDR 威脅面。
- 懸空參照 — 一個指向某物件、而該物件在篩除之後已不復存在的指標。參照清洗階段移除這些,好讓重建後的檔案維持結構一致。