技術文章

重建受損 PDF Xref 表:Delphi 復原掃描

當 PDF 的交互參照表無法使用時,解決之道就是完全略過它,直接從檔案本文重建。PDFlibPas Delphi PDF Library 的做法是用一個單次掃描的 token 掃描器,記錄它看到的每一個真正的間接物件標頭,接著復原 trailer 字典,並把重建完成的表交給一般的載入器處理

PDF 受損時最先壞掉的是什麼

交互參照表是 PDF 中最脆弱的部分,因為它是唯一儲存絕對位元組偏移量的部分。ISO 32000-1 §7.5.4 將這些條目定義為從檔案開頭起算的十位數偏移量,而 §7.5.5 則把 startxref 關鍵字放在檔案尾端附近,指向表本身。任何會移動位元組的編輯,都會讓這些數字全部失效。以文字模式執行、轉譯了 CRLF 的 FTP 傳輸、被截斷的下載、共用硬碟上壞掉的磁區、只是附加寫入卻沒有正確寫出增量更新的批次工具:這些狀況全都會讓物件資料保持完全可讀,卻讓索引指向垃圾資料

這正是為何「檔案已損毀,正在修復中」這個對話框如此常見。位元組資料幾乎總是還在,消失的只是那張地圖。因此重建並不是對遺失資料進行鑑識復原,而是重建一個本來就能從本文推導出來的索引,而它成功的機率遠高於使用者的預期,因為真正昂貴的內容——頁面樹、字型與影像——完全沒有受到影響

為何搜尋「N 0 obj」會找到誤判項目?

一個天真的重建做法,會在原始位元組中搜尋「整數、整數、obj」這個樣式,並記錄每一次命中。而這樣找到的太多了。PDF 是一種容器格式,檔案裡有三個區域對物件語法而言是不透明的:註解(§7.2)、字串(§7.3.4)與串流資料(§7.3.8)。這三者當中任何一個,都可能包含讀起來和物件標頭一模一樣的位元組,但它們沒有一個真的是物件標頭。一段字面字串裡的說明文字、一則殘留的除錯註解,或是兩百萬位元組的 Flate 或 DCT 輸出資料,都很樂意產生出看起來像 99 0 obj 的東西

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

每一個誤判項目都要付出雙重代價。它會用一個根本不存在的物件編號污染重建後的表,還可能把檔案後面出現的、編號相同的真正物件蓋掉。因此 PDFlibPas 完全不做樣式比對。它做的是 token 化處理,也就是說它隨時都知道游標下的位元組是程式碼還是酬載資料,而酬載資料會被直接跳過,永遠不會被解讀

以 64 KiB 區塊為單位的單次掃描狀態機

PDFlibPas 以 64 KiB 為單位的區塊,對整個檔案精確地掃描一次,所用的狀態機建構於 ISO 32000-1 §7.2 的 token 規則與 §7.3.10 的間接物件語法之上。一個 token 會在空白字元或某個分隔字元處結束,而只有在看到完整的一組正整數物件編號、非負整數世代編號,以及裸的 obj 關鍵字之後,才會記錄一個物件標頭。所記錄的偏移量是物件編號 token 的起始位置——這正是交互參照條目必須指向的位置,而不是 obj 關鍵字所在的位置

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

這裡的重要細節是:token 狀態與字串狀態會跨越區塊邊界存活。一個橫跨 65536 位元組分界線的標頭仍然能被正確識別,因為未完成的 token、待處理的整數配對,以及是否處於字串內部的旗標,都會被帶入下一個區塊。緩衝區的大小是固定的:掃描用 64 KiB,可能有意義的最長 token 用 32 位元組,而唯一會隨檔案增長的陣列,是物件編號、世代編號與 64 位元偏移量清單,它們的大小與真實物件數量成正比,而非與檔案大小成正比。實際運作時,掃描過程對整份文件只需依序讀取,最多再加上兩次明確的定位(seek),這正是它在 直接存取合併與分割一文 所討論的數百 MB 等級輸入檔上依然可行的原因

為何不能信任串流會在 endstream 處結束?

因為串流資料是任意位元組,而任意位元組偶然拼出 endstream 這幾個字元完全有可能。緊接在 stream 關鍵字之後開始的串流,必須被當作不透明資料略過,直到它真正結束為止,但結尾關鍵字第一次出現的位置,只能算是候選點。PDFlibPas 的解法是要求佐證:只有當下一個非空白 token 是獨立的 endobj——也就是 §7.3.8 要求圍繞在串流物件周圍的那個序列時——endstream token 才會被接受為串流的真正結尾。壓縮資料裡偶然出現的命中,幾乎不會有這樣的後續,因此掃描器會留在串流內繼續前進。還有兩條較小的規則同等重要。stream 關鍵字只有在它是裸關鍵字時才會進入串流狀態,所以字典裡像 /stream 這樣的名稱物件絕不會觸發它。而 objtrailer token 只有在沒有超出 32 位元組上限、且不是以斜線開頭的情況下才會被採信。少了這兩道防護,一個鍵名取錯的資源字典就足以讓掃描出軌,這正是 安全解析不受信任 PDF 一文 所涵蓋的那一類對抗性輸入

找出 trailer 字典的真正結尾

復原物件只完成了一半的工作,因為載入器仍然需要一個 trailer 才能找到 /Root。PDFlibPas 會記住掃描過程中找到的最後 64 個 trailer 關鍵字位置,並由最近的開始往回驗證,因此最新可用的 trailer 會勝出,而一個後面沒有接上字典的雜散關鍵字,則會直接驗證失敗,並轉而嘗試前一個候選點。每個候選點的讀取上限為 1 MiB,字典的結尾則透過追蹤巢狀的 <<>> 深度,並同時處理字面字串的跳脫字元、十六進位字串與註解來定位

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

深度追蹤並非紙上談兵。一個因截斷而遺失 /Encrypt 的 trailer,會把一份原本可復原的加密文件,變成一份無法開啟的文件;而遺失 /Info 或某個自訂子字典,則會悄悄丟掉下游系統可能仰賴的中繼資料。如果檔案是加密的,復原出來的 trailer 正是讓一般憑證流程得以運作的關鍵,其重試語意與 加密文件載入一文 所描述的相同

重建無法還給你的東西

重建終究只是盡力而為,誠實面對其限制,正是把它做好的一部分。有三種情況會徹底失敗。打包在物件串流內的物件(§7.5.7)對逐位元組掃描而言,個別是不可見的,所以如果容器倖存了下來,但它的交互參照串流(§7.5.8)卻沒有,容器內存放的物件就不會被重建過程建立索引。一份本文確實已經損毀、而非只是索引錯誤的檔案,會產生內容再也無法解析的標頭。而一份沒有可復原的 trailer 關鍵字、也沒有可讀取目錄的檔案,無論找到多少個物件標頭,都沒有東西可以拿來錨定文件樹

重複的物件編號則是有趣的中間情況。一份經過增量更新的檔案,合理地會包含同一個物件編號的好幾個世代,而倖存的交互參照鏈,是唯一記錄哪一個才是目前版本的依據。重建過程沒有這條鏈,所以它會依檔案順序記錄看到的每一個標頭,之後再依物件編號來解析。通常較晚的修訂版本會勝出,這通常也是對的,但一份先被更新、後來又部分回溯(roll back)的文件,重建後的結果可能與原始 xref 所描述的內容有微妙的差異。線性化(linearised)檔案則從另一個方向帶來同樣的警示:一旦索引被重新產生,首頁版面配置與提示表(hint table)就變得毫無意義,因此修復後的檔案應被視為一份普通、非線性化的文件

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

這個備援機制是自動觸發的:只要交互參照鏈無法讀取,PDFlibPas 就會執行原始掃描;而當每一個使用中的條目都宣稱偏移量為零時(這正是表已被寫出、卻從未被填入內容的特徵),也會觸發同樣的掃描。GetDocumentRepaired 在這條路徑執行過後會回傳 1,這個值值得記錄下來、而不該被忽略,因為一份透過重建才載入成功的文件,應該重新儲存為一份乾淨的檔案,而不是就這樣留在處理流程中、彷彿什麼事都沒發生過。儲存它會寫出一份全新、一致的交互參照表,這對所有下游消費端而言,都是成本最低的修正方式

本文所示的重建路徑、GetDocumentRepaired 旗標與串流載入器,都是 PDFlibPas Delphi PDF Library 的一部分,與本部落格其他文章所涵蓋的解析、渲染與簽署 API 一同出貨