HotPDF 這個 Delphi PDF 元件現在會拒絕簽章包裹攻擊:從 v2.759.0 起,VerifyLoadedSignatureEx 與批次驗證器都要求兩段 /ByteRange 之間的間隙恰好是 /Contents 的 hex 字串、連同兩個分隔符;v2.761.0 加入 AddLoadedSignedSignatureField,讓第二枚簽章能以一次乾淨的增量修訂追加到已簽署的 PDF 上。這兩個改動是一對:一枚正確的第二簽章,恰恰就是更嚴格的驗證器所期待的版面
暴露問題的場景再平常不過。合約由供應商簽署,接著流轉到一位必須會簽、又不能動到第一枚簽章的核可人。第二個修訂追加在第一個之後,它自己的 /ByteRange 橫跨長大後的整個檔案,兩枚簽章理應都驗得過。徒手做到這一步,意味著自己寫一段增量區,而測試治具裡正是這麼做的那份檔案,結果是一個教科書級的簽章包裹結構——舊驗證器欣然接受。如果您還沒看過驗證 API,用 HotPDF 驗證 PDF 數位簽章的指南講了本文賴以展開的基礎
ByteRange 間隙裡到底該放什麼?
間隙必須包含完整的 /Contents 值,別無他物:ISO 32000-1 §12.8.3.3 說 hex 字串連同 < 與 > 分隔符,恰好塞進兩個位元組區段之間的空間,ISO 32000-2 §12.8.1 沿用同一條規則。表 252 與 PAdES 文件只說摘要排除 Contents 值——很容易讀成只排除 hex 數字。早期 HotPDF 就是這麼讀的:PreparePDFForSigning 與串流式 CMS 準備把角括號也算了進去,原始碼註解還堅持括號必須被覆蓋。把間隙與簽章值對比的驗證器,會把那種版面標成無效位元組區間,所以 v2.759.0 把兩個分隔符移出簽署區。在任何已簽署檔案上做個快速獨立檢查,看兩個位元組就夠:偏移 ByteRange[1] 的位元組必須是 <,偏移 ByteRange[2] - 1 的位元組必須是 >
為什麼「間隙非空」檢查攔不住簽章包裹?
間隙非空的檢查只能證明「有東西被排除在摘要之外」,不能證明是什麼——而這正是全部的攻擊面。/Contents 佔位符以數千個零數字保留,真實的 CMS 容器很少把它填滿。攻擊者可以用一個 > 在那段零填充裡提前收掉 hex 字串,把新物件或偽造修訂寫進保留空間的其餘部分,位元組區段原封不動。CMS 簽章照樣驗得過,因為每個被簽的位元組都沒變;區段照樣從 0 起到檔案大小結尾;舊 HotPDF 驗證器回報 svValid,CoversWholeDocument 設為 True。而 PDF 閱讀器呢,會解析那個無簽章洞裡的任何東西
HotPDF 現在把間隙當成要逐位元組驗證的資料。驗證器讀出間隙、剝掉分隔符、只接受 hex 數字加 PDF 空白(tab、換行、換頁、回車、空格),解碼數字並要求結果與簽章字典的 /Contents 完全相等。任何其他情況把結果降級為 svInvalidByteRange。這個檢查在單一簽章路徑與 ValidateLoadedSignatureBatch 都會跑——後者保留著自己的覆蓋邏輯、需要同一個修復。v2.759.0 之前由 HotPDF 產生、間隙裡只有數字而括號恰好落在區間內側的檔案,照樣驗得過,歸檔文件不會一夜變紅
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
怎麼給已簽署的 PDF 加第二枚簽章?
用 BeginIncrementalUpdate 打開已簽署檔案,呼叫 AddLoadedSignedSignatureField,以 SaveIncrementalUpdate 存檔,再用類別函式 THotPDF.SignPDFWithPFX 簽署準備好的檔案。v2.761.0 之前,文件裡那套「BeginIncrementalUpdate 之後呼叫 THPDFPage.AddSignedSignatureField」的配方根本行不通,因為增量模式下 CurrentPage 是 nil,沒有任何東西能把 /V 佔位符掛到已載入文件的欄位上。新方法在已載入頁面上建立 widget,並把新文件路徑用的那個佔位符字典掛在 /V 之下,兩條簽署路徑共用同一套序列化。第一枚簽章本身,在 Delphi 建立 PAdES 數位簽章的文章走完了 PFX 管線
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// 第 0 頁,widget 矩形以點計,為 CMS 保留 8192 位元組
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField 刻意比同族安靜。其他 AddLoaded* 欄位建立器會在 AcroForm 上設 /NeedAppearances true,告訴檢視器重生欄位外觀;在已簽署文件上,那次重生可能改寫已簽署內容,所以新方法除非來源本來就帶著這個旗標,否則會把它再移除。/SigFlags 維持原值 OR 3(SignaturesExist 加 AppendOnly,ISO 32000-1 表 219)。您也不必對頁面呼叫 MarkDirty:往 /Annots 與 /Fields 加東西會把 dirty 旗標傳給所屬間接物件,顯式的頁面標記只會把一個沒變過的頁面字典拖進新修訂,修訂分析接著把它回報成頁面修改。最後,佔位符把 /ByteRange 寫在 /Contents 之前,因為修補器先定位哨兵 /ByteRange、再向前搜尋配對的 hex 字串
外部簽署器或 HSM 產出 CMS 時有什麼變化?
工作流不變,但偏移量從此符合規格書的字面意思。PreparePDFForSigning 回傳兩個 0 起算的區段,其間隙是完整的 /Contents 字串,ContentsHexStart 是 AnsiString 裡第一個 hex 數字的 1 起算索引。較短的 CMS 以 0 在收尾的 > 之前補滿。因為 PreparePDFForSigning 修補它找到的第一個未修補哨兵,每次修訂只準備一個佔位符;檔案裡已有更早的簽章時,優先用帶回傳偏移的 InsertSignatureHexAt,而不是靠搜尋的 InsertSignatureHex
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // 您自己的輔助函式
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// 間隙是完整的 hex 字串:'<' 收掉區段 1,'>' 在區段 2 之前
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // 您的 CMS 簽署器,hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // 您自己的輔助函式
end;
新檢查的極限在哪裡?
間隙檢查封住一個具體的洞,不該被過度吹捧。svValid 仍然只意味位元組完整性加一把與內嵌憑證匹配的金鑰;對那張憑證的信不信任是另一個決定。間隙只有在驗證器握有原始位元組時才驗證——VerifyLoadedSignatureEx 從已載入檔案讀取,TStream 重載由您提供。會簽檔案裡的第一枚簽章,CoversWholeDocument 正確地為 False,追加的修訂是只加了簽章、還是也動了頁面,是HotPDF 的 DocMDP、FieldMDP 與修訂分析要回答的問題。另外注意,附加的 PDF MAC 檢查拿偏移與 <、> 位置對比,新舊版面都收;您自己任何寫死了 v2.759.0 之前偏移的工具,一遇上新簽的檔案就會先出錯
如果您的 Delphi 或 C++Builder 應用程式要簽署、會簽或稽核 PDF,最穩的路是讓同一個函式庫產生並驗證同一種版面。HotPDF 這個原生 Delphi PDF 元件把更嚴格的間隙驗證、增量第二簽章與上面展示的外部簽署器掛鉤,裝在同一個元件裡出貨