技術文章

HotXLS XLS XOR 混淆:金鑰推導與 XorRor

HotXLS 會寫出 Excel 5.0/95(BIFF5)的 XOR 混淆活頁簿,而 Excel 16 只在三個細節與 [MS-OFFCRYPTO] 完全一致時才肯開:FILEPASS 金鑰必須是 CreateXorKey_Method1(password),16 位元組的 XOR 陣列必須用 XorRor(右旋一位元)建出,每個位元組的 XorArrayIndex 都得是 (串流偏移 + 記錄長度) mod 16。HotXLS 在 v2.384.47 修對索引,v2.384.54 修對金鑰與旋轉。在此之前,它產出的每一份帶密碼 BIFF5 檔案,HotXLS 自己開得好好的,Excel 卻打不開

最後那句話就是整個故事。讀寫兩端共享同一個錯誤想法時,彼此會完美一致,往返測試全綠,唯一要緊的消費者卻連連搖頭。Excel 16 搖了兩次頭,兩次訊息還不相同,各自指向這套機制的不同層。本文按 Excel 檢查的順序走過這些層,位元組級的細節不論您是呼叫 HotXLS 還是自己寫 BIFF 讀取器都用得上

BIFF XOR 混淆實際上存了什麼?

BIFF XOR 混淆在檔案裡只存兩個 16 位元字組,其餘全都從密碼重算。FILEPASS 記錄($002F)緊跟在活頁簿 globals BOF 之後,BIFF5 檔案裡它的主體恰好 4 位元組:XOR 金鑰在前,密碼驗證值在後。沒有 salt、沒有演算法識別碼,也沒有 RC4、AES 那類機制附帶的加密驗證區塊

讀取器從這兩個字組重建三樣東西:

  • 驗證值:密碼位元組的 16 位元雜湊,與 $CE4B 做 XOR。拿它跟存放的字組比對,就是密碼檢查,而且是唯一一道
  • XOR 金鑰:16 位元值,出自 [MS-OFFCRYPTO] §2.3.7.2 的 CreateXorKey_Method1,由兩張常數表驅動(InitialCode,15 個字組;XorMatrix,105 個字組)
  • XOR 陣列:16 位元組,由密碼位元組補上固定的 16 位元組 pad 組成,每個與低位元組金鑰(偶數位置)或高位元組金鑰(奇數位置)做 XOR,再右旋一位元

記錄標頭保持明文,少數整條獲得豁免的記錄也是,例如 BOF、FILEPASS 與 INTERFACEHDR。其餘每條記錄主體都逐位元組轉換:先左旋 5 位元,再與 16 位元組陣列的某一項做 XOR。解密就是鏡像操作,[MS-OFFCRYPTO] §2.3.7.3 寫成 DecryptData_Method1:先 XOR,再右旋 5

在 HotXLS 裡這些您一概不必直接碰。設好密碼、選好格式,SaveAs 就會發出 FILEPASS 並轉換串流:

uses
  SysUtils, lxHandle;

procedure SaveLegacyProtectedBook(const FileName: string);
var
  Wb: IXLSWorkbook;
begin
  Wb := TXLSWorkbook.Create;
  Wb.Sheets.Add.Name := 'Ledger';
  Wb.Sheets[1].Range['A1', 'A1'].Value := 'Account';
  Wb.Sheets[1].Range['B1', 'B1'].Value := 1250.75;

  // BIFF5 只支援 XOR 混淆;xletAuto 也會挑它
  Wb.EncryptionType := xletXor;
  // 保持 ASCII 且最多 15 個字元(見下文)
  Wb.EncryptionPassword := 'secret';

  if Wb.SaveAs(FileName, xlExcel5) <> 1 then
    raise Exception.Create('BIFF5 save failed');
end;

驗證值對得上,Excel 為什麼還說密碼錯?

Excel 拒絕密碼,是因為它不信任存放的金鑰:Excel 會用 CreateXorKey_Method1 從輸入的密碼推導金鑰,再與 FILEPASS 的金鑰字組比對,金鑰只要是別的值,驗證值再正確也過不了密碼檢查。規格把金鑰描述成密碼的輸出,不是自由參數,Excel 16 執行的正是這種解讀

v2.384.54 之前的 HotXLS 寫入器,用兩個隨機位元組填金鑰字組。紙面上看似無害:驗證值才是文件明載的密碼檢查,陣列又是照檔案宣告的金鑰建出來。HotXLS 自己讀這些檔案毫無障礙,因為它的讀取器把 FILEPASS 裡的金鑰照單全收。同一份檔案、正確的密碼交到 Excel 16 手上,答覆卻是密碼不正確。從 v2.384.54 起金鑰改為推導,密碼 secret 的 FILEPASS 於是永遠是金鑰 $014D、驗證值 $DAA7,這兩個值都與規格的獨立實作交叉核對過

HotXLS 示意圖,Excel 16 開檔時檢查的 BIFF5 XOR FILEPASS 記錄:明文的 4 位元組主體放著 XOR 金鑰與密碼驗證值,Excel 用 CreateXorKey_Method1 從輸入密碼推導金鑰,金鑰是隨機值時就算驗證值相符也以密碼錯誤退件;HotXLS 為 secret 存放推導出的金鑰 014D
FILEPASS 主體只有兩個字組,Excel 卻會從您的密碼重新推導金鑰再比對;隨機填的金鑰就算驗證值正確也過不了檢查,HotXLS 因此從 v2.384.54 起改為推導

兩張表備齊後,推導本身不長。從密碼尾端往前走,每個位元組左移的同時看七次第 6 位元,位元有設就 XOR 進一個 XorMatrix 項目。以下是重現規格演算法、並與 HotXLS 實作相符的原理示意;它不是 HotXLS 的 API:

// [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1 原理示意
// 與 CreateXorArray_Method1(僅供示意,非 HotXLS API)
type
  TXorArray = array [0..15] of Byte;

function DemoCreateXorKey(const Password: AnsiString): Word;
const
  InitialCode: array [0..14] of Word = ($E1F0, $1D0F, $CC9C, $84C0, $110C,
    $0E10, $F1CE, $313E, $1872, $E139, $D40F, $84F9, $280C, $A96A, $4EC3);
  XorMatrix: array [0..104] of Word = (
    $AEFC, $4DD9, $9BB2, $2745, $4E8A, $9D14, $2A09,
    $7B61, $F6C2, $FDA5, $EB6B, $C6F7, $9DCF, $2BBF,
    $4563, $8AC6, $05AD, $0B5A, $16B4, $2D68, $5AD0,
    $0375, $06EA, $0DD4, $1BA8, $3750, $6EA0, $DD40,
    $D849, $A0B3, $5147, $A28E, $553D, $AA7A, $44D5,
    $6F45, $DE8A, $AD35, $4A4B, $9496, $390D, $721A,
    $EB23, $C667, $9CEF, $29FF, $53FE, $A7FC, $5FD9,
    $47D3, $8FA6, $0F6D, $1EDA, $3DB4, $7B68, $F6D0,
    $B861, $60E3, $C1C6, $93AD, $377B, $6EF6, $DDEC,
    $45A0, $8B40, $06A1, $0D42, $1A84, $3508, $6A10,
    $AA51, $4483, $8906, $022D, $045A, $08B4, $1168,
    $76B4, $ED68, $CAF1, $85C3, $1BA7, $374E, $6E9C,
    $3730, $6E60, $DCC0, $A9A1, $4363, $86C6, $1DAD,
    $3331, $6662, $CCC4, $89A9, $0373, $06E6, $0DCC,
    $1021, $2042, $4084, $8108, $1231, $2462, $48C4);
var
  Len, I, Bit, Element: Integer;
  Ch: Byte;
begin
  Result := 0;
  Len := Length(Password);
  if Len > 15 then
    Len := 15;                       // 金鑰只看前 15 個位元組
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // 最後一個 XorMatrix 項目
  for I := Len downto 1 do
  begin
    Ch := Ord(Password[I]);
    for Bit := 1 to 7 do
    begin
      if (Ch and $40) <> 0 then
        Result := Result xor XorMatrix[Element];
      Ch := Byte(Ch shl 1);
      Dec(Element);
    end;
  end;
end;

function XorRor(B, KeyByte: Byte): Byte;
begin
  B := B xor KeyByte;
  Result := Byte((B shr 1) or (B shl 7));   // 右旋一位元
end;

procedure DemoCreateXorArray(const Password: AnsiString; out Arr: TXorArray);
const
  PadArray: TXorArray = ($BB, $FF, $FF, $BA, $FF, $FF, $B9, $80,
    $00, $BE, $0F, $00, $BF, $0F, $00, $00);
var
  Key: Word;
  Len, I: Integer;
begin
  Key := DemoCreateXorKey(Password);
  Len := Length(Password);
  if Len > 16 then
    Len := 16;
  for I := 0 to Len - 1 do
    Arr[I] := Ord(Password[I + 1]);
  for I := Len to 15 do
    Arr[I] := PadArray[I - Len];
  for I := 0 to 15 do
    if Odd(I) then
      Arr[I] := XorRor(Arr[I], Byte(Key shr 8))
    else
      Arr[I] := XorRor(Arr[I], Byte(Key and $FF));
end;

對 secret 來說,這樣建出的陣列是 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF,您測自己的讀取器時可以拿來當固定樣本

HotXLS 示意圖,BIFF5 混淆的 XOR 陣列建構流程:以密碼與固定 pad 為種子的 16 個位元組,偶數位置與低位元組金鑰、奇數位置與高位元組金鑰做 XOR,最後經 XorRor 右旋一位元,密碼 secret 產出固定樣本 1F 32 17 B9
陣列位元組來自密碼、pad 與兩個金鑰位元組,最後旋轉一次;左旋 2 只有在位元組轉換按相反順序執行時才成立,Excel 走的是規格順序

金鑰對了,檔案為什麼還是壞的?

XOR 陣列旋錯方向時,金鑰再對也照樣產出壞檔案:[MS-OFFCRYPTO] 把陣列這一步定義成 XorRor,右旋一位元;左旋兩位的陣列會把每條記錄主體解成雜訊。修好金鑰之後,Excel 16 過了密碼那一關,直接撞上另一個錯誤:回報檔案有問題、無法開啟

HotXLS 舊程式碼把每個陣列位元組左旋 2 位元,這種寫法在幾個 BIFF 實作之間流傳。HotXLS 兩端用同一種旋轉,自家讀取器自然毫無察覺。Excel 16 已經不能存 Excel 5.0/95 檔案,存 BIFF8 也不提供 XOR,手邊因此沒有 Excel 原生樣本可以比對。證據只能從反方向來:寫一條明文 BIFF5 串流,用八種方式重新編碼,讓 Excel 16 一一開啟。八個變體橫跨三個獨立選擇:

選擇選項 A選項 B
陣列旋轉XorRor(右旋 1)左旋 2
陣列索引(偏移 + 記錄長度) mod 16偏移 mod 16
位元組轉換順序左旋 5 再 XOR先 XOR 再左旋 5

Excel 16 恰好開了八個裡的兩個:XorRor 配先旋轉再 XOR 與含記錄長度的索引,以及一個只是看起來不同的變體。左旋 2 配先 XOR 再旋轉加上同一個索引,其實是同一個函式的偽裝。旋轉對 XOR 可分配,rol5(p xor rol2(b)) 等於 rol5(p) xor rol7(b),而對 8 位元值來說,左旋 7 就是右旋 1。簡言之 rol5 ∘ rol2 = ror1——這就是左旋 2 的陣列單獨看似有理的原因:它只有搭配相反的轉換順序才正確;配上規格順序,每個被轉換的位元組都會損毀

同一組實驗也回答了第二個問題。從索引裡拿掉記錄長度的變體全數失敗,印證了 HotXLS 早一個版本光憑規格文字就採用的索引規則

每個位元組的 XorArrayIndex 怎麼算?

一個位元組的 XorArrayIndex,是它在活頁簿串流裡的偏移加上所屬整條記錄資料的長度,再 mod 16。所以索引每條記錄都從一個隨記錄而異的值重新起算,記錄內部逐位元組加一。規格的偽代碼把輸入命名為 FileOffset 與 Data.Length,很容易誤讀成只取記錄起始偏移,而 HotXLS 直到 v2.384.47 出貨的正是這個誤讀

三個細節決定您的索引能不能跟 Excel 對齊:

  • 4 位元組的記錄標頭從不被轉換,但照樣佔用串流位置,所以記錄第一個主體位元組位在標頭偏移 + 4
  • 長度項是完整的記錄資料長度,不是實際被轉換的位元組數
  • BOUNDSHEET 部分明文:前 4 個位元組 lbPlyPos(工作表 BOF 的串流偏移)保持可讀,剖析器才有辦法定位各工作表。這 4 個位元組跳過轉換,卻照樣計入偏移與記錄長度兩者
HotXLS 示意圖,BIFF5 XOR 混淆的 XorArrayIndex 規則:每個主體位元組用串流偏移加完整記錄資料長度再 mod 16,明文的 4 位元組標頭與 BOUNDSHEET 的 lbPlyPos 前綴照樣計入偏移,忽略記錄長度項正是 HotXLS 在 v2.384.47 修掉的缺陷
陣列索引是每條記錄重起一次,不是整條串流重起一次:標頭與任何明文前綴都佔位置,記錄資料長度要進模數,舊 HotXLS 的兩端卻在同一條錯公式上達成共識

湊起來,逐記錄的轉換就幾行程式。再說一次,這是規則示意,不是您需要呼叫的東西:

function Rol8(B: Byte; N: Integer): Byte;
begin
  Result := Byte((B shl N) or (B shr (8 - N)));
end;

// 就地混淆一條記錄主體。BodyPos 是 Body[0] 的串流偏移,
// 亦即記錄標頭偏移 + 4。PlainPrefix 對 BOUNDSHEET 是 4、
// 對 BOF / FILEPASS 是全長,多數記錄為 0
procedure DemoObfuscateRecord(var Body: array of Byte; RecordLength: Word;
  PlainPrefix: Integer; BodyPos: LongWord; const Arr: TXorArray);
var
  I: Integer;
begin
  for I := PlainPrefix to RecordLength - 1 do
    Body[I] := Rol8(Body[I], 5) xor
      Arr[(BodyPos + LongWord(I) + RecordLength) mod 16];
end;

// 讀取是鏡像操作:B := Body[I] xor Arr[...];
// 然後右旋 5,即 Rol8(B, 3)

v2.384.47 之前,HotXLS 讀取器只憑串流位置算索引,寫入器用的是明文位元組偏移。兩端都無視記錄長度,於是又是一次兩端彼此同意、跟誰都不同。獨立撰寫的解碼器讀 v2.384.47 的輸出完全正確,讀舊輸出則是亂碼;後來的八變體 Excel 16 測試,再拿真正的目標驗證了這條規則

舊版 HotXLS 寫出的 XOR 檔案會怎樣?

HotXLS 靠檢查 FILEPASS 金鑰,繼續讀自家 v2.384.54 之前的 XOR 檔案:存放的金鑰等於從密碼推導的金鑰時,讀取器建規格的 XorRor 陣列;不一致時,就把檔案當成舊 HotXLS 檔案,改建左旋 2 的陣列。Excel 寫出的檔案永遠帶推導金鑰,所以永遠走規格那條路

這個判斷是個啟發式,失敗率算得出來:舊檔案的隨機金鑰恰好等於推導金鑰時,會被拿錯的陣列去讀,機率是 65,536 分之 1。後備機制只涵蓋陣列旋轉,索引規則不切換,所以救得回來的是 v2.384.47 到 v2.384.53 之間寫出的檔案。手邊若還有那個窗口的 BIFF5 XOR 檔案,用目前的 HotXLS 開啟後重新存檔,就能得到 Excel 肯收的檔案

兩個密碼細節對新舊檔案都適用:

  • 長度。CreateXorKey_Method1 只讀密碼的前 15 個位元組,這是規格上限。HotXLS 對金鑰套這個上限,驗證值與陣列則維持各自慣用的全長與 16 位元組規則,兩端一致。Excel 對這個格式本來就拒絕超過 15 字元的密碼,所以把 15 當成真正的最大值
  • 字元集。HotXLS 透過系統 ANSI 字碼頁把密碼轉成位元組。規格描述的是取每個 UTF-16 字元的低位元組,對 ASCII 而言兩者一致。手上沒有非 ASCII 密碼保護的 Excel 樣本,其餘情況便沒有基準可判,XOR 檔案的密碼就用 ASCII

讀取這一端,Open 碰到 FILEPASS 記錄時,TXLSWorkbook.OnPassword 讓您跳出密碼詢問。事件型別是 TXLSPasswordEvent,帶 var PassWord: WideString 與 var Retry: Boolean;把 Retry 設成 True 可以再試,最多重試三次:

procedure TImportForm.WorkbookPassword(Sender: TObject;
  var PassWord: WideString; var Retry: Boolean);
var
  S: string;
begin
  S := '';
  Retry := InputQuery('Protected workbook', 'Password:', S);
  PassWord := S;
end;

procedure TImportForm.ImportLegacyFile(const FileName: string);
var
  Wb: IXLSWorkbook;
  Rc: Integer;
begin
  Wb := TXLSWorkbook.Create;
  Wb.OnPassword := WorkbookPassword;
  Rc := Wb.Open(FileName);
  // -1003:需要密碼但未提供;-1005:密碼錯誤
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

早就知道密碼的話,Open(FileName, APassWord) 完全跳過事件

XOR 混淆夠安全嗎?能拿來保護什麼?

BIFF XOR 混淆不是加密,擋不住任何下定決心的讀取者。密碼檢查是 16 位元驗證值,金鑰 16 位元,16 位元組的陣列又在整條串流上反覆出現,BIFF 記錄內容可預測,不用任何密碼就能曝出陣列位元組。HotXLS 之所以支援寫 XOR,純粹是因為 Excel 5.0/95 檔案沒有別的選擇;今天還需要產這種檔案的理由,是有個讀不了新格式的舊系統

傳統引擎用 TXLSWorkbook.EncryptionType 選機制,與存檔格式的組合受嚴格檢查:

  • xletAuto(預設)對 xlExcel97 寫 RC4 CryptoAPI、對 xlExcel5 寫 XOR,與 Excel 自己對各格式的做法一致
  • xletXor 只對 BIFF5 有效;配 xlExcel97 存檔會直接丟出例外,不會默默退回
  • xletRC4 與 xletRC4CryptoAPI 只屬於 BIFF8,在 BIFF5 存檔時指定同樣丟例外

RC4 也早就過時,讓它互通的細節見 Excel 為什麼拒收密碼正確的加密活頁簿。收件方讀得了 XLSX 的話,改用 XLSX 引擎:TXLSXWorkbook.SaveAsEncryptedAgile 寫 Agile Encryption(SHA-512 密碼雜湊、100,000 次迭代 spin count、AES-256-CBC),是 Excel 2010 之後的預設格式;SaveAsEncrypted 則寫較舊的 AES-128 Standard Encryption。兩者的取捨見 在 Delphi 裡用 AES 加密 XLSX 檔案,讀取端則見 用 HotXLS 讀取 Agile 加密的 Excel 檔案

速查:Excel 16 肯收的 BIFF XOR 混淆

  • FILEPASS($002F)跟在 globals BOF 之後;BIFF5 裡主體 4 位元組:金鑰、驗證值
  • 金鑰 = CreateXorKey_Method1(password),依 [MS-OFFCRYPTO] §2.3.7.2,絕不隨機;secret 是 $014D
  • 陣列 = 密碼位元組 + pad,偶數位置與低位元組金鑰、奇數位置與高位元組金鑰做 XOR,再 XorRor(右旋 1)
  • 加密一個位元組:左旋 5 再 XOR;解密:先 XOR 再右旋 5(§2.3.7.3)
  • XorArrayIndex = (位元組串流偏移 + 記錄資料長度) mod 16;標頭與明文前綴計入偏移
  • BOUNDSHEET 前 4 位元組保持明文;BOF、FILEPASS 與 INTERFACEHDR 整條明文
  • 密碼:ASCII,最多 15 字元
  • HotXLS:索引 v2.384.47 修正,金鑰與 XorRor v2.384.54 修正,舊 HotXLS XOR 檔案靠金鑰不符偵測
  • 要真正的保護,至少用 BIFF8 RC4 CryptoAPI,或 XLSX Agile Encryption

BIFF5 與 BIFF8 密碼保護、XLSX Standard 與 Agile Encryption、讀取端的密碼回呼,HotXLS 一套 Delphi 與 C++Builder 函式庫全包,上述互通細節都不必您操心。各版本、支援平台與試用下載請見 HotXLS Delphi spreadsheet component 產品頁