XML 的两大经典攻击都瞄准解析器对外部实体与 DTD 的默认宽容:XXE(XML External Entity)注入借实体声明读取服务器本地文件甚至发起内网请求;实体膨胀炸弹(billion laughs)用嵌套实体指数放大内存。承 5.1 节末的预告,本节演示攻击、给出各语言的三行防御,并清点次级攻击面。
背景:某接口接收用户上传的 XML 并解析入库,解析器默认开启外部实体。攻击者上传:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> <!-- 声明外部实体:SYSTEM 表示值来自外部系统资源 --> ]> <foo>&xxe;</foo> <!-- 解析器跟随声明读取本机文件,内容替换到 &xxe; 处 -->
操作与结果:程序解析后把 foo 的文本内容存库并回显——/etc/passwd 的内容就这样流出。变式更凶:SYSTEM "http://内网地址/管理端口" 让服务器替攻击者发内网请求(SSRF);Windows 上换 file:///c:/ 系列路径。解读:漏洞不在 XML 语法,在解析器替文档执行了"去外面拿东西"这个动作——文档是不可信输入,它无权指挥服务器读文件。
防御的统一思想:不信任的输入,禁 DTD、禁外部实体。各语言三行配置:
// Java DOM/StAX 通用防御 factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setXIncludeAware(false); // 顺带关掉 XInclude 包含机制
# Python 用 defusedxml 替换标准库的解析入口 from defusedxml import ElementTree as SafeET tree = SafeET.parse(untrusted_file) # 默认禁止实体扩展
命令行工具 libxml2 系(xmllint 等)多数已默认禁外部实体,但老版本与自编译关闭安全开关的构建仍需核查。
<!DOCTYPE bomb [ <!ENTITY a "lololol...lol"> <!-- 几十字节 --> <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"> <!-- a 的十倍 --> <!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;"> <!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;"> <!ENTITY e "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;"> ]> <bomb>&e;</bomb> <!-- 数十字节的文档,展开后数 GB —— 内存瞬间耗尽,服务拒绝 -->
三层嵌套十倍放大即千倍,五层十万倍……"十亿声笑"由此得名。防御与 XXE 同源:禁 DTD 一并废掉所有实体扩展;实在需要内部实体的场景,对实体总数与展开体积设上限(部分解析器提供阈值参数)。

XSLT 的 document() 函数能读远程资源——给不可信的样式表等于给攻击者读文件的另一只手,转换样式表必须视为可信代码而非数据。XInclude 同理。XML 签名/加密(WS-Security 体系的基础件)解决的是"传输途中被篡改/窃听",与上述注入防御互补,属于另一条防线。
纵深布防清单:入口层禁 DTD(第一闸);校验层用白名单 XSD 收结构(第 3 章能力);业务层对字段长度与值域设限;监控层记录解析异常。四层各拦一类,单层失守不致全线崩。这套清单的顺序也是排错顺序:出事故后从第一层开始倒查,最外层的闸门最先验明正身,逐层向内收窄——比一上来就翻业务代码高效得多。
⚠️ 上线前自查一问:产品里所有解析 XML 的入口,逐个确认禁 DTD 配置已写——框架默认值会随版本变化,"当年配过"不等于"现在还关着"。
防御配置写完不算完,要验收。最可靠的验收方式是拿攻击样例打自己:把本节的 XXE 文档与炸弹文档放进测试用例,断言解析结果是被拒绝(抛出安全异常)而非被处理。这条测试建议长期保留在回归套件里——6.1 节开头提过"框架默认值随版本变化",升级依赖后这条用例就是你的报警器。
再补一层认知:XML 安全与"注入类"安全的思路完全同构。SQL 注入防御靠参数化查询,XSS 靠输出转义,XXE 靠禁外部实体——共同点都是把"数据"与"指令"严格隔离。XML 的问题恰恰是它的 DOCTYPE 把"指令"(去哪里取实体)混进了"数据"(文档本体),防御就是撤销这个混合通道。理解了同构关系,遇到下一门带声明结构的格式(YAML 的锚点、Protobuf 的 proto 文件)时,第一反应就该是查它的"数据里藏指令"通道在哪、默认开没开。安全的直觉比配置命令更保值——命令会过时,"数据和指令要隔离"这条原理在可预见的未来都成立。
自检清单也给一条极简版,贴在解析代码评审表里即可:一问"这处解析的输入来自谁"——外部输入必禁 DTD;二问"解析器与依赖库版本多久没审了"——升级后跑一次攻击样例回归;三问"报错日志里有没有实体相关的告警被忽略"——实体告警几乎从不是误报。三问全过,XXE 这一类风险基本清零。
敌意输入防住了,接下来处理"多到装不下"——海量文档的存储选型。