Technical Article

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

您寫入一個活頁簿、用密碼加密它、將檔案交給同事,同事在 Excel 中開啟它;Excel 要求輸入密碼;同事輸入了密碼,且 Excel 接受了它;到目前為止,加密看起來是正確的;接著,Excel 彈出一個對話方塊,表示檔案已損壞且無法開啟,或者開啟後呈現一張充滿無意義儲存格的工作表;密碼是正確的,但檔案仍然損壞;這是 Office 加密中最令人困惑的失敗模式,因為告訴您密碼正確的部分與保存您資料的部分是由兩個不同的操作保護的,做對其中一個並不能保證另一個

此處描述的兩個錯誤都精確地具有這種形式;在每種情況下,驗證器通過了,但主體沒有通過,這會送您去尋找並不存在的密碼或金鑰衍生錯誤;真正的故障在下游(包裹位元組如何被轉換);這兩個故障是獨立的,一個在 AES 路徑中,另一個在 RC4 路徑中,但它們共享一個診斷問題,因此值得了解為什麼半正確的結果是最難解讀的型態

為什麼通過的密碼不能證明主體的任何事情

現代加密 XLSX 使用的格式是 ECMA-376 標準加密,它並排儲存了兩個加密內容;一個是 EncryptionVerifier(一個保存隨機值和該值雜湊的小區塊,使用從密碼衍生的金鑰加密);另一個是 EncryptedPackage(活頁簿的整個 zip 容器,使用相同的金鑰加密);驗證器的存在是為了讓讀取器可以在花費精力處理數 MB 的主體之前確認密碼;解密驗證器、雜湊隨機值、將其與儲存的雜湊進行比較,如果匹配,則密碼正確

陷阱在於驗證器和包裹是透過對不同緩衝區的獨立呼叫進行加密的;不管包裹隨後發生了什麼事,正確衍生的金鑰都會正確解密驗證器;因此,如果您的金鑰衍生是正確的,但您的包裹轉換是錯誤的,Excel 會從驗證器確認密碼,然後在主體上失敗;症狀讀起來是「密碼正確,檔案損壞」,這將調查指向密碼路徑,而這正是從未損壞的部分;相同的分離主導了遺留的 RC4 情況:驗證器雜湊首先被檢查,而失去同步漂移的主體仍然使該檢查保持完好

錯誤一:使用 ECB 而非 CBC 的 AES

[MS-OFFCRYPTO] §2.3.4.15 指定標準加密使用電子密碼本(ECB)模式的 AES 加密包裹;填補(Padding)後之包裹的每個 16 位元組區塊都使用相同的金鑰獨立加密;區塊之間沒有鏈結,也沒有初始化向量;以現代標準來看,這是一個不尋常的選擇,因為通常會避免使用 ECB,但互通性不是重新猜測規範的地方;Excel 將包裹解密為 ECB,因此產生器必須將其加密為 ECB,否則兩者將無法達成一致

該錯誤是使用全零初始化向量的 CBC 模式 AES 加密包裹;這就是為什麼它幾乎可以運作,以及為什麼「幾乎」是最糟糕的結果;在 CBC 中,第一個純文字區塊在加密前與 IV 進行 XOR 運算;當 IV 為全零時,該 XOR 不會改變任何內容,因此帶有零 IV 的 CBC 第一個區塊會產生與 ECB 完全相同的加密文字;從第二個區塊開始,CBC 將前一個加密文字區塊餵入下一個區塊,因此第一個區塊之後的每個區塊都會與 ECB 分歧

現在將其覆蓋在結構上;包裹配置在最開始放置一個 8 位元組的小端(Little-endian)長度前綴,因此 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 運算,且錯誤永遠不會自我修正;它會串聯到區塊的末尾,且一旦執行位置出錯,就會串聯到其後的每個區塊;此處的錯誤正是這樣做的;區塊計數器從負一的哨兵值開始,且跳過常式假設計數器已經與目前區塊匹配;從該哨兵開始,它重新設定金鑰並執行了絕不應該被取用的完整 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;

與凍結規範的互通性是位元組級別的匹配

這兩個錯誤都歸結為同一個根本原則,這值得單獨陳述,因為它改變了您衡量設計選擇的方式;當您輸出的取用者是您無法變更的固定外部程式時,加密模式和重新金鑰步調就不是您可以最佳化或簡化的實現細節;它們是傳輸協定的一部分;無論這些選擇是否令您滿意,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 搭配出貨