服务端请求伪造(SSRF)并不只存在于“允许用户提交 URL”这一类明显场景中。图片抓取、网页预览、Webhook 测试、文档转换、远程资源导入等功能,只要由服务端代替用户发起网络请求,就可能形成 SSRF 攻击面。实际审计中,真正困难的部分通常不是找到一个请求点,而是判断请求是否能访问不应暴露的网络区域、是否能被重定向绕过限制,以及修复后是否仍存在解析差异。
本文以 URL 代理服务为例,整理一套适合代码审计、漏洞复现和上线前验证的分析方法。示例均以本地测试环境和防御性验证为前提,不涉及访问真实内网资源。
一、问题场景:一个看似普通的 URL 抓取接口
典型业务代码可能类似下面这样:
def fetch_url(url):
response = requests.get(url, timeout=10)
return response.text如果接口直接接受外部输入并返回响应内容,风险通常包括:
- 访问仅对服务器可见的内部管理页面、监控接口或开发服务。
- 利用服务器所在网络的访问权限探测内网端口。
- 通过云环境中的特殊地址获取实例身份凭据等敏感信息。
- 借助重定向、DNS 解析差异或 IPv6 表示形式绕过简单黑名单。
- 利用响应内容、错误信息和请求耗时判断内部网络状态。
需要注意,SSRF 不一定要求攻击者能够读取完整响应。即使接口只返回状态码或耗时,也可能形成盲 SSRF,进一步影响内网资产探测和内部服务调用。
二、代码审计:先找“请求汇点”,再追踪输入来源
审计时不建议只搜索名为 url 的参数。更有效的方法是从网络请求库和封装层入手,建立“输入源—处理逻辑—请求汇点—输出”的数据流。
常见请求汇点包括:
- Python 的
requests、urllib、httpx。 - Java 的
HttpClient、OkHttp、RestTemplate、WebClient。 - Node.js 的
axios、got、原生http.request。 - 图片处理、PDF 转换、Markdown 渲染和第三方 SDK 中的远程资源加载功能。
输入来源不仅包括 HTTP 参数,还应关注数据库中可被用户间接控制的字段、消息队列内容、Webhook 配置、导入文件和后台任务参数。很多 SSRF 漏洞在同步接口中不明显,却在异步任务或管理员审核流程中出现。
审计记录建议至少包含以下字段:
| 项目 | 需要确认的问题 |
|---|---|
| 协议 | 是否只允许 HTTP 和 HTTPS,是否支持 file、ftp、gopher 等其他协议 |
| 主机 | 主机名是否可控,是否允许 IP、IPv6、短格式或非标准表示 |
| 解析 | 域名解析发生几次,校验解析结果与实际连接目标是否一致 |
| 跳转 | 是否自动跟随重定向,跳转后的地址是否重新校验 |
| 输出 | 是否返回响应正文、响应头、错误信息或耗时 |
| 网络 | 请求进程是否具备访问管理网段、容器网段或云元数据服务的权限 |
三、核心难点:URL 校验不是字符串匹配
很多初步修复只做黑名单判断,例如拒绝 127.0.0.1、localhost 或某几个内网网段。这类方案容易被 URL 解析差异绕过,也无法覆盖 DNS 重绑定和重定向问题。
1. 先限制协议和 URL 结构
应当使用成熟的 URL 解析器,而不是通过字符串截取判断主机名。解析后需要明确拒绝用户名密码、异常端口和不支持的协议。对于不需要携带凭据的业务,应拒绝 URL 中出现用户信息。
from urllib.parse import urlparse
ALLOWED_SCHEMES = {"http", "https"}
def parse_target(raw_url):
parsed = urlparse(raw_url)
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise ValueError("unsupported scheme")
if not parsed.hostname:
raise ValueError("missing hostname")
if parsed.username or parsed.password:
raise ValueError("userinfo is not allowed")
return parsed这段代码只能完成基础语法检查,不能单独作为 SSRF 防护。后续还需要对解析得到的地址进行网络范围判断,并处理重定向和连接目标一致性。
2. 对解析结果进行 IP 范围判断
推荐使用标准库的 IP 地址类型判断,而不是手写正则。至少应拒绝回环地址、链路本地地址、未指定地址、私有地址、组播地址和保留地址。具体范围应结合部署环境补充,例如容器网络、服务网格和企业内部专用网段。
import ipaddress
import socket
def resolve_public_addresses(hostname):
results = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
addresses = {item[4][0] for item in results}
if not addresses:
raise ValueError("hostname has no address")
for value in addresses:
address = ipaddress.ip_address(value)
if not address.is_global:
raise ValueError("target address is not public")
return addressesis_global 可以作为通用起点,但不应被理解为完整的业务策略。某些企业环境会使用特殊的公网地址规划,某些云网络也可能存在需要额外封禁的保留范围。生产环境最好维护明确的允许列表或出口代理策略。
3. 防止 DNS 重绑定
如果只在请求前解析一次域名,实际连接时库可能再次解析,并连接到不同地址。攻击者可以让同一域名先返回公网地址通过校验,再在连接阶段返回内网地址,这就是常见的 DNS 重绑定思路。
较稳妥的方案是:
- 在受控 DNS 解析器中完成解析,并记录最终地址。
- 连接时固定使用已经校验过的地址,而不是让 HTTP 客户端再次自由解析。
- 通过自定义连接器或出口代理确保“校验的地址”和“实际连接的地址”一致。
- 对每次重定向后的主机重新执行解析和网络范围校验。
- 在网络层限制请求进程不能访问敏感网段,避免单一解析缺陷直接造成严重影响。
在高安全要求场景中,可以让应用只向一个内部出站代理发起请求,由代理统一执行 DNS、地址范围、协议、端口和审计策略。这样比在多个业务服务中重复实现更容易维护。
四、重定向是经常被遗漏的第二条路径
即使初始 URL 指向允许访问的站点,也可能通过 301、302、303、307 或 308 跳转到不允许的地址。如果客户端默认跟随重定向,而业务代码只校验初始 URL,防护就会被绕过。
建议将自动重定向关闭,改为业务代码逐跳处理:
MAX_REDIRECTS = 3
# 伪代码:每一次跳转都重新执行 parse、解析和地址范围校验
current_url = user_input
for _ in range(MAX_REDIRECTS + 1):
target = parse_target(current_url)
checked_addresses = resolve_public_addresses(target.hostname)
response = request_once(current_url, checked_addresses, allow_redirects=False)
if response.status_code not in {301, 302, 303, 307, 308}:
break
location = response.headers.get("Location")
if not location:
break
current_url = resolve_relative_url(current_url, location)还要限制跳转次数、响应体大小和总耗时。跳转地址可能是相对路径,也可能改变协议、端口和主机,不能简单拼接字符串后继续请求。
五、请求行为本身也需要收敛
URL 校验通过并不代表风险已经消失。请求客户端的默认行为可能扩大攻击面,建议重点检查以下配置:
- 仅允许业务需要的 HTTP 方法,普通资源抓取通常不需要任意方法。
- 禁止或严格限制自动跟随重定向。
- 关闭不必要的代理环境变量继承,避免请求被导向意外代理。
- 设置连接超时、读取超时、总超时和最大响应体大小。
- 限制响应头数量、单个头长度和压缩解压后的大小,防止资源消耗攻击。
- 不要将完整响应头、内部错误堆栈和底层连接异常直接返回给用户。
- 根据业务需要设置固定的 User-Agent,避免携带服务器内部身份信息。
如果功能只需要获取图片,应进一步限制内容类型、文件大小和文件解析器,而不是返回任意响应正文。下载后的文件应在隔离环境中处理,并对压缩包、脚本文件和多格式解析器保持警惕。
六、推荐的防护架构:应用校验加网络隔离
单纯依赖业务代码很难覆盖所有库行为和解析边界。比较可靠的防护通常分为三层:
应用层
- 只允许明确的协议、端口和 URL 结构。
- 使用标准解析器处理主机名、端口、路径和重定向。
- 对所有解析结果执行地址范围检查。
- 限制请求方法、跳转次数、响应大小和超时时间。
出口代理层
- 所有外部请求统一经过代理,业务服务不直接访问任意网络。
- 代理按域名、端口、解析地址和目标分类执行策略。
- 保留必要的请求审计信息,但避免记录敏感查询参数和完整响应正文。
网络层
- 通过安全组、防火墙或网络策略禁止访问管理网段、控制面和不必要的内部服务。
- 将执行远程抓取的任务放入独立网段或低权限容器。
- 在云环境中限制实例身份服务的访问路径,并优先使用平台提供的网络防护机制。
应用层校验解决“业务是否允许”,网络层隔离解决“即使校验出错,进程还能访问什么”。两者不能相互替代。
七、测试方法:在本地构造可控验证环境
测试 SSRF 修复时,不建议直接对生产网段或云平台特殊地址进行探测。可以在本地启动两个服务:一个作为允许访问的测试站点,另一个模拟被禁止的内部服务,再通过日志确认请求是否到达。
# 仅用于本地测试目录,不连接真实内网资源
python3 -m http.server 18080 --bind 127.0.0.1测试用例应覆盖以下类别:
- 合法的 HTTP 和 HTTPS 地址。
- 不支持的协议和缺失主机名的 URL。
- 本地回环地址、私有地址、链路本地地址和 IPv6 地址。
- 域名解析到多个地址,其中包含被禁止地址的情况。
- 初始地址合法、跳转地址不合法的情况。
- 超过最大响应体、超时和连接失败的情况。
- 包含用户名密码、异常端口、空端口和非标准 URL 编码的情况。
- 同一域名解析结果变化时,校验地址与实际连接地址是否一致。
验证结果不应只看接口返回值,还要结合应用日志、代理日志和网络流量确认实际连接目标。对异步任务,还需要检查任务队列、重试机制和失败日志,避免请求失败后被无限重试。
八、日志与告警:避免把防护变成盲区
建议记录请求的规则判定结果、目标域名、解析地址类别、响应状态、耗时、跳转次数和拒绝原因。出于隐私和合规考虑,不必记录完整 URL 查询参数或响应正文。
以下行为值得设置告警:
- 同一调用方短时间提交大量不同域名或端口。
- 连续命中本地、私有、链路本地或保留地址拦截规则。
- 大量请求因 DNS 解析变化或重定向被拒绝。
- 远程抓取任务出现异常高的超时、连接失败或响应体超限。
- 业务服务尝试访问不属于允许出口范围的网络地址。
日志中的“拒绝原因”应使用稳定的分类码,例如 SCHEME_DENIED、PRIVATE_ADDRESS、REDIRECT_LIMIT,方便统计和回归测试,避免直接暴露底层异常。
九、常见修复误区
- 只封禁 localhost:回环地址并不只有一个字符串表示,且风险范围远不止本机服务。
- 只检查初始 URL:重定向后的目标同样必须重新校验。
- 只校验域名,不校验解析地址:域名可能解析到多个地址,也可能受 DNS 重绑定影响。
- 把“公网地址”当成绝对安全:公网服务本身可能是内部控制入口或存在开放重定向。
- 只在应用层修复:网络出口没有限制时,单点代码错误仍可能产生严重后果。
- 返回详细异常:底层错误可能泄露 DNS、端口、代理和内部网络信息。
总结
SSRF 审计的重点不是寻找一个特殊字符串,而是完整理解“用户可控输入如何变成服务器的网络连接”。可靠的防护至少应覆盖协议限制、标准 URL 解析、解析地址校验、DNS 与连接一致性、逐跳重定向检查、请求资源限制,以及应用之外的出口网络隔离。
从工程实践看,最值得优先落实的三件事是:第一,统一封装安全的远程请求组件,避免业务各自实现;第二,让远程请求经过受控出口代理,并在网络层阻断敏感网段;第三,为协议、解析、重定向和超时等边界建立自动化回归用例。这样才能把一次漏洞修复,转化为后续功能迭代仍然有效的长期防线。
推荐意见