技術文章

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

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

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

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

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

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

錯誤一: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 也會拒絕它

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('report.xlsx');
    // SaveAsEncrypted serializes the workbook, then runs the
    // ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
    // package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
    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,而錯誤不會自己修正;它會一路連鎖到區塊尾端,而一旦目前位置錯了,後面的每個區塊也都會跟著錯。這裡的 bug 正是如此。區塊計數器從負一的哨兵值開始,而跳過常式又假設計數器已經和目前區塊一致。從那個哨兵值出發,它重新取鑰並整整跑掉了一個本不該消耗的 1024 位元組金鑰流區塊,還在過程中把剩餘計數推成負數。從那刻起,解密器就整整落後一個區塊。驗證器在這一切之前就已經檢查過,所以密碼看起來沒問題,而每個資料儲存格都吐出垃圾

修正後的邏輯在 TXLSDecrypterRC4 裡。SkipDecrypt 共用同一個迴圈:只有當目前位置跨進新區塊時才重新取鑰,其中區塊索引是位置除以 REKEY_BLOCK_SIZE(1024),然後只消耗到目前區塊的剩餘量為止。MakeKey 會以區塊索引呼叫,絕不會拿過期或哨兵索引來用,而位置會依處理過的精確位元組數前進,讓 SkipDecrypt 與產生端保持相位對齊。教訓就在最小的單位裡:在串流密碼中,浪費一個位元組不是小錯,而是把下游一切都一起弄丟

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    // CanReadEncrypted checks the Compound File (OLE2) signature so
    // you can branch before attempting a normal Open. OpenEncrypted
    // routes plain files to Open and handles the encrypted container.
    if Book.CanReadEncrypted('legacy.xls') then
      Book.OpenEncrypted('legacy.xls', 'S3cret!')
    else
      Book.Open('legacy.xls');
    // read cells here
  finally
    Book.Free;
  end;
end;

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

這兩個 bug 都回到同一條根本原則,而這條原則值得單獨講,因為它會改變您衡量設計選項的方式。當您輸出的接收端是無法更動的外部固定程式時,加密模式與重新取鑰節奏就不是您可以最佳化或簡化的實作細節。它們就是傳輸契約的一部分。無論您喜不喜歡,Excel 都會用 ECB 解密,並在 1024 位元組邊界重新取鑰,而您的唯一工作,就是產生在那套精確程序下會還原成原始內容的位元組。更現代的模式、看起來無害的 IV、從感覺自然的位置開始計數的計數器,任何一個只要偏離讀取器預期的那一刻,就是缺陷。對凍結規範的互通不是近似值;它要麼逐位元組一致,要麼就是壞的

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

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

公開介面很小。要寫入受密碼保護的現代活頁簿,請填入或開啟 TXLSXWorkbook,然後帶著檔名和密碼呼叫 SaveAsEncrypted;它會序列化活頁簿並執行第一個修正所更正的標準加密流程,成功時傳回 1。要讀取時,先呼叫 CanReadEncrypted 檢查檔案是不是加密的複合檔案容器,然後分支:OpenEncrypted 會處理加密路徑,普通檔案則退回 Open,而直接帶密碼的 Open 也可用。上面提到的模式處理與重新取鑰迴圈都在這些呼叫底下,您只要提供密碼和檔名,引擎就會代您符合規格

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // Reopen on the consumer side
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

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