技術文章

PDF 修訂版的邊界在哪裡?簽章後的偽造更新

一份合約簽章完成之後又多出一個更新,審核腳本就可能把這個更新併進已簽章的修訂版,或把改寫過的頁面當成驗證材料放行。只要修訂版的邊界畫在 startxref 指名 /Prev 鏈走得到的那一節之處,且改寫過的物件保留它先前的類別——PDFlibPas 修訂版映射圖背後的這兩條規則——這兩件事就都不會發生

為什麼 %%EOF 不是切開檔案的正確位置

每個遞增更新都以自己的檔案結尾標記收尾(ISO 32000-1 §7.5.6),所以在每個 %%EOF 處切開檔案,看起來就是找修訂版最直覺的做法。但這個標記只有五個位元組,沒有任何東西阻止這些位元組出現在內容串流、內嵌檔案或字型程式裡。線性化檔案還添了第二個麻煩:附件 F(§F.3.4)允許首頁 trailer 以自己的 startxref 和 %%EOF 收尾,位置貼近檔案開頭,遠在主交叉參照表之前。在每個標記處切開,會憑空發明出沒有任何交叉參照節支持的修訂版。猜哪些標記是假的更糟——那個猜法正是偽造者可以塑形的東西。PDFlibPas 修訂版映射圖反著來。它沿著 /Prev 鏈走,記下鏈實際讀到的每個交叉參照節的位元組偏移量,然後才掃描 startxref N … %%EOF 序列,只有在 N 落在那些記錄過的偏移量之內才保留邊界。串流裡一個遊走的標記,除非它前面有個 startxref 指名了一個真實的節,否則什麼都算不上

PDFlibPas 在 Delphi 中的修訂版映射圖沿 /Prev 鏈走並比對 startxref 偏移量,找出遞增更新的修訂版邊界,無視串流內與線性化首頁 trailer 裡遊走的 %%EOF 標記
只有 /Prev 鏈實際讀到的 startxref 偏移量才標記修訂版結尾,串流裡遊走的 %%EOF 位元組因此對 PDFlibPas 在哪裡切檔案沒有話語權

這三個記號之間的位元組,比 §7.5.5 暗示的更不整齊。標準把 startxref、偏移量與標記畫在連續的三行上,但 §7.2.2(Table 1)的行尾標記與其他空白字元可以重複,且沒有規則設上限。漏掉一個帶填充的標記,兩個修訂版就融成一個,已簽章的那個看起來就結束在沒人簽過的位元組之後。PDFlibPas 修訂版映射圖不管填充多寡、不管落在檔案何處,一律從關鍵字到運算元再到標記沿著序列走,且每個關鍵字只計一次。trailer 關鍵字有個類似的鬆動接點:<< 是分隔符(§7.2.2,Table 2),而 §7.5.5 從不要求 trailer 之後換行,所以同一行上的 trailer<< 是合規語法。逐行讀下去,這一行看起來像交叉參照子節標頭——鏈就走一半停了

用 PDFlibPas 走一遍修訂版映射圖

這些修訂版存取器宣告在 TPDFlib 上,在 Small Business 版實作:從 PDFlibSmallBusiness 單元建立 TPDFlibSmallBusiness(Enterprise 繼承自它,帶同樣的呼叫)。在裸的 TPDFlib 上,它們回傳各自的失敗值(0、7 或 -1),並把 LastErrorCode 設為 PDFLIB_ERROR_SMALLBUSINESS_REQUIRED。修訂版從 0 開始編號,0 是原始文件。每個呼叫都收檔名與密碼,並從磁碟上的位元組建映射圖,所以答案描述的是檔案儲存時的樣子,而不是您剛好載入並編輯過的某份文件。GetDocumentRevisionKind 回報修訂版裡影響最深遠的那一項改動,一個既加了 OCSP 回應又改寫頁面的更新,會被回報成頁面改寫:

類別意義典型來源
0純家務事:不改變讀者所見任何東西的物件交叉參照串流、新的物件串流容器
1驗證材料文件安全儲存庫(DSS)、VRI、憑證、CRL、OCSP 回應
2文件時間戳一個 /DocTimeStamp 簽章與承載它的欄位
3簽章接收到值的既有簽章欄位
4表單欄位欄位值或其外觀
5註解widget 以外的任何註解
6文件內容任何能改變文件呈現的東西
7無法判定修訂版裡有東西讀不出來;視為不安全
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // 每個物件一行 'objnum,change,class,detail'
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // 項目從 1 起算
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

在相信其他任何呼叫之前,先讀的是 GetDocumentRevisionsComplete。只有當鏈一路走回原始節時它才回傳 1;斷掉的或成環的 /Prev,或深度超過 256 節的鏈,都會讓最舊的更新看不到,此後每個計數與索引都只是下限。256 這個深度替真實檔案的存檔與簽章歷史留足了餘裕,而指回自己的鏈也幾乎立刻就結束。留意迴圈裡的索引:GetStringListItem 從 1 起算,修訂版卻從 0 起算,內層迴圈從 0 開始的話,每個修訂版的第一個變更物件就會無聲無息地被丟掉

偽造更新如何把自己偽裝成驗證材料

PAdES 長期驗證在最低一個簽章之後合法地附加更新:文件安全儲存庫(/Type /DSS)、每個簽章一份的 /VRI 字典、憑證與撤銷串流、文件時間戳。認證等級放行它們,這使得驗證材料成為一個更新能掛上的最有價值標籤,它同時也是偽造起來最便宜的一個。/Type 寫什麼全看更新自己。畫頁面的檢視器從不看內容串流、資源字典或字型的 /Type,所以一個更新可以改寫第 3 頁的內容串流、在自己的字典裡加 /Type /DSS,每個算繪器照樣畫出新文字,而相信 /Type 的分類器看到的是家務事。/VRI、/Sig 與 /DocTimeStamp 同理

PDFlibPas 修訂版映射圖不理會更新在自己改寫的物件上寫的標籤。改寫過的物件只有在它的較早版本本來就是驗證材料、時間戳或簽章時,才以那個類別計數。憑證、CRL 與 OCSP 回應串流本身不帶型別,靠指向它們的文件安全儲存庫來辨認——而且只有寫在被判定的修訂版或更早修訂版裡的儲存庫才算。後來才加入的儲存庫伸不回頭,作保不了已簽章修訂版寫下的物件。只要改動會改變讀取器會解析出什麼,對文件結構的改動就算內容:

PDFlibPas GetDocumentRevisionKind 把改寫頁面內容串流的偽造 /Type /DSS 更新回報為文件內容,因為修訂版映射圖按物件的較早版本分類改寫過的物件
把改寫過的頁面內容串流標上 /Type /DSS 對算繪器毫無影響,PDFlibPas 仍把該更新回報為文件內容,因為這個物件先前就是頁面內容
  • 替換既有的物件串流,因為串流裡每個壓縮物件此刻都解析到替身(全新容器仍算家務事,因為每個成員有自己的條目,各自在原處受判定)
  • 加入目錄或頁面
  • trailer 的 /Root 指名另一個目錄,即使更新沒有重定義任何簽章涵蓋的東西也算,因為換掉根就換掉了整份文件,不必碰任何一個被簽章的物件
  • 改寫過的串流,其字典與舊的逐位元組相同:間接的 /Length 或填充到相同尺寸的替換內容,能在圖形改變的同時保持字典不變,所以只有不帶串流內容的物件才憑這個證據視為未變

物件串流成員沒有可剝除的標頭

從 PDF 1.5 起,更新可以把改寫過的物件收進物件串流,而 §7.5.7 對這個格式說得很直白:只存物件值,不得使用 obj 與 endobj 關鍵字。於是那種「讀到物件所在位置、靠搜尋字串 obj 來剝掉標頭」的程式碼,在成員開頭什麼都找不到,會繼續往內容裡搜。給簽章之後才改寫的頁面字典塞一個看似無辜的字串,例如 (obj<</Type/DSS>>),搜尋就停在字串裡面。解碼器收到的是 <</Type/DSS>>——一個該修訂版從未寫過的儲存庫字典。PDFlibPas 修訂版映射圖從未壓縮物件的開頭解析 N G obj,核對數字指名的確實是它預期的物件,標頭缺失或不符時視為不可讀,而不繼續深挖

交叉參照串流自己也帶著欄位層級的歧義。按 §7.5.8.3(Table 18),只有 type 1 條目存位元組偏移量;type 2 條目的第二個欄位是容納它的物件串流的物件號,第三個是串流內的索引;而 type 0 條目的第二個欄位是下一個空閒物件號。把那個物件號當偏移量讀,壓縮條目就會落在涵蓋那些小位元組位置的任何修訂版裡——幾乎總是原始文件,改寫就此藏進已簽章的位元組中。修訂版映射圖把每個條目歸入宣告它的那個交叉參照節。同一條款還說,型別不是 0、1 或 2 的條目是對空物件的參照,所以這類條目既不加也不放任何東西,不上映射圖

簽章審核什麼時候該拒絕作答?

檔案藏起自己歷史的一部分時。修訂版映射圖用三種方式回報這件事:GetDocumentRevisionsComplete 回傳 0、GetDocumentRevisionKind 在簽章之後的某個修訂版回傳 7、或 GetSignatureRevisionIndex 回傳 -1。最後那個值涵蓋不在修訂版邊界上結尾的 ByteRange——簽章聲稱涵蓋的範圍內嵌著其他位元組——也涵蓋不存在的欄位、不帶簽章值的欄位,或落在讀不出來的檔案裡的欄位。三種情況都附上修訂版索引與 GetDocumentRevisionChanges 的輸出行送人工複核,讓打開檔案的人從物件號著手

// 以 DocMDP 式政策核對一個簽章之後的各個更新。
// 類別按嚴重度排序,所以每個政策等級是一個上限:
// P 1 容忍家務事、驗證材料與時間戳(0..2),
// P 2 加入簽署與表單填寫(..4),P 3 加入註解(..5)。
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // 歷史有一部分從未被看到
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // 不在修訂版邊界上、沒有這個已簽章
                                 // 欄位,或檔案讀不出來
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 高於一切上限;寫出來是給讀者看的
  end;
  Result := True;
end;

這些上限依 ISO 32000-2 §12.8.2.2(Table 257):P 1 不允許任何改動,2 允許表單填寫與簽署,3 加入註解,而只添加 DSS 資料或文件時間戳的遞增更新在任何等級都不計為改動。ISO 32000-1 §12.8.2.2 定義同樣三個值,但沒有這條豁免。政策是您自己的而不是簽發者的時候,就自己寫迴圈,例如一條不管檔案掛什麼 P 都拒收帶註解合約的收件規則。政策該從檔案來時,同一個類別上的 GetSignatureModificationVerdict 會讀認證等級、套用偵測 FieldMDP 鎖定表單欄位的竄改所述的逐欄位鎖,並對越界情況回傳 5。兩者回答的問題都與對照您留存的文件副本分類改動不同:修訂版映射圖除了眼前的檔案什麼都不需要

PDFlibPas 在 Delphi 中的 DocMDP 式簽章審核,以 GetDocumentRevisionKind 上限對應 P 1、P 2 與 P 3,並先檢查 GetDocumentRevisionsComplete 與 GetSignatureRevisionIndex
每個 DocMDP 等級化為修訂版類別的上限:P 1 容忍 0 到 2、P 2 到 4、P 3 到 5,無法判定的修訂版任何等級都過不了

附加走的是同一條鏈。遞增更新的 trailer 必須帶 /Prev 條目,給出前一個交叉參照節的位置(§7.5.6),而只有靠全盤掃描重建交叉參照表才開得了的文件,沒有這種位置可給。AppendToFile 對它回傳 0,目標檔案分毫未動。請完整存一次,在乾淨的副本上簽章——完整改寫不會把既有簽章帶過去

對已簽章合約的歸檔,在任何新東西被附加之前,對每個簽章欄位跑一次 UpdatesWithinPolicy。後來的修訂版讀出類別 7 的檔案,應該先送到人的手上,趕在任何會把讀不出的修訂版再埋進一個更新底下的 PDFlibPas 簽章或長期驗證步驟之前