技術文章

為什麼 Excel 會拒絕您的加密活頁簿:ECB 與 RC4

您寫出一個活頁簿、用密碼加密、把檔案交給同事,對方在 Excel 裡開啟它。Excel 會要求輸入密碼。對方輸入後,Excel 也接受了。到這裡為止,加密看起來都對。接著,Excel 卻跳出對話方塊,說檔案損毀且無法開啟,或者只打開到一張毫無意義的儲存格表。密碼是對的,檔案還是壞的。這是 Office 加密裡最讓人困惑的失敗模式,因為告訴您密碼對不對的部分,和承載資料的部分,是由兩個不同操作保護的,把其中一個做對,並不能保證另一個也對

這裡描述的兩個陷阱都是這個形狀。每種情況下,驗證器通過了,主體卻沒有,這會讓您去找一個其實不存在的密碼或金鑰衍生問題。真正的錯在下游,也就是封裝位元組如何被轉換。兩個陷阱彼此獨立,一個在 AES 路徑、一個在 RC4 路徑,但它們共用了同一種診斷困境,所以很值得看看為什麼半對的結果是最難解讀的一種

為什麼通過的密碼無法證明主體無誤

現代加密 XLSX 使用的格式是 ECMA-376 標準加密,而且會並排儲存兩個加密物件。其中一個是 EncryptionVerifier:一小段區塊,內含隨機值和該值的雜湊,並使用從密碼衍生出的金鑰加密。另一個是 EncryptedPackage:整個活頁簿的 zip 容器,同樣使用相同金鑰加密。驗證器的存在,是為了讓讀取器在花力氣處理數 MB 的主體之前,先確認密碼。先解密驗證器、對隨機值雜湊、與儲存的雜湊比對;如果相符,密碼就對

陷阱在於,驗證器和封裝是透過對不同緩衝區的獨立呼叫加密的。只要金鑰衍生正確,無論封裝之後發生什麼事,驗證器都能被正確解密。所以如果您的金鑰衍生沒問題,但封裝轉換錯了,Excel 會先從驗證器確認密碼,接著在主體上失敗。症狀看起來就是「密碼對、檔案壞」,於是調查會被引向密碼路徑,而那恰好是從頭到尾都沒壞的部分。遺留 RC4 情況的分離方式也是一樣:先檢查驗證器雜湊,而失去同步的主體仍然讓那一步通過

HotXLS 在 Delphi 處理的 ECMA-376 標準加密圖解:單一衍生金鑰透過不同的呼叫,保護小型驗證器與整個 EncryptedPackage
驗證器與套件由不同呼叫、不同緩衝區分別加密,密碼正確證明不了內文的任何事

陷阱一:AES 用的是 ECB,不是 CBC

[MS-OFFCRYPTO] §2.3.4.15 規定,標準加密是用 AES 的電子密碼本(ECB)模式來加密封裝。填補後的封裝每個 16 位元組區塊都以同一把金鑰獨立加密。區塊之間沒有鏈結,也沒有初始化向量。以現代標準來看,這是個不尋常的選擇,因為 ECB 平常都會被避免,但互通性不是拿來猜規格的地方。Excel 會把封裝當成 ECB 解密,所以產生器也必須把它加密成 ECB,不然兩邊就不會一致

誘人的錯法是用全零初始化向量的 CBC 模式 AES 來加密封裝。它幾乎可行,而「幾乎」正是最糟的落點,原因如下。在 CBC 裡,第一個純文字區塊加密前會先與 IV 做 XOR。當 IV 全為零,這個 XOR 什麼也不改變,所以帶零 IV 的 CBC,其第一個區塊產生的密文與 ECB 完全相同。從第二個區塊起,CBC 把前一個密文區塊餵進下一個,因此第一個之後的每個區塊都與 ECB 分道揚鑣

把這一點套到結構上看。封裝配置在最前面放了 8 位元組的小端序長度前綴,所以 Excel 最早檢查的檔案部分就落在第一或第二個區塊裡。第一個區塊碰巧匹配,代表最早的驗證會通過,而之後每個區塊都解密成雜訊。一旦把模式點名,解法一點都不含蓄:用 ECB 加密每個 16 位元組區塊,不要鏈結。在引擎裡,XlsEncryptStdPackage 以 16 位元組為步進走過填補後的緩衝區,對每一步呼叫 AESEncryptECB128Block——這正是驗證器區塊已在用的同一個原語。原始碼在迴圈處留了一則註解,把規則說得直白:帶零 IV 的 CBC 只有第一個區塊與 ECB 一致,封裝其餘部分會解密成垃圾,Excel 會拒收

HotXLS 圖解:加密 XLSX 套件 16 位元組區塊上的 AES ECB 與零 IV CBC 比較——只有第一個區塊相同,之後每個區塊都發散
零 IV 的 CBC 只在第一個區塊重現 ECB 行為,那裡裝著 Excel 最先驗證的長度前綴;其後每個區塊解出來都是雜訊
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('report.xlsx');
    // 將活頁簿序列化,接著執行
    // ECMA-376 標準加密管線:依 [MS-OFFCRYPTO] 2.3.4.15 對封裝套用
    // AES-128 ECB。成功時回傳 1。
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

陷阱二:RC4 重新取鑰的步調失準

舊版 .xls 路徑使用 RC4 CryptoAPI 配置,而它的規則在本質上不同。 [MS-OFFCRYPTO] §2.3.6 規定,密碼會在每個 1024 位元組區塊邊界重新取鑰。串流被切成 1024 位元組的區塊,區塊編號 0、1、2 等各自衍生出新的 RC4 金鑰,且在每個區塊內,金鑰流會從位元組到位元組連續消耗。兩個不變條件必須同時成立:每個邊界都重新取鑰,且在區塊內消耗金鑰流時不能有任何缺口。RC4 是串流密碼,所以它的金鑰流是單一有序序列;您取出的第 n 個位元組,取決於之前已經取了多少位元組。解密只是對同一序列做相同的 XOR,這表示產生端和消費端必須在完全相同的位置取出完全相同的位元組

困難全在這裡。串流密碼沒有重新同步機制。只要浪費掉一個金鑰流位元組,之後每個位元組都拿錯的金鑰流位元組做 XOR,錯誤永遠不會自我修正;它會連鎖到區塊尾端,而目前位置一旦錯了,之後的每個區塊也跟著錯。一個小小的簿記失誤恰恰會造成這種事。想像一個從哨兵值負一開始的區塊計數器,再加一個假設計數器已與目前區塊一致的跳過常式。從那個哨兵值出發,它會重新取鑰,並燒掉一整段本不該消耗的 1024 位元組金鑰流,順便把剩餘計數推成負數。從那一刻起,解密器整整錯開了一個區塊。而在這一切之前檢查的驗證器照樣通過,所以密碼看起來是對的,每個資料儲存格卻解出垃圾

在 HotXLS 裡,這段邏輯住在 TXLSDecrypterRC4。Skip 與 Decrypt 共用同一個迴圈:只有當目前位置跨進新區塊時才重新取鑰,區塊索引是位置除以 REKEY_BLOCK_SIZE(1024),然後只消耗到目前區塊的剩餘部分為止,不多拿。MakeKey 拿到的永遠是當下的區塊索引,不會是過期或哨兵索引,位置則按實際處理的位元組數前進,讓 Skip 與 Decrypt 和產生端保持相位對齊。教訓落在最小的單位上:對串流密碼而言,浪費一個位元組不是小誤差,是下游的一切全數報廢

從 Delphi 讀取的舊式 Excel 加密中 RC4 換鑰漂移圖解:產生端每 1024 位元組換鑰,以哨兵為起始的讀取端燒掉一個金鑰流區塊而相位錯開
串流密碼沒有重新同步,燒掉一個 1024 位元組金鑰流區塊,之後解出的每個儲存格都是亂碼,驗證器卻照樣通過
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    // 以 RC4 或 RC4 CryptoAPI 保護的舊版 .xls 在
    // Open 內部解密:傳入密碼,串流解密器會讓重新取鑰
    // 的節奏與產生端保持對齊。
    Book.Open('legacy.xls', 'S3cret!');
    // 在這裡讀取儲存格
  finally
    Book.Free;
  end;
end;

與凍結規範互通,就是逐位元組一致

兩個陷阱都收斂到同一條根本原則,而這條原則值得單獨講,因為它會改變您衡量設計取捨的方式。當您輸出的接收端是一個無法更動的外部固定程式時,加密模式與重新取鑰節奏就不是您可以自行最佳化或簡化的實作細節。它們是傳輸契約的一部分。無論這些選擇討不討您喜歡,Excel 都會用 ECB 解密、在 1024 位元組邊界重新取鑰,您唯一的任務是產出在這套確切程序下能解密回原始內容的位元組。更現代的模式、看似無害的 IV、從「感覺自然」之處起算的計數器——任何一項,只要偏離讀取端的預期,當下就是缺陷。對凍結規範做互通沒有近似可言:不是逐位元組一致,就是壞掉

這也是為什麼驗證器本身不是一個好的煙霧測試。它只會告訴您金鑰衍生正常運作,這是必要條件,但遠遠不夠。只要測試只是打開加密檔案並確認密碼通過,它就會報告成功,但主體卻可能完全無法讀取。真正的測試會解密封裝,將還原出的位元組和原始輸入比對,或是把活頁簿加密後再解密並讀回儲存格。驗證器證明的是密碼;只有主體能證明加密

讀寫受保護活頁簿的支援方式

公開介面很小。要寫出受密碼保護的現代活頁簿,填入或開啟一個 TXLSXWorkbook,帶著檔名與密碼呼叫 SaveAsEncrypted;它會序列化活頁簿並執行前述的標準加密管線,成功時回傳 1。SaveAsEncryptedAgile 接受相同的參數,寫出 Excel 2010 及之後版本預設使用的 Agile 配置,採 SHA-512 金鑰衍生與 AES-256 封裝加密。讀取時,先呼叫 CanReadEncrypted 判斷檔案是否為 Compound File 容器,再分支:OpenEncrypted 能解密 Standard 與 Agile 兩種封裝,對一般檔案則退回 Open;密碼錯誤或容器使用其他配置時,它會擲出 EXlsxEncryptionNotImplemented。舊版 .xls 檔也是 Compound File,所以改帶密碼交給 TXLSWorkbook.Open。前述的模式處理與重新取鑰迴圈都墊在這些呼叫底下;您只出密碼和檔名,規格的吻合由引擎代勞

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // 在消費端重新開啟
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

受保護輸出的形狀、EncryptionInfo 串流、驗證器區塊,以及封裝配置,都在我們對 AES 保護 XLSX 輸出的逐步說明中有介紹。至於工作表層級鎖定,以及保護如何和頁面設定與列印互動,請參考關於保護、頁面設定與列印的文章。兩者都建立在這裡描述的加密路徑之上,而這條路徑是 Delphi 和 C++Builder 的 HotXLS 試算表元件 的一部分,並與本部落格其他地方介紹的讀取、寫入和轉譯 API 一起隨附