技術文章

PDFium Component DocMDP:Widget /P 如何藏住頁面編輯

v3.126.2 之前的 PDFium Component 建置裡,TPdf.AnalyzeSignatureRevisions 可能把一次貨真價實的頁面內容編輯,評成 DocMDP P=3 下允許的註解變更——因為它的修訂角色圖把簽章 widget 指回頁面的 /P 回參照當成了所有權。v3.126.2 起,PDFium Component 把導航邊與承載邊分開,頁面內容於是始終是頁面內容。這個修正背後的 bug 回報,紙面上毫不起眼:一份允許註解的認證合約,對手加了一次增量儲存,分析器說後續變更全部允許。直到有人比對渲染後的頁面,第 2 頁的付款金額變了

本文是簽章後修訂變更分析總覽的攻擊者視角續篇,修訂重建與 DocMDP 評級的基礎不再重講,直接進入物件圖:所有權當初怎麼建模、一條邊的方向為什麼能決定安全判決、v3.126.2 改了什麼,以及怎麼稽核您自己的驗收邏輯

頁面編輯為什麼能在 DocMDP P=3 下混過註解變更?

頁面編輯能混過去,是因為舊角色圖追蹤字典裡的每一條間接參照,把被參照的物件當成歸參照者所有,而簽章 widget 恰好回頭指著自己的頁面。註解字典帶著 /P——一條指向它所附頁面物件的間接參照(ISO 32000-1 §12.5.2)。那個條目是導航提示:widget 並不擁有頁面,是頁面透過自己的 /Annots 陣列擁有 widget

分析器在給後續變更評級之前,先為每個物件指派一組角色位元:頁面、註解、表單與驗章素材。根物件的角色取自它自己的字典,角色再向外傳染到它參照的一切。舊傳播裡,這條鏈是這麼走的:

  1. 簽章 widget 是帶 /FT /Sig 的 /Subtype /Widget,拿到註解角色
  2. widget 的 /P 把註解角色推上頁面字典,而頁面字典本來就有頁面角色
  3. 頁面把兩種角色推進 /Contents、/Resources,並經 /Parent 一路推上 Pages 樹、橫向傳到每個兄弟頁面
  4. << /Length 812 >> 這類內容串流字典沒有 /Type,分類器於是退回角色位元,而且註解角色先於頁面角色被檢查
PDFium Component 的 v3.126.2 之前 DocMDP 角色圖示意:簽章 widget 的 /P 回參照把註解角色推上頁面字典,頁面再經 /Contents 把角色擴散到沒有 /Type 條目的內容串流,分類器輸出 prckAnnotation,P=3 評級回傳 prdAllowed
v3.126.2 之前,角色圖把每條間接參照都當所有權,widget 的 /P 條目於是把註解角色推上頁面,一次如假包換的頁面編輯,從分析器出來卻成了允許的註解變更

被改的內容串流於是被標成 prckAnnotation。依 ISO 32000-1 §12.8.2.2,DocMDP P=3 允許註解變更,判決於是 prdAllowed,報告往上彙整成 prasAllowed。同一份檔案在 P=2 下會被拒,但純屬巧合:P=2 禁止註解變更,被貼錯標籤的串流是因為錯的理由被擋下。固定跑四輪的傳播迴圈又添了第二個弱點:經間接陣列、或經物件編號倒著走的長鏈送達的承載,可能一個角色都拿不到

簽章驗證器為什麼必須問物件歸誰所有?

簽章驗證器必須問物件歸誰所有,因為 PDF 的增量更新(ISO 32000-1 §7.5.6)允許任何人追加一個重定義既有物件編號的修訂,而重定義後的主體不會自我介紹。簽章照樣驗得過——它只涵蓋自己那個修訂的位元組。因此,對抗簽章後動手腳的每一道防線,都繫於把每個被改的物件映射到使用它的結構,再問簽署者是否允許那個結構變動

好幾類公開的攻擊正是打在這個縫上。Incremental saving attack 追加一個調換頁面內容的修訂,賭的是驗證器只檢查簽章位元組範圍。Shadow attack 在簽章前埋下隱藏內容,事後用一個小小的、看似無辜的變更把它啟動。對認證文件的攻擊則濫用 P=2 與 P=3 明文允許某些後續編輯這件事,把被禁的編輯喬裝成允許的那種。用 /Type /Annot 之類標籤分類物件、或用任何碰巧抵達的路徑分類物件的驗證器,就暴露在第三類之下:攻擊者只需要一條能碰到被禁結構的允許結構

所以問題不在哪些物件變了,而在物件歸誰。經頁面的 /Contents 抵達的內容串流就是頁面內容,別的什麼指著它都改變不了這一點;註解經 /P 回指頁面,說的是註解住在哪,不是它擁有什麼

PDFium Component v3.126.2 怎麼建模所有權?

PDFium Component v3.126.2 把回參照視為導航、擋在角色傳播之外,至於哪些鍵算導航,由持有它們的字典的結構角色決定,不單看鍵名。下表彙整不再攜帶所有權的導航鍵:

持有者字典視為導航的鍵規格出處
頁面或 Pages 節點/Parent、/Kids、/AnnotsISO 32000-1 §7.7.3
註解或 widget/PISO 32000-1 §12.5.2
widget 或欄位字典/ParentISO 32000-1 §12.7.3
PDFium Component v3.126.2 的物件圖示意:/Contents 與 /Annots 這類承載邊擴散頁面與註解角色,widget /P 這類導航邊不攜帶任何角色,圖上並標出各持有者字典的導航鍵——即使在偽造的 /Type 之下,內容串流仍保持 prckPageContent
v3.126.2 把回參照擋在角色傳播之外:角色只循真正的所有權流動,內容串流於是保持頁面內容身分,/P 提示什麼也決定不了

全域按鍵名過濾會挖出新洞。字型或 XObject 資源完全可以合法地取名 /P、/Parent 或 /Annots;/Resources 字典若把它的 /P 條目排除在傳播之外,攻擊者就能把頁面私有的 XObject 藏在無辜的資源名背後。v3.126.2 裡,導航過濾只在持有字典確實是頁面、Pages 節點、註解、widget 或欄位時生效。這類字典若帶著重複的導航鍵,例如 widget 裡有兩個 /P,分析器不去猜檢視器會用哪一份——角色建構直接失敗,簽章變成 Indeterminate

另外幾條規則封掉剩餘的改標路線:

  • Pages 節點本身就是頁面角色的根,從 Pages 樹繼承的資源(ISO 32000-1 §7.7.3.4)經真正的所有權進入頁面語境,不靠從子頁面往上走的 /Parent
  • 抵達 catalog、Pages 節點、頁面、註解或欄位字典的註解或表單角色就地止步——這些結構性物件確立自己的角色,外來的承載角色不得覆寫
  • 分類時頁面角色說了算:頁面私有的物件就是 prckPageContent,就算後續修訂用偽造的 /FT、/Type /Annot 標籤改寫它、或讓它與外觀串流共享,也不變
  • 只當欄位或註解外觀用的 Form XObject 保留其表單或註解類別,填表後的正常外觀重生於是照樣按一般許可規則評級
  • 自身沒有 /FT 的 widget 沿 /Parent 鏈解析繼承的欄位型別;解析不了的鏈讓角色建構失敗,而不是默默落入註解
  • 每個後續修訂的角色位元都併入受涵蓋修訂的角色,後到的更新於是無法先斷開串流、再動手編輯,把早先的頁面所有權關係抹掉

不動點,取代固定輪數

v3.126.2 的角色可達性用工作佇列跑,迭代到沒有任何物件再拿到新的角色位元為止——不管鏈多深、物件編號怎麼排,這都是真正的不動點。自成一個物件存放的 /Contents 陣列這類間接陣列也會被走訪。每個物件至多拿到四種角色位元,佇列因此以每個物件編號四個條目為上限;超出預算丟 prrResourceLimitExceeded。指向自由物件、代數不符或物件標頭損壞丟 prrMalformedRevisionChain;壓縮物件串流裡的承載丟 prrCompressedObjectUnresolved。這些失敗一律以 prasIndeterminate 收場,絕不給允許判決;失敗若發生在建構受涵蓋修訂的角色時,該簽章乾脆完全不回報 Changes

下面的常式列出能通過這套分析存活下來的頁面內容編輯。prckPageContent 變更永遠不會被評成 prdAllowed:DocMDP P=1、2 或 3 都讓它成為 prdDisallowed,無 DocMDP 的簽章則評 prdSuspicious

uses
  SysUtils, TypInfo, PDFium, FPdfPades;

procedure ListPageContentEdits(const FileName: string);
const
  ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  Sig: TPadesSignatureRevisionAnalysis;
  Change: TPadesRevisionObjectChange;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;   // 是 record,沒有東西要釋放
    for i := 0 to High(Report.Signatures) do
    begin
      Sig := Report.Signatures[i];
      if not (prrPageContentChanged in Sig.Risks) then
        Continue;
      Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
        [Sig.SignatureIndex, Sig.DocMdpPermission]));
      for j := 0 to High(Sig.Changes) do
      begin
        Change := Sig.Changes[j];
        if Change.Kind <> prckPageContent then
          Continue;
        Writeln(Format('  revision %d  object %d %d R  %s%s',
          [Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
           GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
           ShadowTag[Change.IsAuthoritative]]));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

FieldMDP 與註解共用一個物件時會怎樣?

欄位的 /V 與註解的 /Contents 指向同一個間接物件時,v3.126.2 照樣維持 FieldMDP 鎖的效力,即使該變更被歸類為註解編輯。這個場景手工就能拼出來:簽署者用 FieldMDP 鎖住 Total 欄位(ISO 32000-1 §12.8.2.4),攻擊者讓某個文字註解的 /Contents 參照承載欄位值的那個字串物件。P=3 下註解編輯是允許的,所以修正之前,改寫那個共享字串就能以允許判決改掉被鎖的欄位值

如今該物件同時帶註解與表單兩種角色,只要簽章帶 FieldMDP 轉換,註解判決就會重查表單那一側:

  • P=2 下註解變更直接不允許,與以往完全相同
  • FieldMDP 為 All 時所有欄位都鎖,共享變更於是 prdDisallowed
  • FieldMDP 為 Include 或 Exclude 時,分析器無法把共享的純量回溯到單一欄位名,判決是 prdIndeterminate,不替您猜
  • 沒有 FieldMDP 時套 P=3 註解規則,變更維持允許
PDFium Component 的 FieldMDP 判決示意圖:被鎖的 Total 欄位 /V 與註解 /Contents 參照同一個間接物件,按 DocMDP P=2、FieldMDP All、FieldMDP Include 或 Exclude、無 FieldMDP 四種情況分支,同一個共享編輯分別得到 prdDisallowed、prdIndeterminate 或 prdAllowed
同一個間接物件身兼註解與表單兩種角色時,註解判決會重查 FieldMDP 鎖,同一個編輯於是可以從允許、不允許一路排到無法判定

對閘門程式碼來說,有一個回報細節很要緊。共享案例回報為 Kind = prckAnnotation 配 Decision = prdIndeterminate,而 prrFieldMdpUnresolved 只對歸類為表單欄位的變更加進風險集合。只搜 prrFieldMdpUnresolved、無視 Status 的閘門,會把這個案例整個漏掉

Delphi 程式碼該怎麼在修訂分析上 fail closed?

Delphi 程式碼應該只在分析狀態為 prasNoLaterChanges 或 prasAllowed、且無結構性風險時接受已簽章文件,並把 prasIndeterminate 與 prasSuspicious 當成不受信任,而不是記條日誌就放行。Indeterminate 的意思是分析器無法證明後續修訂獲得允許;對攻擊者而言,若您的程式碼放行,穩定產出 Indeterminate 的輸入跟產出 Allowed 的一樣好用。全域函式 AnalyzePadesSignatureRevisions 收任何 TStream、從位置 0 讀起,正好適合不必渲染文件的檔案上傳處理器

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // 重複定義會被記錄,但不會把 Status 降級
  BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
    prrResourceLimitExceeded];

function SignedRevisionsAcceptable(const FileName: string;
  out Reason: string): Boolean;
var
  Source: TFileStream;
  Report: TPadesRevisionAnalysisReport;
begin
  Result := False;
  Reason := '';
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Report := AnalyzePadesSignatureRevisions(Source);
  finally
    Source.Free;
  end;
  if Report.SignatureCount = 0 then
  begin
    Reason := 'no signature anchors the analysis';
    Exit;
  end;
  if Report.Risks * BlockingRisks <> [] then
  begin
    Reason := 'structural risk in the revision chain';
    Exit;
  end;
  case Report.Status of
    prasNoLaterChanges, prasAllowed:
      Result := True;
  else
    // prasIndeterminate 與 prasSuspicious 是拒絕,不是警告
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

有兩條邊界值得說明白。TPadesRevisionAnalysisReport 對 CMS 完整性或憑證信任隻字未提,所以這道閘門與密碼學及信任驗證並列,不能取而代之。正確的所有權圖也不會讓 P=3 對所有工作流程都安全。P=3 確實允許註解,而一個帶不透明外觀的註解,可以一條內容串流都不碰就蓋住已簽章的文字。您的認證文件若是合約而非審閱稿,要嘛用 P=2 認證,要嘛把允許的註解變更導向人工覆核,就像這個輔助函式:

function AllowedAnnotationEditsUnderP3(
  const Report: TPadesRevisionAnalysisReport): Integer;
var
  i, j: Integer;
begin
  Result := 0;
  for i := 0 to High(Report.Signatures) do
    if Report.Signatures[i].DocMdpPermission = 3 then
      for j := 0 to High(Report.Signatures[i].Changes) do
        if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
           (Report.Signatures[i].Changes[j].Decision = prdAllowed) then
          Inc(Result);
end;

簽章修訂稽核清單

用這份清單檢查您的驗證管線曾經暴露與否、現在是否 fail closed:

  • v3.126.2 之前的 PDFium Component 建置,可能對 DocMDP P=3 文件的頁面內容編輯回報 prasAllowed;對舊建置放行過的認證 P=3 檔案,重跑一次 TPdf.AnalyzeSignatureRevisions
  • 重新檢查帶 FieldMDP 鎖的 P=3 文件,留意欄位值與註解可能共享間接物件的地方
  • 只接受 prasNoLaterChanges 與 prasAllowed;把 prasIndeterminate 與 prasSuspicious 當成不受信任
  • Report.Risks 與 Report.Status 都要測,因為 prrDuplicateObjectDefinition 單靠自己不改變狀態
  • 簽章狀態為 Indeterminate 時,別把空的 Changes 陣列讀成乾淨結果——角色建構失敗根本不回報任何變更
  • 別只靠 prrFieldMdpUnresolved 抓 FieldMDP 問題——共享註解案例只會透過判決與狀態現形
  • 決定 P=3 下允許的註解變更,在您的工作流程裡是否需要人工覆核
  • 分析原始檔案位元組;被 SaveAs 改寫過的文件已不含修訂鏈

修訂分析只是簽章檢查的一層。字典與 baseline level 交給檢視 PDF 數位簽章與 PAdES 層級;JavaScript、launch action 與嵌入式檔案交給範圍更廣的PDF 安全風險稽核。TPdf.AnalyzeSignatureRevisions、AnalyzePadesSignatureRevisions 與本文所述的所有權感知角色圖,都隨支援 Delphi、C++Builder 與 Lazarus 的 PDFium Component 出貨