一份 xlsx 文件本质上是一个 ZIP 归档,而 ZIP 并没有唯一权威的目录表。面向 Delphi 和 C++Builder 的 HotXLS Excel Library 把这种模糊性当作一个攻击面来对待:它的目录结束记录解析器,只有在四项独立的交叉检查全部一致通过之后,才会接受一个候选记录,所以藏在 ZIP 注释里的伪造目录永远赢不了
让这个问题变得具体的场景很平常。一台服务器接受客户上传的电子表格。文件通过了杀毒扫描,被写进一个待处理目录,你的 Delphi 服务打开它,只是想取出三列数据。看起来一切正常,除了一点:扫描器和你的解析器对这份归档到底包含什么并没有达成一致。扫描器枚举出了一组成员,你的加载器从同一批字节里枚举出了另一组。两者在通常意义上都没有 bug。它们只是在两个不同的方向上解决了 ZIP 格式里的一处歧义,而攻击者恰好选择了能让这种分歧发生的字节
关于一个 ZIP 归档的真相,到底存在哪里
它存在文件最末尾,一个叫作目录结束记录、只有 22 字节的结构里。ZIP 文件不是从头到尾顺序读取的:每个成员前面紧跟着一个本地文件头和它的压缩数据,但真正权威的索引是中央目录,一段位于文件末尾附近、给出每个条目名称和其本地文件头偏移量的记录序列。要找到中央目录,你必须先找到 EOCD,因为正是 EOCD 说明了目录从哪里开始、有多少条记录。HotXLS 用 TEndOfCentralDirectoryRecord 对它建模,其字段和磁盘上的布局一一对应:偏移量 4 处是 FDiskNumber,6 处是 FStartDisk,8 处是 FThisDiskEntries,10 处是 FTotalEntries,12 处是 FSizeOfCD,16 处是 FOffsetOfStartCD,20 处是 FCommentLen。这个总长度就是 FMinSize,在构造函数里算作 4*3 + 5*2。它之后跟着归档注释,最多可以是 65535 字节的任意内容,这就使 FMaxSize 达到 65557,也意味着这条记录并不在一个固定位置上。你得自己去找它
为什么倒着扫描 EOCD 签名还不够
因为你要找的那四个字节,PK\005\006,完全可以合法地出现在归档注释里、出现在压缩数据里,也可以出现在攻击者故意追加的第二个 EOCD 里。一个从后往前扫描、遇到第一个签名就停下的解析器,非常容易被引导:在文件尾部附近放一个诱饵 EOCD,朴素的解析器就会跟着它走,而一个按不同顺序扫描、或者把文件里最后一个签名当作权威的解析器,则会跟着真正的那个走。这属于 ZIP 歧义攻击这一大类,它的收益正是上面描述的那种分裂:扫描引擎和消费方应用程序从同一份文件里看到了不同的条目集合
TEndOfCentralDirectoryRecord.Parse 确实是倒着扫描的。它把 startscan 设为最后一个字节,把 endscan 钳制到 lsize - FMaxSize 或零,并以 256 字节为一个缓冲区窗口来回走,相邻缓冲区之间重叠三个字节,这样一个跨越缓冲区边界的签名就不会被漏掉。区别在于命中之后发生了什么。找到签名只会产出一个 Candidate 偏移量。HotXLS 随后会读取该偏移量处的 22 字节,用 ReadEOCD 解析它们,并且要求解析出来的字段和它们自称描述的那个文件在内部保持一致,然后才会把 FOffsetEOCD 真正赋值
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
把这个判断条件拆开来看,是四条必须同时被一份伪造记录满足的独立主张。Candidate + FMinSize + FCommentLen = lsize 要求声明的注释长度恰好延伸到文件末尾,这正是能识破"注释里藏诱饵"这个手法的关键:一个埋在真实注释里的假 EOCD,没办法同时把自己之后的每一个字节都算进去。FDiskNumber = 0 和 FStartDisk = 0 拒绝了那些没有任何一份 xlsx 会正当使用、只存在于精心构造的归档中用来制造混乱的多卷分割字段。FThisDiskEntries = FTotalEntries 拒绝了"分裂计数"手法,即一个解析器按其中一个字段确定循环次数,另一个解析器按另一个字段确定。而 Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate 要求中央目录恰好在 EOCD 开始的地方结束,这样目录就没法被指向文件里别的什么无关数据块。最后这一处的 Int64 类型转换很关键:两个操作数都是 32 位的,如果不做位宽扩展,一对精心构造的数值就可能发生回绕,在算术上恰好满足这个测试,实际却指向一个毫无意义的地方
本地文件头必须和中央目录保持一致
EOCD 检查确定了哪个目录是权威的;它们还没能保证这个目录对每个成员说的都是实话。ZIP 文件里每个条目都被描述了两次,一次在中央目录里,一次在它自己的本地文件头里,而格式本身并不强制这两份描述互相一致,所以一个信任中央目录的读取器和一个信任本地文件头的读取器,可能从同一个归档里提取出不同的内容。TZipEntry.ParseLocalHeader 通过解析 FCdFile.LocalFileHeaderOffset 处的本地文件头、逐字段比较这两份副本来堵上这个缺口,每一种不一致都会返回各自专属的负数错误码:规范化后的条目名称、压缩方法、通用位标志,以及在数据描述符标志未设置时的 CRC32 和两个大小字段。当这个标志被设置时,本地副本的这几个值可以为零,因为真实数值存在紧随其后的一个数据描述符里,但任何非零的本地值仍然必须匹配。最后一项检查会拒绝那些数据范围会跑出文件末尾的条目,做法是比较 Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) 和 inputstream.Size。任何失败都会作为一个非 1 的结果从 TCentralDirectory.Parse 中传出,TZipArchive.OpenArchive 会把它转换成 Can't open zip archive,而不是把一个半信半疑的归档对象交到你手上。当你只需要知道一份文件里有哪些工作表时,在完整解析之前先跑一遍这项校验成本很低,轻量级工作表清单检查路径正好能给你这个结果,而不需要实体化任何单元格数据
当字节本身在撒谎时会发生什么
结构上的一致性依然说明不了负载内容本身,所以 HotXLS 把每个条目流都包在 TZipVerifiedStream 里,在调用方读取的同时强制核对声明的大小和 CRC32。这刻意不是一次事后检查:一个声明解压后大小为 4 KB、实际却膨胀到数吉字节的解压炸弹,会在 4 KB 那个点就被拦下,而不是在损害发生之后。这个包装器把每次读取都钳制在剩余声明字节数以内,如果源数据提前枯竭就抛出 ZIP entry ended before its declared size,读完之后再探一个多余字节,如果还有剩余就抛出 ZIP entry exceeds its declared size,最后在 VerifyComplete 里比较累计的 CRC32,不一致就抛出 ZIP entry uncompressed size mismatch 或 ZIP entry CRC32 mismatch
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
有一个后果值得提前规划。这个流按设计是只能向前读的;Seek 到除当前位置以外的任何地方都会抛出 ZIP entry stream is forward-only,唯一的例外是偏移量为零的 soEnd,这样查询大小的操作依然可用。对不可信输入来说这是正确的取舍,因为一个能倒回去的流,就是一个你能绕过其 CRC 核算的流,但这也意味着期望一个可寻址流的消费方代码,需要自己准备一个缓冲区。同样这种只能向前的原则,也支撑着流式直读器,当上传的工作簿大到你完全不想让它常驻内存时,这正是该用的 API
资源限制要在分配之前生效,而不是之后
lxZipArchive 里的三个常量约束了单个归档能要求进程做多少事,TZipEntries.Add 会在中央目录还在读取过程中、在任何一个条目数据字节被碰到之前就应用它们。ZipMaxEntryUncompressedSize 把单个成员限制在 1 GiB,ZipMaxTotalUncompressedSize 把整个归档限制在 4 GiB,ZipMaxCompressionRatio 设为 10000,会拒绝任何声明膨胀倍数超过一万倍的 deflate 条目,也包括"非零解压大小配零压缩大小"这种退化情形。条目名称在同一次调用里会经过 CanonicalZipEntryName,它会用 Invalid ZIP entry name 拒绝内嵌的 NUL 字符、冒号,以及任何 .. 路径段,还会对各段做小写化和归一化处理,让两个仅在大小写或多余分隔符上有差异的成员,以 Duplicate ZIP entry name 的形式冲突,而不是悄悄互相遮盖
ZIP 层之上的纵深防御
ZIP 层只是若干层防御中的一层,只要 HotXLS 在解析攻击者可控的结构,这种模式就会反复出现。最清晰的例子在 BIFF 公式解析器里:TXLSFormula.GetTranslated 会递归处理 tMemFunc 令牌,所以一份老式 .xls 里精心构造的 rgce 令牌流可以任意深度嵌套,从而耗尽栈空间。防线是一个常量 MaxTranslateDepth = 256,这个数字是对照一个已知的上游事实定出来的,不是猜出来的。Excel 把公式嵌套上限定为 64,所以 256 留出了四倍的余量,绝不会拒绝一份真实电子表格产生的公式,同时依然能在栈耗尽之前的很早阶段终止一个恶意流
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
注意这道防线返回的是 nil,而不是抛出异常。一条深到不像真实公式的公式得不到语法树,周围的解析过程照常继续,工作簿依然能加载。这种不对称是刻意为之的,也值得你在自己的限制逻辑里照搬:一个用来阻止资源耗尽的边界,应该只降级它能降级的最小单元,而不是中止整份文档。同样的道理也适用于你扩展计算层的时候,所以如果你通过公式引擎自定义函数 API注册自己的处理函数,请给它们自己的参数和递归限制,不要假设调用方已经替你检查过了
这些检查买不到什么
要把这条边界说清楚。四项 EOCD 交叉检查让归档索引变得没有歧义,所以 HotXLS 和任何其他遵循规范的读取器都会把同一份文件解析成同一组条目;但它们完全不说明这组条目本身是否无害。本地文件头一致性检查阻止的是"两种视图"这个手法,而不是一个被一致描述出来的恶意负载。经过校验的流阻止的是截断、溢出和损坏,而不是一段格式完全良好、却编码了你没预料到的东西的 XML 部件。而这一切都碰不到宏:一份结构上无懈可击的工作簿里如果带着一个 VBA 工程,它依然是一个 VBA 工程,是保留、剥离还是拒绝它,这个决定属于你的策略层,不属于 ZIP 读取器
你换来的是一条干净的失败边界。一份不可信的 xlsx,要么作为一个没有歧义的归档打开,其成员和它们声明的大小、校验和完全吻合,要么就抛出一个明确点出具体违反了哪条不变量的错误消息,你的服务可以依据这个异常做隔离处理,而不用去猜。ZIP 读取器以及它上面的各层解析器,都作为面向 Delphi 和 C++Builder 的 HotXLS Excel Component 的一部分随附提供,执行解析的机器上既不需要 Excel,也不需要 OLE 自动化,这一点本身就有意义地缩小了一份上传文件所能触及的范围