Technical Article

強化 Pascal PDF 解析器以防禦惡意檔案

PDF 不是您開啟的檔案;它是您執行的小型程式;每個內嵌的字型都是等待字元字串(Charstring)的基於堆疊的解譯器,每個影像都是被餵入由檔案選擇之寬度、高度和位元深度欄位的解碼器,且每個串流在到達時都包裝在檔案設定了參數的篩選器中;那些數字都不是您的;它們來自製作檔案的人,在實際的工作負載中,這可能是客戶的發票或來自未知寄件者的附件;將這些位元組轉換為像素和字圖的解碼器就是攻擊層面,而在那裡信任其輸入的解析器距離崩潰或更糟的情況僅隔著一個格式錯誤的檔案

PDFlibPas 經歷了強化階段,將整個解碼路徑視為敵對路徑,涵蓋字型程式(TrueType、Type1、CFF 和 CMap 表)、影像解碼器(PNG、GIF、TIFF、JBIG2 以及 CCITT Group 3 和 Group 4),以及串流篩選器(LZW、ASCII85 和 Flate 預測器);以下是它關閉的五個缺陷類別,每個都基於使其成為可能的特定 Delphi 行為;它們在目前的版本中已得到修正,且相同的模式會出現在解析不可信輸入的任何 Pascal 程式碼中

給您分配不足之緩衝區的整數溢位

影像解碼器中經典的記憶體安全錯誤是發生環繞的維度乘積;解碼器讀取寬度、高度、元件計數和位元深度,將它們相乘以調整其輸出的大小,配置該數量的位元組,然後以其真實維度寫入影像;如果在 32 位元算術中進行乘法,即使每個單獨的因子都在合理的範圍內,乘積也可能會環繞為一個小值,因此配置成功,但結果太小,解碼就會超出其末尾;這是 CWE-190 整數溢位,一步之後會導致堆疊越界寫入(CWE-787)

共用影像路徑已經將每個維度限制在 65535;並非所有的獨立解碼器都繼承了該限制;當兩個運算元都是 32 位元整數時,例如 ByteCount * FHeight 的列位元組乘以高度運算式,或 FWidth * Components * BitDepth 的每像素運算式,在 Delphi 中都是 32 位元乘積,不論您將結果分配給多寬的變數;對於大型掃描,60000 的寬度和高度都是合理的,但它們以位元組為單位的乘積超出了有符號的 32 位元範圍,且長度結果很小;相同的陷阱存在於 ZLib 預測器步長 BitsPerComponent * Colors * Columns

修正方法是使至少一個運算元為 Int64,以便在 64 位元中評估整個運算式,然後與 MaxInt 進行比較並在收窄回呼叫 SetLength 之前拒絕該檔案

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

使這成為 Delphi 問題而不是一般問題的原因是靜默的收窄;將過寬的運算式分配給 32 位元目標是合法的轉換(編譯器預設不會對此發出警告),且範圍檢查不會捕捉到在值被用作索引之前發生的環繞;將乘積保持在 32 位元,語言就會悄悄地給您一個長度,該長度對解碼即將觸及的記憶體量撒謊

使防護無法觸發的欄位型態

TIFF 檔案是影像檔案目錄(IFD)的鏈結,每個都攜帶下一個的位元組位移量;惡意檔案可以將該鏈結指向自身,而在沒有停止條件的情況下遍歷它的讀取器將永遠執行;這是由受攻擊者控制之輸入驅動的無限迴圈 CWE-835,而防禦方法是設定一個計數器,一旦它超過了任何合法檔案都不會達到的限制就會停止

頁面計數器被宣告為 Word,在 Delphi 中可保存 0 到 65535;迴圈攜帶了一個形式為「當頁數超過 65535 時停止」的終止防護,這讀起來是正確的,直到您注意到運算元和閾值共享一個上限;Word 絕不可能大於 65535,因此該比較在結構上始終為 false:當計數器達到 65535 時,下一次遞增會將其環繞回 0,防護永遠不會看到高於上限的值,而迴圈的 IFD 鏈結會使讀取器一直旋轉

修正方法是拓寬欄位,以便防護可以表達計數器實際可以保存的值;在將 TPDFTIFF.FPageCount 宣告為 Integer 的情況下,相同的 FPageCount > 65535 比較變得可達,迴圈終止,且公用 PageCount 屬性變更了型態以匹配,而不會破壞任何呼叫者;每當邊界檢查具有 Value > MaxValueOfType(Value) 的形式,且運算元已經精確地被設定為該最大值時,條件就是一個常數 false:拓寬型態,或測試與最大值的相等性,以便它可以觸發

在熱點路徑上關閉範圍檢查

在開啟範圍檢查的情況下,Delphi 會在每個陣列和字串索引上插入邊界檢查,這就是引發可捕捉之 ERangeError 的超出範圍索引,與讀取或寫入不屬於該結構之記憶體的相同索引之間的區別;熱點路徑(Hot path)有時會使用本機 {$R-} 指令將其停用,這在索引不再安全前都是可以辯護的

字型解譯器依賴的清單存取器 TPDFlibStringList.Get 正是這樣的一條路徑;在 Windows 上,它是在關閉範圍檢查的情況下進行編譯的,並直接索引其後端儲存區,因此超出範圍的索引不是錯誤,而是原始記憶體存取;當索引始終有效時這沒有問題,但在 CFF 或 Type2 字元字串(Charstring)解譯器內部這就變得有問題了,因為索引可以來自檔案;從空堆疊彈出運算元的字元字串會產生負一的索引;與字圖計數偏差一的字圖識別碼會索引超出末尾一個位置;在關閉範圍檢查的情況下,兩者都會成為真正的越界存取,而不是可捕捉的異常,且因為這些位置保存著引用計數的 AnsiString 值,雜亂的讀取也可能會損壞字串的引用計數

強化工作並沒有為熱點路徑重新開啟範圍檢查;它先使索引可被證明是有效的:在獲取運算元堆疊頂部之前,解譯器會檢查堆疊是否為非空,且每個索引防護都寫入為針對計數的嚴格小於,而不是允許偏差一的小於或等於;該指令將邊界的責任從編譯器移交給您,且它移除的驗證必須在每個進入點手動放回

字元字串解譯器中的無限制遞迴

Type2 字元字串可以呼叫副常式(Subroutine),而副常式本身也是一個可以呼叫另一個副常式的字元字串,因此本機和全域副常式呼叫運算元會讓檔案決定它走得多深;直接或透過循環呼叫自身的副常式會無休止地遞迴,直到本機堆疊耗盡且程序結束為止;這是未受控制的遞迴 CWE-674

Type1 解譯器已經對此進行了防護;它攜帶了一個呼叫深度計數器和上限 PLType1MaxCallDepth,並拒絕下降超出它,這反映了 Type1 規範本身命名的深度限制;後來加入且在結構上相似的 Type2 解譯器沒有攜帶相同的防護,且帶有呼叫其自身編號之副常式的手工字型會直接穿過缺失的檢查,進入堆疊溢位

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

修正方法是給予 Type2 路徑與其 Type1 兄弟已有的相同有界深度;對受攻擊者控制之結構進行的任何遞迴下降(不論是字型副常式、巢狀陣列還是交叉參照鏈結)都需要一個輸入無法提升的深度上限

洩漏到輸出中的未初始化記憶體

最微妙的缺陷會將堆積(Heap)內容洩漏到解密的輸出中,原因在於 SetLength 的一個容易忘記的屬性;當您使用 SetLength 增長 AnsiString 時,Delphi 配置了位元組但沒有將它們歸零,因此新區域會保存先前存在於該堆積記憶體中的任何內容;如果隨後寫入了每個位元組,這就無所謂了;如果路徑使緩衝區的一部分未寫入然後將其作為資料返回,那些過期的位元組就會隨著結果一起輸出;這是使用未初始化記憶體的 CWE-457,且當結果跨越信任邊界時,它就會成為資訊洩漏

AES-CBC 解密路徑精確地遇到了這一點;輸出緩衝區使用 SetLength 調整大小,且解密器一次處理一個 16 位元組的加密文字區塊;當加密文字長度不是 16 的倍數時(攻擊者可以選擇此長度),尾隨的部分區塊永遠不會被寫入,因此那些最後的位元組保留了 SetLength 留下的堆積內容,且緩衝區作為檔案物件的解密純文字被傳回;解決補救方法是兩個防護,且單獨任何一個都不夠:解密進入點現在拒絕任何長度不是區塊大小倍數的加密文字,且作為後盾,輸出在建置前使用 FillChar 進行清除,以便未能寫入區域的任何路徑傳回零而不是堆積殘留物

這個階段帶給您的收穫

這五個缺陷是不同的錯誤,但它們有共通點;整數寬度發生乘積環繞、使防護固定為常數 false 的欄位型態、在索引不再安全的地方停用範圍檢查、沒有底線的遞迴,以及語言拒絕歸零的緩衝區;在每一個中,Delphi 都精確地執行了它所定義的內容,因為語言給您提供了發生環繞的算術、靜默的收窄、您可以關閉的範圍檢查、沒有內建限制的遞迴,以及不進行初始化的配置;這就是協定,且 Pascal 解析器透過在檔案控制的每個邊界上手動擁有四件事來滿足它:整數寬度、範圍檢查、遞迴深度和緩衝區初始化

這些缺陷在目前的 PDFlibPas 版本(Delphi 和 C++Builder 的引擎)中已被關閉;如果您的工作還涉及檔案如何聲稱受到保護,稽核加密與權限以及 PDF/A 和 PDF/UA 預檢的配套筆記涵蓋了同一個解析器的分析側,且所有這一切都包含在 PDFlibPas Delphi PDF Library 中,並與本部落格其他地方介紹的載入、轉譯和簽章 API 搭配出貨