文件接收管線會接受由陌生人建立的檔案。發票、掃描檔、網頁表單的附件:每一個都聲稱自己是 PDF,並帶有數百個預期由您的解析器採用的數字。串流長度、影像尺寸、位元組位移、物件參照——每一個數字都由產生檔案的人決定,而截斷的上傳檔或蓄意格式不正確的文件,最終都會將其中一個數字放到會造成損害的位置。能在該檔案下存活的解析器,與會當機或在記憶體已損壞的情況下繼續執行的解析器之間,差異在於一小組不依賴任何特定 PDF 函式庫的習慣
這些習慣共有一項前提:從檔案讀取的值是一項主張,不是量測值。只有在根據解析器自行量測的事物加以檢查後,它才能使用——檔案的實際大小、解碼器產生的實際位元組數、遞迴的實際深度。以下會將這項前提套用到文件解析器真正容易失效的位置
宣告的長度是主張,不是量測值
最簡單的不一致情況是串流長度。PDF 串流物件會在 /Length 索引鍵中宣告其位元組數,而實際資料位於 stream 與 endstream 關鍵字之間。沒有任何機制強迫兩者一致。截斷檔案所含的實際位元組少於宣告數量;由損壞產生器建立的檔案可能宣告一個延伸到檔案結尾之外或相鄰物件內部的長度。依宣告值配置並複製到 endstream,就會溢出緩衝區;不檢查可用資料量便精確讀取宣告數量,則會走到檔案結尾之外。只有在將宣告值限制於量測到的資料結尾距離後,才能讓它決定配置大小;並將不一致視為決策點——掃描 endstream 來修復,或拒絕該串流——絕不能默默相信它
描述比您配置之點陣圖更大的影像參數
影像串流會提高風險,因為有兩組彼此獨立的數字描述同一批像素。影像字典帶有 /Width 與 /Height,而點陣緩衝區通常依此決定大小。解碼篩選器則帶有自己的幾何資訊:CCITTFaxDecode 從其 DecodeParms 取得 /Columns、/Rows 與 /K,其中 /K 選擇 Group 3 或 Group 4 機制,且解碼器每條掃描線輸出 (Columns + 7) div 8 個位元組。宣告 /Width 100 卻將預設值 /Columns 1728 交給篩選器的檔案,會使解碼器每列產生的位元組數超過緩衝區預期值十六倍,溢位會一次一條掃描線地寫入配置區塊之後的任何內容。沒有 /Rows 時,解碼器會執行到資料指示停止為止,因此列數也必須加以限制。DCTDecode 有同樣的介面:JPEG 資料在其 SOF 標記中帶有自身的寬度與高度,而沒有任何規定強迫它們必須與字典相符
防禦規則是機械性的:從已驗證的解碼參數計算預期點陣大小——CCITT 使用篩選器本身的 /Columns 與 /Rows,DCT 使用 SOF 尺寸——依限制檢查、據此配置,並在解碼期間驗證輸出絕不超出配置範圍。當字典與篩選器對幾何資訊不一致時,請調和它們或拒絕影像。解析器絕不可根據一組數字決定緩衝區大小,卻讓解碼器以另一組數字執行
Delphi 的算術與配置陷阱
即使解析器有意驗證,Delphi 的三種行為仍會破壞這項意圖。第一項是 32 位元乘法:無論目的地寬度為何,Delphi 都會以 32 位元計算兩個 Integer 運算元的乘積,因此即使每個因數都各自通過合理性檢查,Width * Height * BytesPerPixel 仍可能溢位。一張 30000 乘以 30000、每像素三個位元組的掃描影像為 27 億位元組,在帶正負號的 32 位元算術中會溢位為負數;稍有不同的因數則會溢位成小的正長度,使程式配置過小的緩衝區。請藉由轉型第一個運算元讓整個運算式使用寬型別——Size := Int64(Width) * Height * BytesPerPixel——接著在任何內容傳入 SetLength 前與明確上限比較
第二項是範圍檢查。Delphi 預設的發行組態會將它關閉,因此由檔案資料計算出的超出範圍索引不會引發例外——它會讀取或寫入陣列相鄰的記憶體。請在每個使用檔案衍生值作為索引的單元頂端以 {$R+} 重新開啟它(並使用 {$Q+} 偵測算術溢位)。相較於解析器本來就會執行的 I/O,成本微乎其微,而且它會將無聲的損壞轉化為可攔截的 ERangeError
第三項是將檔案提供的 Int64 傳給 TMemoryStream.SetSize。在目前的 RTL 中,它會配置檔案要求的任何大小,因此一個聲稱四 GB 的串流會在接收中途造成記憶體不足失敗。在較舊的 RTL 中,SetSize 接受 Longint,該值會先無聲地縮窄:宣告的 $100000010 變為 16,配置成功,而實際資料的寫入遠遠超出範圍。在任何配置呼叫取得大小之前,請依量測到的來源大小與硬性上限驗證每個大小
指向檔案外的位移
交叉參照表將物件編號對應至絕對位元組位移,解析器會定位至其指定的位置。在受損或惡意檔案中,這些位移可能落在檔案結尾之外或不相關的結構內。TStream 會讓失敗悄無聲息:將 Position 設為超過 Size 不會產生錯誤,而一般的 Read 超出結尾時只會傳回少於要求的位元組,因此略過計數檢查的程式碼會繼續解析前一個物件遺留的過期位元組。防禦方式是建立一個管制點——每個由檔案驅動的定位與讀取都必須通過的單一輔助程式,在串流移動前依量測到的檔案大小驗證位移與計數
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // 任何單一物件都不得超過 64 MB
type
EPdfBoundsError = class(Exception);
// 每個由檔案驅動的定位與讀取都必須通過這裡。Offset 與 Count 是
// 檔案提供的宣稱;Source.Size 是它們必須符合的量測值。
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
將交叉參照位移、串流範圍及內嵌檔案讀取都導向這個程式,壞掉的位移就會轉為明確拒絕並指出相關數字,而不是在三次呼叫之後才發生存取違規
物件圖形中的迴圈與深度
PDF 是圖形,不是樹狀結構。任何值都可以是間接參照,參照可解析為另一個參照——/Length 12 0 R,其中物件 12 包含 13 0 R——而且沒有任何機制阻止鏈結回到自身。天真地追隨參照的解析器會遞迴到原生堆疊耗盡,而堆疊耗盡無法攔截;它會終止程序。深度巢狀的陣列與字典,即使完全沒有迴圈,也會導致相同結果
請同時使用兩項防護:明確的深度計數器會將誠實但深層的情況限制在沒有合法檔案會接近的上限;已造訪集合則會在第二次造訪時捕捉真正的迴圈,將其轉化為精確、可回報的錯誤,而不是觸發上限
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // 比任何合法的參照鏈都深得多
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // 當 Kind = pvReference 時才有意義
// ... 其餘種類的承載欄位
end;
// LoadObject 是您自己的常式:它為 ObjNumber 查詢 xref 位移、
// 用 ReadBounded 讀取物件,然後解析它。
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // 例如 /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // 兄弟節點可以合法地共用這個物件
end;
end;
解壓縮是放大器
幾 KB 的 FlateDecode 輸入可膨脹為數 GB;通用壓縮會獎勵重複的純文字,而攻擊者可讓內容最大程度地重複。將每個串流的膨脹大小限制在其取用端合理所需的範圍,並保留第二個每份文件的預算:五百個各自略低於每串流上限的串流,與一個巨大串流一樣必然會耗盡記憶體。檢查必須置於膨脹迴圈內,在輸出位元組產生時加以計數並在超限時中止,而不是等到迴圈後、記憶體早已耗盡才檢查。將文件預算表示為壓縮檔案大小的倍數效果很好,因為合法文件的比率通常遠低於精心建構串流可達到的比率
超出自有單元的縱深防禦
函式庫內也存在相同的缺陷類別。本部落格的兩個案例研究剖析了真實實例:強化 Pascal PDF 解析器以防禦惡意檔案討論原生 Pascal 引擎中的整數溢位、無界遞迴與未初始化緩衝區;強化 PDFium Component 繫結則討論繫結 C 引擎時的呼叫慣例、整數寬度與所有權風險。對於真正不受信任的接收來源——公開上傳表單、未經驗證的信箱——也請在獨立的低權限程序中執行解析與解碼工作,這樣一來,突破每一項程序內防護的檔案只會造成工作失敗,而不會使服務中斷
預檢核對清單
下一個版本發行前,請依此清單檢查解析器:每個串流緩衝區都依限制後的長度而非宣告長度決定大小;每個點陣圖都依已驗證的解碼器參數決定大小,並依解碼器輸出檢查;每個尺寸乘積都以 Int64 計算並與明確上限比較;每個以檔案衍生值作為索引的單元都啟用 {$R+};每次定位都根據量測到的檔案大小進行邊界檢查;每次參照解析都有深度限制並檢查迴圈;每個膨脹迴圈都根據每串流與每文件預算計算輸出。這些檢查都不會在合法文件上造成可量測的時間成本,而且每一項都能將記憶體損壞轉化為乾淨、可記錄的拒絕
注意:losLab 的 HotPDF Delphi Component、PDF Library for Delphi Delphi PDF Library 與 PDFium Component 都會在內部套用這些邊界檢查、深度限制與膨脹上限,因此以它們建置的接收管線一開始就具有強化的基礎