在 3.114.8 版之前,PDFiumPas 在非 Windows 目標上用執行階段函式庫的 Random 函式產生 PDF 加密金鑰材料,而因為沒有任何東西呼叫 Randomize,每個處理程序都產出同一位元組序列。於是 Linux 與 macOS 上的 Free Pascal 建置,跑一次寫一次完全相同的檔案加密金鑰、salt、CBC IV 與 AES-GCM nonce 前綴。3.114.8 改讀 /dev/urandom,讀不到就丟例外
缺陷本身是個四行迴圈。更有用的教訓是:一套加密又解密數百份文件、涵蓋 AESV3 與 AESV4、帶與不帶 PDF MAC 的測試套件,為什麼從頭到尾都是綠的。每個處理程序內固定的亂數,對所有跑在單一處理程序裡的測試都隱形,而加密測試通常正是這麼寫的
PDFiumPas 在哪裡需要亂數位元組?
PDFiumPas 加密堆疊裡的每個亂數位元組都來自單一程序——FPdfAes 單元裡的 AesGenerateRandomBytes——所以一個壞來源會污染全部。ISO 32000-2 §7.6.4 的標準安全處理器與 ISO/TS 32003 的 AESV4 延伸在這些地方消費這些位元組:
- 32 位元組的檔案加密金鑰,由 DeriveEncryptionKeys 為每份文件現場產生,再用密碼衍生的金鑰包進 /UE 與 /OE
- 兩個 16 位元組的 salt,一個存在 /U 的最後 16 位元組、一個存在 /O 的最後 16 位元組,各自再拆成 8 位元組的 validation salt 與 8 位元組的 key salt
- /Perms 背後明文的第 12 到 15 位元組,ISO 32000-2 在該區塊用檔案金鑰加密之前,以亂數資料填入
- AESV3 文件裡每個加密字串與串流前面加掛的 16 位元組 CBC IV
- AESV4 文件的 8 位元組 nonce 前綴,後面跟著一個從零起算的 4 位元組逐物件計數器
- 設起 EnableIntegrityProtection 時的 32 位元組 /KDFSalt 與 MAC 金鑰
為什麼每個處理程序產出同一把金鑰?
AesGenerateRandomBytes 只在 Windows 上用作業系統產生器;其他地方它用 RTL 偽亂數產生器填滿緩衝區,而那個產生器在程式不呼叫 Randomize 時從 RandSeed = 0 起步。迴圈上面的註解說種子來自 GetTickCount64。從來沒有一行程式碼做過這件事,於是那句註解成了種子唯一存在的地方:
// 3.114.8 之前 AesGenerateRandomBytes 的非 Windows 分支
//(它上面的註解承諾了 GetTickCount64 種子,卻從未兌現)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
序列在每個處理程序裡重新起步、並在程序內前進,所以任何處理程序加密的第一份文件,與其他每個跑同一建置的處理程序的第一份文件共享檔案金鑰,第二份與第二份共享,以此類推。R5、R6 與 R7 的檔案金鑰根本不依賴密碼,密碼只是把它包起來,這意味著任何能重現序列的人不必知道密碼就握有金鑰。AESV4 又添一層失敗:同一把金鑰配上相同的 8 位元組前綴、計數器又從零重來,GCM nonce 就會重複,而 NIST SP 800-38D §8 明文禁止這件事。同一把金鑰下重複的 GCM nonce 會洩漏兩段明文的 XOR,並暴露認證子金鑰,AESV4-GCM 加密與 PDF MAC token 所依賴的標籤就此失去意義。機密性與完整性一起陪葬
影響範圍比上面那段聽起來窄。Windows 建置從未受影響,因為 Windows 分支向來透過 advapi32 以 CRYPT_VERIFYCONTEXT 呼叫 CryptGenRandom,失敗就丟例外。暴露的是 3.114.8 之前非 Windows 建置的輸出,實務上就是 Linux 與 macOS 上的 Lazarus 與 Free Pascal 應用程式——PDFium 建置裡 Delphi 與 FPC 的差異陷阱清單再添一筆
為什麼呼叫 Randomize 從來就不是正確的修法?
呼叫 Randomize 只會把症狀藏起來,不會修好來源,因為 RandSeed 是 32 位元值,而 Randomize 用時鐘衍生它。這把可能的金鑰串流上限壓到 2^32,而粗略知道檔案什麼時候寫的,就能把搜尋空間削到遠低於此——比起 256 位元的 AES 金鑰,什麼都不是。金鑰材料必須來自核心的熵池,所以 3.114.8 的 AesGenerateRandomBytes 讀 /dev/urandom、對短讀取做迴圈,池子湊不出要求的每一個位元組就丟例外:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // 失敗或串流意外結束
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
拒絕是刻意的,與 Windows 分支在 CryptGenRandom 不可用時的既有一貫行為一致。加密存檔失敗是您當天就會注意到的事故;帶著可預測金鑰的成功存檔,是您從別人嘴裡聽來的事故。接著有兩個實際後果。一個沒掛好 /dev 的最小容器或 chroot,現在會讓加密失敗而不是悄悄降級,所以請把它掛上。而因為例外會在目標檔案以 fmCreate 開啟之後從 TPdf.SaveAsEncrypted 一路傳出去,現場會留下一個空輸出檔,交給您的錯誤處理常式去刪
為什麼 round trip 測試從來抓不到它?
round trip 測試看不見固定的亂數,因為解密會原樣取回加密當時選的檔案金鑰。測試加密一份文件、用密碼重新打開、從 /UE 解開金鑰、解密每個物件;一把可預測的金鑰解開與解密起來跟一把隨機的一樣好,GCM 標籤也驗得過,因為它們就是用那把金鑰算的。甚至是加密兩次、斷言兩個輸出不同的測試也會過,因為同一處理程序裡的第二次呼叫取的是序列的下一段位元組。真正要緊的性質——每個處理程序一把不同的金鑰——只有跨處理程序比較輸出才觀察得到。每當同一段程式碼既生產又消費一個值,測試就對整類缺陷失明,而亂數是最純粹的例子
怎麼跨處理程序測金鑰亂度?
在目標平台上以兩個獨立處理程序各跑一次小 probe,再比較輸出。下面的 probe 呼叫 DeriveEncryptionKeys,印出 /U 項目第 32 到 47 位元組裡存的 salt。這個值本來就明文寫進每份加密檔案,所以印在 CI 記錄裡什麼也不洩漏,而它與檔案金鑰出自同一個產生器:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = 32 位元組雜湊 + 16 位元組 salt
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // 每次執行都必須不同
end.
把 probe 接進每個非 Windows 目標的建置:跑兩次,兩行相同就讓工作失敗。同樣的比較也適用於已經在市面上流通的檔案。拿同一個應用程式不同兩次執行寫出的兩份加密 PDF,從它們的 Encrypt 字典讀出 /U 字串,比較最後 16 位元組;相同的 salt 就指出受影響的建置,而這些文件應該用 3.114.8 或更新版本從明文重新加密,讓每份拿到一把新的檔案金鑰。通則是:非 Windows 的程式碼路徑要在那個平台上實際跑過,而不是相信 Windows 那一輪——非 Windows 建置的 libcurl 時間戳記後端背後正是同一套推理
PDFiumPas 是建構在 PDFium 引擎上的 Delphi 與 Lazarus PDF 元件,AES-256、AES-GCM 與 PDF MAC token 都以原生 Pascal 實作,金鑰材料在每個平台都取自作業系統產生器。詳情與下載見 PDFium Delphi 元件頁面