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
分析器在給後續變更評級之前,先為每個物件指派一組角色位元:頁面、註解、表單與驗章素材。根物件的角色取自它自己的字典,角色再向外傳染到它參照的一切。舊傳播裡,這條鏈是這麼走的:
- 簽章 widget 是帶
/FT /Sig的/Subtype /Widget,拿到註解角色 - widget 的
/P把註解角色推上頁面字典,而頁面字典本來就有頁面角色 - 頁面把兩種角色推進
/Contents、/Resources,並經/Parent一路推上 Pages 樹、橫向傳到每個兄弟頁面 << /Length 812 >>這類內容串流字典沒有/Type,分類器於是退回角色位元,而且註解角色先於頁面角色被檢查
被改的內容串流於是被標成 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、/Annots | ISO 32000-1 §7.7.3 |
| 註解或 widget | /P | ISO 32000-1 §12.5.2 |
| widget 或欄位字典 | /Parent | ISO 32000-1 §12.7.3 |
全域按鍵名過濾會挖出新洞。字型或 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 註解規則,變更維持允許
對閘門程式碼來說,有一個回報細節很要緊。共享案例回報為 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 出貨