一份 20 KB 的 PDF 把一個服務行程釘死到 OOM killer 出手,這不是你程式碼裡的錯誤,是一顆解壓縮炸彈。HotPDF,這套給 Delphi 與 C++Builder 用的原生 VCL PDF 元件,用 DecodeBudgetBytes 來約束它,這是一個每濾鏡鏈的上限,預設值為 268435456 位元組,並把每個解碼階段都記到同一份共用預算上
那份吃掉一個工作行程的 20 KB 檔案
這類事故的樣貌總是一樣。一個負責產生縮圖的佇列工作者接下一份上傳檔案,常駐記憶體在不到兩秒內衝過 12 GB,然後行程消失,沒有留下任何堆疊追蹤。檔案只有 20 KB。它有一頁、一個內容串流,以及一個帶有五個項目的 /Filter 陣列。陣列裡每個名稱都是規範定義的濾鏡,每個階段都解碼無誤,檔案裡沒有任何東西是格式錯誤的。這正是這類輸入棘手的地方:沒有任何一個損毀的位元組可供拒絕
這和正確解碼單一濾鏡不是同一個問題。把 LZWDecode 與 /DecodeParms 中的預測器搞對,是它自己的主題,涵蓋於已載入文件上 LZW、預測器與 DecodeParms 的說明。這裡每個解碼器都已經是正確的。問題出在把五個正確解碼器接連跑一遍、卻沒有人統計總量時,它們會做出什麼事。ISO 32000-1 §7.4 明確說明 /Filter 可以是單一名稱或名稱陣列,且陣列會依序套用,先套用第一個項目。規範對於一個階段可以把輸入放大多少完全沒說,對於整條鏈的加總也隻字未提。ASCIIHexDecode 階段大致會把輸入減半,聽起來人畜無害。FlateDecode 階段跑在一串零位元組上,能達到成千上萬倍的壓縮比。把它們串起來,算術就是連乘:20 KB 變成 20 MB,再變成 20 GB,而其中每一步都是對一個合法串流的合規解碼
為何逐濾鏡限制擋不住解壓縮炸彈?
因為逐濾鏡限制會在 /Filter 陣列的每個元素重新武裝一次。一條五階段的鏈子若每階段有 256 MiB 上限,就等於授權了 1.25 GiB,而且最後一個階段開始時,不論前四個階段產生了什麼,它依然拿到一份全新的額度。這個限制被誠實地執行著,卻約束不了任何要緊的東西。HotPDF 在 v2.447.0 之前正是這個形狀,而且旁邊還有第二個缺口。LZW 解壓縮器帶有一個 MaxOutputBytes 上限,影像預測器路徑會核算自己的列數,所以這兩者在局部是有界的。FlateDecode、ASCIIHexDecode、ASCII85Decode 與 RunLengthDecode 完全沒有上限:每一個都會不斷寫入一個 TMemoryStream,直到輸入耗盡,或分配器投降為止。因此一條惡意的鏈子有兩條路可走。它可以用一個完全無防護的濾鏡,或者用有防護的濾鏡,單純多加幾個
還有第三個細節,一個粗糙的修補會漏掉。你該在意的數字,不是最終解碼輸出的大小,而是峰值,而峰值通常活在某個中間緩衝區裡。一條鏈子最終產出一份不起眼的 4 MB 內容串流,卻可能在第三階段分配了 8 GB,然後回傳一個看起來完全合理的結果。事後檢查結果的長度,對於那次殺死行程的分配動作,什麼都說明不了
一條濾鏡鏈一個預算追蹤器
HotPDF v2.447.0 的修正,是讓核算跨越整條鏈子,而不是只顧單一階段。每條濾鏡鏈都建構一個 THPDFDecodeBudgetTracker,每個解碼器都透過一個包裝真正目標的 THPDFBudgetWriteStream來寫入。這個包裝會在轉發任何一個位元組之前呼叫 Budget.Consume(Count),所以拒絕動作發生時,目標串流還是它原本的大小。這個順序正是整件事的關鍵:在緩衝區已經長大之後才做的檢查,是一份診斷紀錄,不是一道防線
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
局部上限並沒有消失,它們變成了共用預算的投影。LZW 階段現在把 Decoder.MaxOutputBytes := Budget.RemainingBytes 設定成整條鏈子剩下多少,而不是一個獨立的額度。影像預測器階段以 BeginFilter 開場,在分配前透過 Consume 核算自己的列需求量,這代表預測器輸出會記到餵給它的通用濾鏡同一份預算上。這在影像路徑上尤其重要,因為濾鏡鏈與預測器是同一個操作的兩半,涵蓋於透過解碼濾鏈從已載入文件擷取影像一文
當預算拒絕時,呼叫端看到什麼?
在堆疊底層,一次拒絕會丟出 EHPDFDecodeBudgetError。在那之上,答案取決於被呼叫的 API 原本就有的合約。原本透過 False 或 nil 回報失敗的高階讀取方法,繼續照做,因為把一個文件化的布林結果變成例外,會破壞那些本來就正確處理格式錯誤輸入的呼叫端。已載入頁面內容路徑是刻意的例外:它會重新丟出 EHPDFDecodeBudgetError,而不是讓一個被截斷的內容串流渲染成一頁看似空白的內容。這樣的設計代表單獨一個 False 本身是有歧義的,所以預算機制會連同它一起發布一筆診斷紀錄:THotPDF.GetLastDecodeBudgetInfo 回傳該實例最近解碼過的那條鏈子的狀態
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
把這些欄位放在一起讀,就能分辨兩種攻擊形狀。當 PeakStageBytes 接近 DecodedBytes 時,代表某個單一階段就搞出了全部的傷害,你面對的是單一高倍率濾鏡。當 PeakStageBytes 只是 DecodedBytes 的一小部分、FilterCount 卻很高時,代表沒有哪一個階段特別誇張,而是整條鏈子一點一點累積超過上限,這正是逐濾鏡限制看不見的那種情況。有一個警訊值得寫進你的處理常式:在該實例至少解碼過一個濾鏡之前,GetLastDecodeBudgetInfo 會回傳 False,所以從它得到的 False 不代表這份文件是乾淨的
預算何時重置,以及零何時是誠實的答案
DecodeBudgetBytes 約束的是一條串流鏈,不是一份文件,這個邊界是刻意的,也容易被誤讀。每個內容串流、每個內嵌檔案、每個交叉參照串流、每個物件串流,都以全新的 256 MiB 起算。因此一份 4,000 頁的文件,就有 4,000 次獨立的機會可以花掉整份上限,而物件串流會進一步放大這個數字,因為每一個都是自成一格的壓縮容器,裡面裝著許多物件,如物件串流與增量更新筆記所述。如果你真正需要的是對整個行程記憶體的上限,這項特性只是輸入之一,不是全部,它該放在一個工作層級或容器層級的上限之後
零代表無限制,這是一個合法的設定,而不是一個逃生口。當你擁有輸入時就設定它:一條重新處理自家系統產生文件的封存管線,或是一個需要遠超任何你敢寫死的上限的、單一 600 dpi 彩色掃描鏈的光柵化步驟。負值會被直接以 ERangeError 拒絕,因為負的預算沒有一致的意義,悄悄夾住它只會掩蓋一個組態錯誤
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
挑選這個數字值得比通常付出更多心力,因為設得太低的預算是一次自找的服務中斷。用預設值跑一遍你既有的文件語料庫,記錄每條鏈子的 PeakStageBytes 與 DecodedBytes,把上限設在觀察到的最大值之上、留出真正的餘裕。一個因為聽起來安全而挑選的整數,會在最糟的時刻拒絕一份合法的大型掃描件,而這個失敗在你的日誌裡看起來會和攻擊一模一樣
不再發生的那次複製
讓每個階段都透過一個預算包裝走,結果反而讓這條鏈子變得更便宜,而不是更貴。當一個串流帶有濾鏡時,第一階段現在會直接讀取來源串流,而不是先把已編碼的位元組複製進一個暫存緩衝區,從那之後只有兩個緩衝區同時存活:目前的輸入,以及正在寫入的階段輸出。原始複製在兩種需要它的情況下依然存在,也就是一個完全沒有濾鏡的串流,以及呼叫端想保留最後一次編碼的影像,因為這兩種情況都會交回一個呼叫端自己擁有、能夠獨立定位的串流。沒有防護的舊版程式碼分配得更多,卻約束得更少,這正是兩者常見的關係。不過有一點值得明講:這一切都不會讓任意一份 PDF 變得可以安全載入。它關閉的是一個特定、且成本極低的阻斷服務向量,也就是一個小檔案透過巢狀濾鏡買到一次大量分配的那種情況。位元組核算上的整數溢位是另外分開防護的,而剖析帶惡意文件、不信任其內部偏移量的更廣泛課題,則是另一門學問。解碼預算是眾多界限之一,它的價值在於,它是你能在碰觸檔案之前,用單一屬性就設定好的那一個
這條逐鏈預算、它的診斷紀錄,以及它所保護的已載入文件解碼路徑,全都隨元件本身一併出貨,不需要設定或修補任何外部解壓縮相依套件。如果你正在評估如何在 Delphi 或 C++Builder 服務中約束不受信任的 PDF 輸入,HotPDF Delphi PDF 元件頁面列出了這些限制所適用的已載入文件工具組