技術文章

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

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

PDF Library for Delphi 把整條解碼路徑都當成敵對路徑對待,涵蓋字型程式(TrueType、Type1、CFF 與 CMap 表)、影像解碼器(PNG、GIF、TIFF、JBIG2 以及 CCITT Group 3 與 Group 4),以及串流篩選器(LZW、ASCII85 與 Flate 預測器)。接下來是這條路徑必須封住的五個記憶體安全陷阱,每一個都奠基於使它成為可能的特定 Delphi 行為,而同樣的形狀也會出現在任何解析不可信輸入的 Pascal 程式碼裡

PDF Library for Delphi:Pascal PDF 解析器的攻擊面圖:字型解譯器、影像解碼器與串流篩選器,指向五個設有防護的陷阱類別
寬度、深度、位移、長度與篩選器旗標全都來自產製端,這使字型、影像與篩選器解碼器成為剖析器必須當成敵對對待的攻擊面

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

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

當獨立解碼器自己計算尺寸時,共用影像路徑上把每個維度鉗制在 65535 並不足夠。諸如 ByteCount * FHeight 的列位元組乘以高度運算式,或諸如 FWidth * Components * BitDepth 的每像素運算式,當兩個運算元都是 32 位元整數時,在 Delphi 中就是 32 位元乘積,不論您把結果分配給多寬的變數。60000 的寬度與高度對一次大型掃描來說各自都合理,但它們以位元組為單位的乘積會超出有符號 32 位元範圍,長度於是變得很小。同樣的陷阱也存在於諸如 BitsPerComponent * Colors * Columns 的預測器步長之中

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

PDF Library for Delphi:並列比較:環繞的 32 位元維度乘積導致配置過小與越界寫入,對照以 Int64 擴寫檢查拒絕該檔案
以 Int64 計算位元組數乘積,剖析器才能與 MaxInt 比較,在 SetLength 切出撒謊的緩衝區之前就拒絕惡意維度
// 在配置之前拒絕,而不是在寫入之後。
// 以 Int64 計算乘積,讓它不會在 32 位元處環繞
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // 惡意或無法支援的尺寸;拒絕此影像
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 這個比較變得可達,迴圈也能終止。每當一個邊界檢查具有 Value > MaxValueOfType(Value) 的形狀、而運算元又正好以該最大值作為型別時,這個條件就是常數 false:把型別拓寬,或是改成對最大值測試相等,讓它有機會觸發

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

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

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

PDF Library for Delphi 在這條熱點路徑上保持範圍檢查關閉,改為讓索引可被證明有效:在取出運算元堆疊頂端之前,解譯器會檢查堆疊非空,且每個索引守衛都寫成對計數的嚴格小於,而不是允許偏差一的小於或等於。這個指令把邊界的責任從編譯器移交給您,它移除掉的驗證,就必須在每個進入點用手工放回

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

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

Type1 解譯器以一個呼叫深度計數器和上限 PLType1MaxCallDepth 防範這種情況,並拒絕下降超過它,這呼應了 Type1 規範本身點名的深度限制。陷阱在於:一個後寫的、結構上相似的解譯器(例如 Type2 那個),並不會自動繼承這道守衛,而一個帶有「副常式呼叫自己編號」的手工字型,會直接穿過缺席的檢查,一路撞進堆疊溢位

// 兩個字元字串解譯器都需要的深度守衛形狀。
// 在巢狀呼叫之間追蹤深度,超過上限就拒絕遞迴
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // 惡意的自我參照副常式;停止下降
// ……解譯此副常式,返回時再 Dec(CallDepth)

PDF Library for Delphi 給予 Type2 路徑與其 Type1 兄弟相同的有界深度。任何對受攻擊者控制之結構的遞迴下降,不論是字型副常式、巢狀陣列還是交叉參照鏈結,都需要一個輸入無法抬高的深度上限

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

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

AES-CBC 解密路徑正是這個問題發作的地方。用 SetLength 配置輸出緩衝區大小,一次處理一個 16 位元組的密文區塊,而當密文長度不是 16 的倍數時──這個長度攻擊者可以任選──尾端的部分區塊永遠不會被寫入,於是那幾個最後的位元組保留了 SetLength 留下的堆積內容,緩衝區就以文件物件的「解密明文」身分送了回去。解方是兩道守衛,單獨任何一道都不夠:PDF Library for Delphi 的解密進入點會拒絕任何長度不是區塊大小倍數的密文,而作為後盾,輸出在使用前會以 FillChar 清除,讓任何未能寫入某個區域的路徑回傳零、而不是堆積殘留物

PDF Library for Delphi 圖解:AES-CBC 解密中,尾端區塊未終止時會留下未初始化的堆積位元組,除非進入點長度檢查與 FillChar 緩衝區清除為這條路徑把關
兩道獨立防線堵住洩漏:拒絕長度不對齊 16 位元組邊界的密文,並預先清空輸出,即使漏寫也只會得到零而非堆積殘渣

五個陷阱的共同點

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

PDF Library for Delphi 這個 Delphi 與 C++Builder 的引擎,對這五個陷阱都設下了防護。如果您的工作還延伸到檔案如何宣稱自身受到保護,關於稽核加密與權限以及PDF/A 與 PDF/UA 預檢的配套筆記,涵蓋了同一個解析器的分析側;而這一切,連同本部落格其他文章介紹的載入、轉譯與簽章 API,都隨 PDF Library for Delphi Delphi PDF Library 一同出貨