vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

AES-256 的檔案金鑰有 32 個位元組,MuPDF 1.28.5 之前產生的金鑰卻只有 512 種可能:時間戳每差 512 秒,輸出的位元組流就一模一樣。未最佳化的純 Python 把 512 種全試完,文章說約 11 毫秒。

九個位元的 AES-256 金鑰

Rick de Jager 在 V12 發表於 10 月 27 日的文章,把檢查 PDF 的 AES-256 加密的位置,從密碼移到另一個地方:檔案金鑰是怎麼產生的。他看了 MuPDF、Foxit PDF Reader、OpenPDF 與 iText,發現這些實作產生金鑰時用的亂數來源,有的狀態空間小到能整個列舉,有的雖然變化多一些,仍然是可以猜的時間值。以下依序拆開三組實作的機制,最後談攻擊需要什麼,以及維護 PDF 工具鏈的人能做什麼。

加密的是檔案金鑰,不是密碼

依文章的說明,PDF 用 AES-256 加密時,密碼並不直接加密文件:「When a PDF is encrypted with AES-256, the password does not encrypt the document directly.」實作會產生一把隨機的 32 位元組檔案金鑰,內容用這把金鑰加密。密碼加上 salt 另外導出一把金鑰,這把金鑰負責加密檔案金鑰。檔案裡因此存著兩樣東西,一是加密過的檔案金鑰,一是用檔案金鑰加密的內容,要讀內容就得先拿到檔案金鑰。

密碼雜湊的強度因修訂版而異。文章寫道,現行的修訂版 6 使用 AES-256,並且把密碼雜湊刻意放慢,較舊的 AES-256 修訂版 5 沒有這一步。放慢雜湊的用意,是讓猜密碼的人每猜一次都付出成本。這個成本只落在「從密碼走到檔案金鑰」的那一段路上,檔案金鑰本身怎麼產生,它管不到。

PDF 可以同時設使用者密碼(用來檢視)與擁有者密碼(用來改變權限)。文章說兩者都能解開同一把檔案金鑰,所以權限限制最後取決於閱讀器自己遵不遵守。這個設計帶來一個直接後果:只要檔案金鑰能在不知道密碼的情況下被算出來,使用者密碼、擁有者密碼,連同修訂版 6 的慢雜湊,全部被繞過,攻擊者拿到的是同一把鑰匙。後面三個案例攻擊的都是這把鑰匙。

MuPDF:48 位元的狀態,只有 9 位元活著

MuPDF 在 1.28.5 之前,用 drand48 家族的線性同餘產生器(LCG)產生金鑰位元組。依文章列出的參數,乘數是 0x5deece66d,增量是 0xb,模數是 2 的 48 次方,所以內部狀態一共 48 位元。種子只設定一次,取自目前的 epoch 秒數。狀態由三個 16 位元的字組成,最低的字固定為常數 0x330e,文章寫的是「and the 32-bit timestamp occupies state bits 16 to 47」,32 位元的時間戳佔據狀態的第 16 到第 47 位元。

關鍵在輸出怎麼取。文章的說法是「Every output byte therefore comes from bits 17 to 24 of the LCG state.」每個輸出位元組只取狀態的第 17 到第 24 位元,剛好 8 位元,其餘 40 位元不直接出現在輸出裡。下圖把 48 位元的狀態攤開,標出時間戳落在哪裡、輸出視窗在哪裡。

MuPDF 的 48 位元 LCG 狀態(依文章描述,第 47 位元在左) 時間戳 32 位元,放進狀態的第 16 到 47 位元 時間戳第 9 到 31 位元(23 位元)無效 時間戳第 0 到 8 位元 LCG 狀態 48 位元 第 47 到 25 位元:碰不到輸出視窗 輸出 8 位元 固定 0x330e,16 位元 47 25 24 17 16 15 0 9 位元:第 16 到 24 位元,是金鑰唯一的變化 時間戳第 0 位元對到狀態第 16 位元,時間戳第 9 位元對到狀態第 25 位元,已在視窗之外。 第 16 位元本身不在輸出視窗內,但乘加運算的進位會把它帶進第 17 位元以上。

乘法與加法有一個性質:結果的某一個位元,只受輸入中同位與更低位的位元影響,進位只往高位傳。所以輸出視窗(第 17 到 24 位元)每一步的值,只取決於種子的第 0 到第 24 位元,第 25 位元以上不論怎麼變,都碰不到視窗。狀態的低 25 位元因此自成一個模數為 2 的 25 次方的小型 LCG,輸出視窗整個坐在裡面。48 位元的狀態並沒有讓輸出多出不確定性,真正運轉的只有 25 位元,其中 16 位元還被常數佔掉。這是一般的 LCG 算術,是我自己的說明,不是文章的原話。

種子的第 0 到第 15 位元是固定常數 0x330e,沒有任何不確定性。時間戳從第 16 位元開始,能影響視窗的只有第 16 到第 24 位元,一共 9 位元。文章的原句是「so only nine bits from the timestamp matter」,只有 9 個時間戳位元有關。視窗明明是 8 位元,為什麼是 9?因為第 16 位元雖然在視窗之外,運算的進位會把它的影響推進第 17 位元,視窗因此還是看得到它。

9 位元就是 512 種。文章接著寫:「Timestamps that differ by 512 seconds produce exactly the same byte stream.」32 位元的時間戳因此分成 512 個等價類,同一類包含所有除以 512 餘數相同的秒數。不論檔案在哪一秒產生,它的金鑰必定落在這 512 條位元組流的其中一條。下面的小工具讓你自己拖動時間差,看兩條位元組流什麼時候重合。

拖動時間差,比對兩條位元組流 · 0 到 1100 秒

512 秒

時間戳 T

時間戳 T + d

依文章給的乘數、增量、模數、常數 0x330e 與第 17 到 24 位元的取位規則做示意計算,T 是任取的 1700000000。文章沒有說明金鑰從第幾個輸出位元組開始取,所以這裡的位元組不保證與 MuPDF 的實際輸出逐一吻合,但等價類的結論不依賴這些細節。

依文章給的乘數、增量、模數、常數 0x330e 與第 17 到 24 位元的取位規則做示意計算,T 是任取的 1700…

時間戳相差 512 秒的整數倍時,兩條位元組流完全相同,32 位元的時間戳實際上只剩 9 個位元決定金鑰。

拖到 512 與 1024,兩列的每個位元組都吻合;拖到 511,兩列幾乎全部不同。原因在算術:兩個種子只在第 25 位元以上不同,差值是 2 的 25 次方的倍數,經過乘加之後差值仍是這個倍數,第 24 位元以下永遠一樣,視窗裡的值每一步都相同。

攻擊於是很短:列舉 512 條位元組流,各自導出一把 32 位元組的候選金鑰,拿去試解密。文章還指出,MuPDF 用同一個產生器產生文件的 /ID,而 /ID 以明文存放在 trailer 裡,文章的原句是「stored in the trailer in plaintext」,所以它可以當作額外的確認依據。文章給的耗時是「takes about 11 milliseconds」,約 11 毫秒,用的是未最佳化的純 Python。文章還以一份時間戳除以 512 餘 80 的測試文件示範,還原出的金鑰不需要密碼就打開了文件。

等價類還有一個含意。同一類的兩個時間戳產生同一條位元組流,若金鑰是取自流上同一段位置,這兩份文件的檔案金鑰就會相同。文章沒有明說金鑰取自哪一段,所以這只是推測。可以確定的是,搜尋空間不隨文件數量或時間跨度增長:不管對方手上有一份檔案還是一萬份,要試的永遠是同樣的 512 條流。

// 示意流程,依文章描述整理,不是 V12 的程式碼
對 k 從 0 到 511 逐一處理
    由等價類 k 的任一時間戳產生位元組流
    由這條位元組流導出候選金鑰
    若候選金鑰能解開檔案,或重現出明文的 /ID,就回傳金鑰

Foxit:雜湊裡沒有祕密

Foxit PDF Reader 2026.1.3.36551 的做法完全不同,沒有 LCG,也沒有摺疊。文章說它把檔案金鑰導出為「目前時間戳、32 個未初始化位元組與一個常數」的雜湊,具體算式在文章的程式碼區塊裡,是 SHA-256 加上 32 位元小端序的 time(NULL)。那 32 個位元組雖然標為未初始化,文章寫的是「In practice these uninitialized bytes are zero filled and do not add any randomness.」實際上全是零,沒有貢獻任何隨機性。常數字串是 there。

「未初始化」和「全是零」在密碼學上差很多。未初始化記憶體的內容原則上難以預期,寫程式的人有可能把它當成額外的隨機來源,這是我的推測,文章沒有討論作者的意圖。但文章已經說明實際情況:這 32 個位元組是常數,等於整段輸入裡只有時間戳不固定。雜湊函式本身沒有問題,SHA-256 把輸入打散得很徹底,可是打散一個只有 32 位元變化的輸入,輸出仍然只有 2 的 32 次方種可能。

Foxit PDF Reader 2026.1.3.36551 的檔案金鑰導出 uint32_le(time(NULL)) 4 位元組,唯一的未知數 32 個未初始化位元組 實務上全是零,無隨機性 "there" 常數字串 SHA-256 檔案金鑰(32 位元組) /Perms 區塊 含已知明文 逐個猜時間戳、重算雜湊,用 /Perms 的已知明文判斷猜對了沒有

結果是檔案金鑰的全部不確定性,只剩那一個 32 位元的時間戳。和 MuPDF 不同,這裡沒有摺疊成 512 種的結構,每一秒導出的金鑰都不一樣,攻擊者得把時間戳一個一個試。驗證的步驟很便宜:文章說 /Perms 權限區塊含有已知明文,攻擊者算出一個候選金鑰的雜湊,用它解一個 AES 區塊,看結果是否出現已知的魔術值就夠了,不需要密碼雜湊,也不需要解開整份文件。

文章給的吞吐量是「it sustains 370 to 376 million timestamps per second」,機器是 Ryzen 9 7950X,32 執行緒的 Rust 實作。表格列了兩種搜尋範圍:往回十年共 315,573,571 個候選,約 0.84 秒;從 Unix epoch 的 1970 年到現在共 1,792,610,110 個候選,約 4.85 秒。至於比較近期產生的文件,文章寫的是「in the order of milliseconds」,毫秒量級。搜尋範圍從 1970 年至今縮到最近一段時間,工作量就等比例縮小。

需要試的候選時間戳數量(對數軸) MuPDF 512 個候選,純 Python 約 11 ms Foxit 近十年 315,573,571 個候選,0.84 s(32 執行緒) Foxit 1970 至今 1,792,610,110 個候選,4.85 s(32 執行緒) 10² 10⁴ 10⁶ 10⁸ 10¹⁰ 兩種硬體與語言不同,耗時不宜互相換算

照文章的吞吐量推算,把整個 32 位元的時間戳空間,也就是 4,294,967,296 個值,全部試完,在那台機器上大約需要 11 到 12 秒。所以就算對方完全不知道文件是哪一年產生的,上限也是這個量級。

512 個與十七億多個,兩者相除約三百五十萬倍(我的算術),但結論相同:以現代硬體窮舉,毫無困難。兩個耗時數字來自不同的程式語言與機器,圖上只用長條長度比較候選數量,不比較耗時。

文章還追了這個模式的出處:「It was already present in PDFium’s initial public commit from May 2014.」它在 PDFium 2014 年 5 月的初始公開 commit 裡就已經存在。據文章,該檔案的標頭註明原始碼來自 Foxit,其他編輯器可能經由 PDFium 繼承了同樣的模式——這是文章的推論方向。哪些產品受影響,文章沒有列出名單,我也沒有逐一核對。

OpenPDF 與 iText:較難猜,但仍然是猜

OpenPDF 3.0.5 與 iText 9.7.1 走第三條路。文章寫的是「an ARC4 stream seeded with a millisecond timestamp and the JVM’s free memory」:一條 ARC4 串流,種子是毫秒時間戳加上 JVM 的可用記憶體,兩個值用加號串起來。

種子由兩段輸入組成:毫秒時間戳,以及 JVM 的可用記憶體,兩者用加號相連。依文章的整理,同一條串流既產生檔案金鑰,也產生存在 /U 與 /O 裡的 32 位元組 salt,而這些 salt 以明文存放。攻擊者拿到檔案,就握有這條串流的一段已知輸出,等於有一個內建的檢驗點:猜一組時間戳與可用記憶體,重現串流,對照檔案裡的明文 salt,吻合就代表猜對,接著能還原金鑰。不需要先解密任何東西,每一次猜測都能立刻判定對錯。

這裡的狀態空間比前兩者大:毫秒時間戳的刻度是秒的一千倍,還要再猜 JVM 的可用記憶體。我的讀法是,變異度比 MuPDF 與 Foxit 多,但 256 位元的安全性仍然被削弱。推測起來,兩個值都落在有限而且可以收斂的範圍裡,加上明文 salt 讓猜錯的成本只剩算力。文章沒有給這組攻擊的耗時數字,我也不替它補一個。

把 MuPDF 和這組實作並排,有一個共通的設計後果。MuPDF 的文件 /ID 和檔案金鑰出自同一個產生器,OpenPDF 與 iText 的明文 salt 和檔案金鑰出自同一條 ARC4 串流。檔案裡公開的欄位,因此是祕密金鑰所屬序列的另一段輸出。依文章的描述,攻擊者不必先破解任何東西,只要重現序列,就能同時對上公開欄位與藏在旁邊的金鑰。這也說明了為什麼這類弱點一旦被找到,驗證幾乎不花成本。

三組實作各自讓攻擊者猜什麼、拿什麼驗證,整理自 V12 的文章
實作與版本 金鑰的來源 攻擊者要猜的 檔案裡的驗證依據
MuPDF,1.28.5 之前 drand48 家族 LCG,種子為 time(NULL) 秒數 時間戳的 9 個有效位元,512 種 試解密,明文 /ID 輔助確認
Foxit PDF Reader 2026.1.3.36551 SHA-256 作用於小端時間戳、32 個零位元組與 there 32 位元時間戳 /Perms 的已知明文,解一個 AES 區塊
OpenPDF 3.0.5、iText 9.7.1 ARC4 串流,種子為毫秒時間戳加 JVM 可用記憶體 毫秒時間戳與可用記憶體兩個值 /U 與 /O 裡的明文 salt,吻合即可還原金鑰

攻擊者需要什麼,防守的人能做什麼

三種攻擊需要的東西相同:拿到加密後的 PDF 檔案。/ID、/Perms、/U、/O 這些用來驗證猜測的值都以明文存在檔案裡。攻擊的對象是檔案金鑰,所以文章描述的流程裡不需要知道密碼。這也是修訂版 6 的慢雜湊救不了這些檔案的原因:慢雜湊守住的是密碼到金鑰的那一段,攻擊者從金鑰產生器的另一端進來,直接猜檔案金鑰。

把三個案例放在一起看,結構其實一樣:金鑰的唯一來源是一個時間值,差別只在有多少位元活下來,以及驗證一次猜測要花多少力氣。MuPDF 把 32 位元的時間戳截成 9 位元,Foxit 保留完整的 32 位元,OpenPDF 與 iText 換成毫秒再加上 JVM 的可用記憶體。驗證的成本都很低,一個明文的 /ID、一個已知明文的 /Perms 區塊、一組明文 salt,各自讓猜測可以立刻判定對錯。評估一個金鑰產生器,先數種子裡有多少位元真的會走到輸出,再看檔案裡有沒有東西讓對方能當場驗證。

修補狀態要看文章的原句:「These issues were disclosed to the vendors and, in MuPDF’s case, fixed.」問題已通報廠商,只有 MuPDF 被明說已修。文章的說法是 1.28.5 之前的版本使用這個產生器,我據此推測 1.28.5 起已修,但文章沒有明寫修補版本。Foxit、OpenPDF 與 iText 的修補狀況,文章沒有交代,我也沒有其他來源可查。更麻煩的是下一句:「Previously published copies cannot be retroactively protected, however, so these bugs may still have an impact.」已經發出去的檔案,無法事後補保護。

無法事後保護的原因在檔案本身。檔案金鑰與用來驗證的明文欄位,在檔案產生的那一刻就已經定下來並寫進檔案,之後修補產生器,只會改變新產生的檔案。已經寄出、存檔或上傳的舊副本,金鑰仍然出自舊產生器,攻擊者只要拿到其中一份就能下手。

文章本身沒有列出建議。以下是我依文章事實做的推論,不是 V12 的主張。

  • MuPDF 方面,文章指出 1.28.5 之前的版本受影響且 MuPDF 已修,升級只管得到升級之後產生的檔案。
  • 用上表的產品與版本產生、而且現在還需要保密的 AES-256 PDF,都該當成金鑰可被還原。需要的話用修補後的工具重新加密,並假設舊副本可能已經流出去。
  • 盤點自己的 PDF 工具鏈。文章指出 Foxit 的模式在 PDFium 初始 commit 就有,所以依賴 PDFium 的工具,值得向廠商確認修補狀況。
  • 自己的程式碼要產生金鑰時,別用時間戳或 LCG,用密碼學安全的亂數產生器。這是通則,不是文章的結論。

另外,V12 已經在 GitHub 的 v12-security/pocs 公開了 MuPDF 與 Foxit 的解密程式,攻擊的門檻因此降到下載就能用,不需要自己重寫一遍。文章沒有說明這些程式是否涵蓋 OpenPDF 與 iText。

文章說使用者密碼與擁有者密碼解開的是同一把檔案金鑰,而這幾個案例被攻擊的正是這把金鑰。同一篇文章也寫了「so the restrictions are up to the reader」,權限限制取決於閱讀器。兩句合起來,金鑰可還原時,這類檔案上的檢視限制與編輯限制都擋不住人,風險也取決於檔案由哪個產品與版本產生,和密碼設得多長無關。這是我的推論,文章本身沒有這樣下結論。

文章沒有回答的問題也值得記下來:Foxit、OpenPDF、iText 何時修補;PDFium 衍生的其他產品有哪些;實際上有沒有人在 V12 通報之前就利用過這些弱點。這三個問題在文章裡都沒有答案,它能確定的是機制本身,三組實作的金鑰都出自可以猜出來的時間值。

What this changes:檢查 PDF 加密時,除了問密碼夠不夠強、演算法是不是 AES-256,還要問檔案金鑰從哪個亂數來源出來,這個問題的答案在 MuPDF 是 512 種,在 Foxit 是一個 32 位元的時間戳。