技术文章

HotXLS 检测被篡改的 XLSX:Agile dataIntegrity HMAC

HotXLS 会在 Agile 加密的 XLSX 包中写入一个符合规范的 dataIntegrity 块,并在打开时校验它。HMAC-SHA-512 覆盖完整的 EncryptedPackage 流,包括它开头 8 字节的 StreamSize 前缀,并且是在任何分段被解密之前,先对密文本身做校验,因此密码错误或包被篡改会被直接检测出来,而不是被解密成一堆乱码

只有加密而没有完整性校验,只是答对了一半,而 Office 文件格式又让这个缺口特别容易被忽视,因为从外面看加密显得非常彻底。搞清楚每一层各自承诺了什么,才能让安全审查做得又快又准

一份加密工作簿究竟承诺了什么?

[MS-OFFCRYPTO] 中定义的 Agile 加密,通过 CBC 模式的 AES、配合从迭代 SHA-512 密码哈希派生出的密钥,为你提供保密性。保密性就是这套方案的全部承诺。CBC 不是一种认证模式:它完全不能说明你正在解密的密文,是不是当初写入的那份密文

由此带来的实际后果很具体:在加密包里翻转几个比特位,CBC 会心安理得地把它们解密成不同的明文。下游通常会得到一个 ZIP 解析错误,因为被破坏的 deflate 流很少能幸存下来,但这句话里的"通常"承担了太多风险,而在下游解析器报错的地方才发现文件被改过,是个很糟糕的时机。dataIntegrity 元素存在的意义,就是在解密之前,直接用一个覆盖精确字节的 MAC 来回答这个问题

校验如何运行,顺序又是怎样的

顺序才是有意思的地方。HotXLS 先从密码派生出中间密钥,用块密钥派生出的 IV 解密 dataIntegrity 属性中加密过的 HMAC 密钥和 HMAC 值,再对存储状态下的加密包计算 HMAC-SHA-512 并进行比较。只有在这之后,分段解密才会开始

对密文而不是明文校验 MAC,是标准的"先加密后 MAC"规范做法,这也是让这次校验真正有意义的原因:被篡改的包会被直接拒绝,攻击者可控的字节根本不会走到解密和解压路径里去。打开文件路径中的两处比较——密码校验器哈希和 HMAC 值——都是在整个摘要范围内用 XOR 和 OR 累积差异,而不是在第一个不匹配的字节处提前返回,因此两者都不会通过时序泄露字节位置信息

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    // 对普通文件、Standard 加密文件和 Agile 加密文件都适用
    if Book.OpenEncrypted('incoming.xlsx', PasswordFromVault) = 1 then
      ProcessWorkbook(Book)
    else
      // 密码错误,或者包的 dataIntegrity HMAC 不匹配
      Quarantine('incoming.xlsx');
  except
    on E: Exception do
      Quarantine('incoming.xlsx: ' + E.Message);
  end;
  Book.Free;
end;

在写入这一侧,你的代码完全不用改。SaveAsEncrypted 会自动生成这个块,盐值、校验器输入和 HMAC 密钥都来自 CryptGenRandom。如果这次调用失败,HotXLS 会直接抛出异常,而不是退回到一个更弱的随机源。CSPRNG 失败即拒绝,这不是过度谨慎——静默降级到可预测的随机源,产出的文件看起来像是加密了、能通过每一项功能测试,却毫无价值

为什么没有这个块的文件仍然能打开?

因为现实中流通的大量 Agile 加密工作簿,都是由完全省略了 dataIntegrity 的生成端写出来的,直接拒绝它们所破坏的合法工作,会远远多于它所保护的东西。HotXLS 只有在加密 HMAC 密钥和加密 HMAC 值这两个属性都存在且格式正确时,才认为完整性信息存在。否则校验会被跳过,文件照常打开

这是一个带有安全后果的兼容性决策,你应该在自己的威胁模型里把它明确写出来:块缺失和攻击者把它删掉,是无法区分的两种情况,因为这些属性本身在它们所携带的 MAC 覆盖范围之外。如果管道两端都由你掌控,就应该把块缺失当作应用层面的策略失败来处理。如果你接收的是来自任何人的文件,就该把这项检查看成它本来的样子——存在时是有价值的信号,不存在时则完全不构成信号

"修改密码"是一种约定,不是一道安全边界

经典的 XLS 工作簿支持一种经常和加密混淆的独立机制:写保护,也就是 Excel 里的"修改密码"提示框。HotXLS 通过 SetModifyPassword 暴露这项功能,参数包括密码、一个"建议只读"标志和预留用户名,状态则通过 IsWriteReserved 读取。传入空密码会清除写保护

写入的内容是一对 WRITEPROT 和 FILESHARING 记录,携带建议只读标志、一个遗留的 16 位密码哈希,以及以 BIFF8 Unicode 字符串形式保存的用户名。这个 16 位哈希是一个校验和,不是密码学摘要,文档内容也完全没有被加密。任何人用别的工具打开这个文件都能读到全部内容。这项功能真正的作用是协调:它告诉后面打开文件的人,有人认为这份文件目前归自己编辑,和XLSX 工作表保护与 allow 选项一文中介绍的工作表级控制属于同一类机制

var
  Book: IXLSWorkbook;   // 接口计数管理:不要调用 Free
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('shared-model.xls') = 1 then
  begin
    // 建议只读,由报表服务预留
    Book.SetModifyPassword('edit-me', True, 'Reporting Service');

    if Book.IsWriteReserved then
      Book.SaveAs('shared-model.xls', xlExcel97);
  end;
end;

让这两层机制各自发挥所长。真正的保密性来自 SaveAsEncrypted,配合一个圈外人都不知道的密码,产出的正是AES 保护的 XLSX 输出一文中描述的 AES-256 输出。当工作簿是一份共享编辑的制品、你希望 Excel 在别人保存覆盖之前先弹窗确认时,就在上面再加一层写保护

在不受信任的接入路径上要检查什么

完整性校验保护的是加密载荷本身,而不是包裹它的容器。XLSX 文件是一个 ZIP 归档,归档结构会在任何加密逻辑运行之前先被解析,因此容器级别的校验应该排在链条的最前面;具体的失败模式在面向不受信任 XLSX 的 ZIP 中央目录结束记录校验一文中有说明。在那之后,应该把完整性校验失败和密码错误当作同一类运维事件来处理,因为从你这一侧看,这两者在设计上就是无法区分的,二者都意味着这份文件不能被信任为发送者所认为的那个样子

应该记录哪些文件带有 dataIntegrity 块。在几千份文档的规模上,这项统计能告诉你发送方所用工具链的一些有用信息,把单个文件的检查变成一个可以据此采取行动的整体性观察

HotXLS 让 Delphi 和 C++Builder 无需安装 Excel 即可读写 XLS、XLSX 和 ODS,用 Pascal 原生实现了 [MS-OFFCRYPTO] 的 Standard 和 Agile 加密路径。加密、保护和工作簿 API 都记录在 HotXLS Delphi 电子表格组件页面