HotXLS 写出的 Excel 5.0/95(BIFF5)XOR 混淆工作簿,只有三个细节与 [MS-OFFCRYPTO] 严丝合缝时 Excel 16 才肯打开:FILEPASS 密钥必须是 CreateXorKey_Method1(password),16 字节 XOR 数组必须用 XorRor(循环右移一位)构建,每个字节必须用 XorArrayIndex = (stream offset + record length) mod 16。HotXLS 在 v2.384.47 修对了索引,在 v2.384.54 修对了密钥和旋转。在此之前,它产出的每个带密码 BIFF5 文件都在 HotXLS 里开得好好的,一到 Excel 就失败
最后那句话就是整个故事的缩影。读写双方共享同一个错误想法时,彼此配合得天衣无缝,往返测试一路全绿,而唯一要紧的消费者说不。Excel 16 说不了两次,两次报错不同,每次都指向这套方案的另一层。本文按 Excel 检查它们的顺序走过这些层,字节级细节对调用 HotXLS 和自己写 BIFF 读取器的都管用
BIFF XOR 混淆在文件里到底存了什么?
BIFF XOR 混淆在文件里只存两个 16 位字,其余一切都从密码重算。FILEPASS 记录($002F)紧跟在工作簿全局 BOF 之后,在 BIFF5 文件里它的主体恰好 4 字节:XOR 密钥后跟密码校验值。没有 salt,没有算法标识符,也没有 RC4 和 AES 方案携带的那种加密校验值 blob
读取器从这两个字重建三样东西:
- 校验值,密码字节与
$CE4B异或后的 16 位哈希。拿它与存储的字比较就是密码检查,而且是唯一的检查 - XOR 密钥,来自 [MS-OFFCRYPTO] §2.3.7.2 的
CreateXorKey_Method1的 16 位值,由两张常量表驱动(InitialCode15 字,XorMatrix105 字) - XOR 数组,16 字节,由密码字节用固定的 16 字节 pad 补齐而成,每个字节与低密钥字节(偶数位置)或高密钥字节(奇数位置)异或,然后循环右移一位
记录头保持明文,方案豁免的少数整条记录也是明文,其中有 BOF、FILEPASS 和 INTERFACEHDR。其余每条记录体都被逐字节变换:循环左移 5 位,再与 16 字节数组的某一项异或。[MS-OFFCRYPTO] §2.3.7.3 写作 DecryptData_Method1 的解密是镜像操作:先异或,再循环右移 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 位,位一旦置位就异或进一个 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 样本可拿来做 diff。证据只能从反方向来:写一份明文 BIFF5 流,用八种方式重新编码,让 Excel 16 逐个打开。八种变体交叉了三个独立选择:
| 选择 | 选项 A | 选项 B |
|---|---|---|
| 数组旋转 | XorRor(右旋 1 位) | 左移 2 位 |
| 数组索引 | (offset + record length) mod 16 | offset mod 16 |
| 字节变换顺序 | 先左移 5 位,再异或 | 先异或,再左移 5 位 |
Excel 16 恰好打开八分之二:XorRor 加先旋后异或加记录长度索引,以及一个只是看起来不同的变体。左移 2 位加先异或后旋加同一索引,是同一个函数的伪装。旋转对异或可分配,所以 rol5(p xor rol2(b)) 等于 rol5(p) xor rol7(b),而 8 位值上左移 7 位就是右移 1 位。简言之 rol5 ∘ rol2 = ror1,这就是左移 2 位数组单独看貌似有理的原因:只有搭配相反的变换顺序它才是对的。配上规范顺序,它就污染每个被变换的字节
同一实验还定了第二个问题。从索引里去掉记录长度的变体全军覆没,这就证实了 HotXLS 早一个版本仅凭规范文本采纳的索引规则
每个字节的 XorArrayIndex 怎么算?
一个字节的 XorArrayIndex 是它在工作簿流里的偏移加上它所属整条记录数据的长度,再对 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 写的文件总带着推导密钥,所以总走规范路径
这个检测是个失败率精确已知的启发式。旧文件随机密钥恰好等于推导密钥时,会用错误的数组去读,这个概率是 1/65,536。兜底只覆盖数组旋转;索引规则不切换,所以它救回的是 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 样本,其余情况就没有 ground truth,XOR 文件的密码请坚持用 ASCII
读取这头,TXLSWorkbook.OnPassword 让你在 Open 撞上 FILEPASS 记录时弹出密码询问。事件是 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)紧跟全局 BOF;BIFF5 里主体 4 字节:密钥,然后校验值 - 密钥 =
CreateXorKey_Method1(password),依 [MS-OFFCRYPTO] §2.3.7.2,绝不随机;secret对应$014D - 数组 = 密码字节 + pad,偶数位置与低密钥字节异或、奇数位置与高密钥字节异或,然后 XorRor(右旋 1 位)
- 加密一个字节:先左移 5 位再异或;解密:先异或,再右移 5 位(§2.3.7.3)
- XorArrayIndex = (byte stream offset + record data length) 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
HotXLS 用同一个 Delphi 和 C++Builder 库处理 BIFF5 与 BIFF8 密码保护、XLSX Standard 与 Agile Encryption,以及读取端的密码回调,上面的互操作细节都替你处理好了。版本、平台与试用下载见 HotXLS Delphi 电子表格组件