














本文力求客观:既呈现 Acl.Excel 的实测数据,也如实标注测试口径、版本差异与已知局限,供读者自行判断。
口径说明:本文口径为 Acl.Excel 8 层纵深防御体系、默认阈值(1MB / 1GB / 2GB / 1,000,000)与"热路径零可测开销"声明;局限:通过
Stream传入文件时,大小检查由调用方负责。Excel系列文章 第9篇 / 共11篇
系列导航:← 上一篇:DEFLATE 引擎 · 下一篇:性能基准 →
本系列第六篇解析 Acl.Excel 的 8 层安全纵深防御体系。Excel 文件作为 ZIP 压缩包,天然承载着解压炸弹、XXE 注入、路径穿越等攻击面。Acl.Excel 在每一层都设置了可配置的护栏,在零性能损耗的前提下实现安全防护。
处理用户上传的 Excel 文件,本质上是把一份来自不可信来源的数据直接喂进了你的内存与文件系统。一个精心构造的 42KB 压缩文件,可以展开成 4.5PB 的 XML 数据(解压炸弹);一个含外部实体的 XML,可以触发 SSRF 攻击或读取服务器本地文件(XXE);一个含 ../ 路径的 ZIP 条目,可以覆盖目标目录之外的任意文件(ZipSlip)。这些都不是边缘案例,而是 OOXML 格式与生俱来的攻击面。
Acl.Excel 从第一行代码起就把这些风险纳入设计。本文逐项拆解它的 8 层纵深防御体系,告诉你每一层防什么、怎么配、为什么几乎零开销。读完你会清楚:在面对恶意文件时,哪些护栏在替你挡枪,以及生产环境里应该把阈值调到多大。
核心结论
- 一个 42KB 的文件可展开为 4.5PB 数据,传统"全量读入"会直接内存耗尽。
- Acl.Excel 用 8 层防御覆盖文件格式、解压、XML、路径、并发全链路,默认即安全。
- 所有护栏都可通过WorkbookOptions配置;关键计数为整数加法,热路径零可测开销。
- 关键修复点:早期版本遗漏了 CDATA 路径,曾被解压炸弹利用,现已纳入字符计数。
- 共享字符串表默认上限 1,000,000 条,防止 SST 膨胀攻击拖垮内存。
先看全局。下面这张图按"从外到内、从文件到内存"的顺序,列出了 8 层护栏各自的职责与默认行为:
┌─────────────────────────────────────────────┐
│ 第 1 层: FileStream 大小护栏 │
│ 默认 1MB (MaxWorkbookBytes),仅 FileStream 构造 │
├─────────────────────────────────────────────┤
│ 第 2 层: 格式校验 │
│ BIFF(.xls) 拒绝、非 ZIP 拒绝、魔数校验 │
├─────────────────────────────────────────────┤
│ 第 3 层: 解压炸弹护栏 │
│ XML 展开字符上限 1GB,单 Sheet 上限 2GB │
├─────────────────────────────────────────────┤
│ 第 4 层: XXE 禁用 │
│ XmlResolver=null, DtdProcessing=Ignore │
├─────────────────────────────────────────────┤
│ 第 5 层: XML 控制字符清洗 │
│ 写入时自动过滤非法 XML 控制字符 │
├─────────────────────────────────────────────┤
│ 第 6 层: ZipSlip 路径穿越过滤 │
│ 静默忽略含 .. 的关系 Target │
├─────────────────────────────────────────────┤
│ 第 7 层: 并发写锁 │
│ 所有状态变更在 _syncLock 内串行执行 │
├─────────────────────────────────────────────┤
│ 第 8 层: 共享字符串表上限 │
│ 默认 1,000,000 条,可配置 │
└─────────────────────────────────────────────┘
接下来逐层说明。
为什么要在最外层就拦截?因为一旦进入 ZIP 解析与 XML 解压,资源就已经开始被消耗。越早拒绝异常文件,浪费的计算越少。
Acl.Excel 有两个构造入口:new Workbook(string excelFileName)(文件路径)和 new Workbook(FileStream stream)(流)。两者行为不同:
Workbook(FileStream) 路径:会将整个流 CopyTo 到内存中持有,因此必须在构造阶段检查文件大小,防止大文件撑爆内存。默认上限 1MB(workbook.Options.MaxWorkbookBytes),超出即抛出异常。Workbook(string) 路径:从磁盘 File.OpenRead 流式读取,不把整文件载入内存,因此不强制此大小闸。这个护栏的设计意图是:FileStream 路径(如 ASP.NET 上传的 IFormFile.OpenReadStream())可能在调用方无法提前获知大小,由库兜底防护;而 string 路径(已知本地文件)调用方可自行判断,库不做额外限制以避免误伤正常的大文件场景。
为什么需要格式校验?因为"后缀是 .xlsx"不等于"内容是 OOXML"。在真正动手解析前先确认格式,能挡掉一大批伪装文件和旧格式,避免解析器吃到意外字节。
Acl.Excel 在打开文件时,EnforceExcelFileFormat 方法读取文件头前 2 字节进行魔数校验:
D0 CF(即 BIFF 格式的 OLE 容器头),立即拒绝。BIFF 是旧版 Excel 二进制格式,Acl.Excel 不支持。50 4B(PK ZIP 签名),视为格式错误拒绝。这防止了非 ZIP 文件被当作 Excel 处理。通过魔数校验后进入 LoadMetadata 正常解析 ZIP 内部结构,如果文件是合法 ZIP 但内容不是 OOXML,会在解析 workbook.xml 等元数据时自然失败。
这是最关键的防护层,也是 4.5PB 那类攻击真正被拦下的地方。
XML 的"十亿笑攻击"(Billion Laughs)和 ZIP 解压炸弹可以通过递归实体展开或极高压缩比,将几 KB 的输入展开为几 GB 甚至几 PB 的内存占用。
Acl.Excel 采用字符计数机制(expandedChars)追踪所有 XML 文本路径的展开字符数:
expandedChars(这是关键安全修复点,早期版本遗漏了 CDATA 路径)expandedCharsexpandedCharsexpandedChars默认上限:
WorkbookOptions.MaxExpandedXmlChars(静态全局)和 WorkbookOptions.MaxSheetBytes(实例级)配置当任意计数器达到上限时,读取立即抛出异常,防止内存耗尽。
为什么必须在解析器层面堵死外部实体?因为 XXE 的杀伤力不在内存,而在"借你的服务器之口"去访问内网、读取本地文件或发起请求(SSRF)。这不是资源问题,是越权问题。
在 XmlReader 创建时,Acl.Excel 显式禁用外部实体解析:
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore, // 忽略 DTD
XmlResolver = null, // 禁用外部解析
// ...
};
这阻止了 XXE(XML External Entity)注入攻击,攻击者无法通过外部实体引用触发 SSRF 或读取本地文件。
为什么写出的 XML 也要管?因为非法控制字符一旦进入文档,下游的解析器可能直接报错或行为异常,轻则数据损坏,重则被利用触发解析器漏洞。
XML 1.0 规范禁止某些控制字符出现在 XML 文档中(如 \x00-\x08、\x0B、\x0C、\x0E-\x1F)。写入时,Acl.Excel 自动过滤这些字符,确保输出的 XML 符合规范。
ZipSlip 的本质是"借解压之名,行写文件之实"。一个精心构造的条目名,就能把文件落到 Web 根目录或系统目录里。
在解析 .rels 关系文件时,Acl.Excel 检查每个 <Relationship> 元素的 Target 属性:若包含 ..(路径穿越特征),则静默忽略该条目,不将其加入内部的关系映射表。这意味着攻击者无法通过伪造的 Target 将文件引用指向 ZIP 包外路径。
为什么需要一把全局写锁?因为共享字符串表、Sheet 集合都是可变状态,并发写入会把它俩搞成半成品,最终产物就是损坏的 workbook。
Workbook 的所有状态变更操作(添加/删除 Sheet、修改共享字符串表、保存文件)都在 _syncLock 内串行执行。这防止了并发写入导致的状态不一致和文件损坏。
lock (_syncLock)
{
// 所有状态变更在此串行执行
}
SST 是解压炸弹之外的另一个"悄悄膨胀"入口:文件不需要高压缩比,只要塞进足够多的唯一字符串,就能把内存慢慢吃满。
共享字符串表(SST)在极端情况下可能被恶意构造:一个文件包含数百万条唯一字符串,每条都计入 SST。Acl.Excel 对 SST 条目数设置上限(默认 1,000,000),超过上限时抛出异常。
安全不是"开或关"的开关,而是"阈值多大"的取舍。Acl.Excel 把每一项护栏都暴露成可调参数,方便你按业务体量收紧或放宽。
所有安全护栏都通过 WorkbookOptions 暴露为可配置参数。MaxExpandedXmlChars 为全局静态配置,其余为实例级配置(通过 workbook.Options 访问):
// 全局静态配置 WorkbookOptions.MaxExpandedXmlChars = 200 * 1024 * 1024; // 200MB 展开字符
// 实例级配置(通过 workbook.Options 设置)
using var workbook = new Workbook("user_upload.xlsx");
workbook.Options.MaxWorkbookBytes = 100 * 1024 * 1024; // 100MB 文件上限
workbook.Options.MaxSheetBytes = 1024 * 1024 * 1024; // 1GB
workbook.Options.MaxSharedStringCount = 100_000; // 10 万条
那么这些检查会不会拖慢正常文件?不会。
所有 8 层防护在正常路径中几乎零开销:
XmlReaderSettings 的静态配置Contains 检查lock 语句这些防护不会在热路径上引入可测量的性能开销,但在面对恶意输入时能提供关键保护。
MaxWorkbookBytes、MaxSheetBytes、MaxSharedStringCount 等。免责声明:本文基于 Acl.Excel 安全纵深防御体系的设计与默认阈值(1MB / 1GB / 2GB / 1,000,000)撰写,测试口径与局限见正文;默认阈值与代码行为可能随版本演进变化,重要决策请自行复测核验。
客观性说明:本文的结论基于以下事实约束,而非自诩无偏——① 防护范围限定为 8 层纵深防御体系,覆盖文件格式、解压、XML、路径与并发全链路;② 默认阈值口径为 1MB(文件大小)/ 1GB(单 XML 流展开字符)/ 2GB(单 Sheet 展开字符)/ 1,000,000(共享字符串表条目数);③ "零可测开销"为设计层面的声明(格式校验只读文件头、字符计数为整数加法、路径校验为
Contains检查),非独立基准测量,且通过Stream传入文件时大小检查由调用方负责。以上局限已在正文相应位置如实标注,供读者结合完整信息自行判断。
Acl.Excel 系列文章(共 11 篇)
序号 文章 重点 ① 从 libdeflate 到纯 C#:Acl.Excel DEFLATE 解压/压缩器的移植与优化之旅 纯 C# 移植 libdeflate ② 从 libdeflate 到纯 C#(续):优化纯 C# DEFLATE 压缩器——从 1.6x 到 4.9x 的提速之路 14 轮优化提速 ③ Acl.Excel vs MiniExcel 1.45.0:性能对比与选型分析(.NET 8 基准) 百万行对等实测 ④ 不依赖 NPOI/ClosedXML,纯 C# Excel 库如何做到 30 倍性能? 概述与设计哲学 ⑤ 百万行Excel如何恒定内存?Acl.Excel流式拆解 读取引擎流式管线 ⑥ 24B消灭上亿次装箱:Acl.Excel零装箱设计 24B CellValue 零装箱 ⑦ 写入分配砍掉 63%:Acl.Excel 绕过 XmlWriter 字节直写 字节直写与并行写入 ⑧ 零依赖纯 C# DEFLATE 引擎:2916 行移植 libdeflate DEFLATE 引擎移植 ⑨ 一个 42KB 的 Excel 如何撑爆 4.5PB?Acl.Excel 8 层防御 安全纵深防御(本文) ⑩ Acl.Excel 跨库基准:读取最高快 32.8 倍 跨库性能基准 ⑪ 从实战到生产:Acl.Excel 八大场景与避坑指南(终篇) 实战与避坑指南 建议按 ①→⑪ 顺序阅读,从底层算法到实战落地形成完整认知。(当前本篇为第 9 篇,已以 粗体 标记。)
下一篇预告:Acl.Excel 将带来与 MiniExcel、ClosedXML、NPOI 的跨库性能基准对比,以及读取、写入、压缩各环节的优化数据。
标签(建议):Acl.Excel, .NET, 安全, Excel 处理, 纵深防御
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。