技術文章

在 Delphi 中安全地解析 PDF 字典:名稱 Token

諸如 Pos('/Length', Dict) 之類的子字串搜尋是讀取 PDF 字典的錯誤工具,因為 PDF 名稱鍵值(keys)共享前綴:/Length/Length1 的前綴,而 /Encrypt/EncryptMetadata 的前綴。ISO 32000-1 §7.3.5 定義名稱為僅在分隔符或空白字元處結束的 Token,因此只有當其後續位元組為這些字元之一時,鍵值才算被找到。若字典讀取器忽略了這項檢查,最終將會從完全合法的檔案中讀取到錯誤的數值

讓我們學到這一點的 Bug 看起來完全不像詞法分析器(lexer)的問題。符合性掃描開始回報 FontFile 串流已損壞:解壓縮後的字型程式只剩碎片,在表格中途被截斷。該檔案在每個檢視器中都能正常開啟。磁碟上的串流資料也是完整的。根本原因在於我們共享字典讀取器中的一行代碼:Pos('/Length', ...) 匹配到了 /Length1(這是 FontFile 串流字典根據 ISO 32000-1 Table 127 所攜帶的標準鍵值),而讀取器將 /Length1 後面的整數誤認為是串流長度。該特定檔案的寫入器剛好在 /Length 之前序列化了 /Length1,這完全是允許的,因為根據 §7.3.7 字典項目是無序的。串流因此被截斷為無效的位元組計數,隨後取用該串流的每個下游檢查都默默地失效了

為什麼子字串匹配會毀壞 PDF 字典解析?

子字串匹配之所以會失效,是因為 PDF 名稱空間充滿了刻意設計的前綴家族,且字典項目的順序是未指定的。ISO 32000-1 Table 127 為嵌入式字型串流定義了 /Length1/Length2/Length3,它們在同一個字典中都與 /Length 併存。加密字典將 trailer 中的 /Encrypt 與其內部的 /EncryptMetadata 配對。短鍵值的情況更糟:以 Key = 'N' 建置的 Pos('/' + Key, ...) 尋找,會輕易地對應到 /Name/Nums。這些衝突都不需要格式錯誤的檔案。將 /Length1 排在 /Length 之前的寫入器是完全符合規範的,這意味著子字串 Bug 並不是針對損壞輸入的健全性漏洞,而是針對有效輸入的正確性缺陷

這種失效模式也是無聲無息的。錯誤的 /Length 不會引發例外;它只會交給您一個比串流實際佔用空間更短或更長的位元組切片。如果該切片被送去進行字型子集檢查、CMap 解析或元資料掃描,使用者端(consumer)只會看到無效資料,且通常完全不會回報任何錯誤,因為半個 zlib 串流只會解壓失敗,隨後代碼繼續執行。我們確實發布過此類缺陷,並在對 ISO 32000-1 §7.2–§7.3 進行逐條審查、將所有 Pos 風格的鍵值尋找標記為可疑之後,於我們共享讀取器的 v2.14.3 版本中修正了此問題

ISO 32000-1 §7.3.5 實際定義的名稱是什麼

第 7.3.5 節簡短且精確:名稱物件是一個斜線(solidus)後跟一連串一般字元,且該 Token 由第一個分隔符或空白字元終止。分隔符是八個括號字元加上斜線和百分號 —— ( ) < > [ ] { } / % —— 而空白字元則是 null、tab、換行、換頁、歸位和空格(§7.2.2–§7.2.3)。該終止規則就是全部的細節。/Length1 不是「/Length 後面接 1」;它是一個單一、不可分割的 Token,就像在 Pascal 中 LengthOneLength 是不同的識別碼一樣。Any 讀取器 that finds keys by raw byte search is reimplementing the lexer with the termination rule deleted

以下是該缺陷的具體形式,已簡化至其核心部分。此版本可編譯,且能通過針對「寫入器將 /Length 排在最前面」的檔案測試,但對於非此順序的寫入器則會損壞串流

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

完整 Token 匹配:檢查鍵值後面的位元組

正確的判定直接來自 §7.3.5:只有當緊接在候選匹配項後面的字元是分隔符、空白字元或緩衝區結尾時,它才是真正的鍵值。其他所有情況都是僅共享前綴的更長名稱,因此搜尋必須繼續向後進行,而不是放棄。我們讀取器中的修正將每個原始的 Pos 尋找替換為基於此規則建置的單一共享常式

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

該迴圈中有兩個細節非常重要。首先,它會繼續搜尋,而不是在第一次前綴衝突時就回報失敗,因為 /Length1 120 /Length 4076 是合法的順序,且真正的鍵值還在後面。其次,緩衝區結尾的情況也算作終止符,因為字典片段合法地在名稱後方立即結束。另一個值得在您自己的代碼中進行審查的微妙之處:如果您的搜尋字串不包含斜線,則相同的規則也適用於匹配的左側,否則 Pos('Length', ...) 可能會落在 /PieceLength 內部。如上所述,使用前導的 / 來錨定搜尋字串可以處理左邊界,因為 / 本身就是結束前一個 Token 的分隔符

惡意 PDF 如何將解析器 Bug 轉化為吉位元組(GB)的記憶體配置?

格式錯誤或惡意的檔案會將這些語法錯誤升級為資源耗盡,因為字典中的整數經常會提供給記憶體配置大小。我們的審查在物件串流擴展中發現了完全符合此特徵的鏈結。ObjStm 字典的 /N 項目表示該串流包含多少個壓縮物件,而擴展代碼對依其大小調整的陣列呼叫了 SetLength。然而,整數解析器在失敗時並未修改其 out 參數,卻仍然將其回傳 —— 因此非數值的 /N 會將未初始化的堆疊值傳給 SetLength。該處的無效正整數意味著數吉位元組的記憶體配置請求,而這僅是由幾個位元組的損壞輸入所觸發,且此時您僅僅是掃描了您甚至還未同意信任的文件

修復包含兩個獨立的部分,且兩者都具有通用性。解析器現在在失敗時會回傳明確的 0,而絕不是未初始化的記憶體。此外,使用者端也不再無條件地信任 /N 運算:在 /First 之前的 ObjStm 標頭區域為每個壓縮物件儲存了一對整數(物件編號和偏移量),且每對整數(包括分隔符)至少佔用四個位元組。因此,對於宣告的標頭大小,任何超過 FirstVal div 4 + 1/N 在物理上都是不可能的,並且會在發生任何配置之前被拒絕。該邊界僅花費一次比較,且源自已有的資料,這正是我們尋找的模式:由檔案本身證明的上限,而非任意的常數

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

在我們的讀取器中,還有另外兩個上限完成了防禦邊界,均已在 v2.12.0 中出貨。串流讀取器在配置結果緩衝區之前,會拒絕任何大於整個檔案的 /Length —— 串流不可能大於它所存在容器,因此該檢查沒有偽陽性(false positives)。此外,解壓路徑將解壓縮的輸出限制在 256 MiB,這阻止了經典的 zlib 炸彈(即幾 KB 的輸入無限制地膨脹);該上限對於任何真實的 PDF 串流都非常寬裕,同時又保持了最壞情況下的可存活性。這三者的主題是相同的:檔案宣告的每個大小都是一個聲明(claim),解析器在為其分配記憶體之前,會根據它可以測量的指標來驗證每個聲明。相同的審查立場也適用於下一層的繫結邊界,這在在 Delphi 中強化 PDFium ABI 和記憶體安全中有所討論

僅靠完整 Token 規則還不夠的地方

老實說有些限制,以免您過度信任上述常式。完整 Token 匹配修正了鍵值識別,但在字典範圍內進行扁平位元組搜尋仍無法判斷匹配項是位於巢狀字典、字面字串還是註解內部 —— 如果您給予 FindDictKey 太寬的範圍,對頁面物件的搜尋可能會落在其 /Resources 子字典內的鍵值上。我們的讀取器首先將範圍限制在單個物件主體內,並將字串 and 註解內容視為獨立且仍待審查的項目。子字串安全性只是階梯的一階,而不是整個階梯:交叉參照一致性有其專屬學科,在驗證物件與 xref 串流中討論,而針對非您所編寫文件的更廣泛威脅目錄則在審查 PDF 安全風險中介紹

如果您在 Delphi 或 Lazarus 中維護手寫的字典讀取器,此事件的檢查清單很短。在程式碼庫中搜尋所有的 Pos('/,並將這些匹配項路由至一個完整 Token 輔助常式。列出您鍵值所屬的前綴家族 —— /Length/Encrypt/N/Type 對比 /Type1 都會出現在真實檔案中。然後檢查每個傳遞給 SetLengthGetMem 或複製迴圈的整數,並詢問是什麼限制了它:檔案大小、源自標頭的上限,還是沒有限制。這裡描述的解析層是我們 PDFium 元件的基礎,其中位元組級讀取器與轉譯引擎在接觸到的每份文件上都會互相交叉檢查