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,這兩個值都與規格的獨立實作交叉核對過
兩張表備齊後,推導本身不長。從密碼尾端往前走,每個位元組左移的同時看七次第 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,您測自己的讀取器時可以拿來當固定樣本
金鑰對了,檔案為什麼還是壞的?
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 個位元組跳過轉換,卻照樣計入偏移與記錄長度兩者
湊起來,逐記錄的轉換就幾行程式。再說一次,這是規則示意,不是您需要呼叫的東西:
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 產品頁