SSRF(服务端请求伪造)并不是“让服务器替客户端发一个 HTTP 请求”这么简单。真正难处理的地方在于:URL 解析存在差异、DNS 解析结果可能变化、重定向会改变目标,以及应用所在网络通常比普通用户拥有更高的访问权限。很多实际事件并非由单一漏洞触发,而是由“任意 URL 请求能力 + 缺少出口控制 + 内部服务认证薄弱”共同造成。
一、问题场景与风险边界
常见业务功能包括图片抓取、远程文件导入、Webhook 测试、在线网页预览、PDF 转换和第三方接口代理。这些功能本身并不等于漏洞,关键在于服务端是否允许用户完全控制请求目标,以及请求是否能够访问不应暴露的网络区域。
- 应用服务器访问本机回环地址、内网地址或链路本地地址。
- 应用所在网络可以访问管理面板、配置中心、监控接口等内部服务。
- 请求组件默认跟随重定向,导致初始校验通过后又跳转到受限地址。
- 域名校验和实际连接之间存在时间差,DNS 结果发生变化后仍继续连接。
- 日志只记录用户输入的 URL,没有记录最终解析地址、重定向链和出口策略。
在授权测试或内部实验环境中,应使用专门的模拟服务验证行为,不要直接探测生产内网、云平台元数据或第三方系统。
二、代码审计时的定位方法
审计时不要只搜索“SSRF”或某个 HTTP 库的函数名,而应沿着数据流寻找“用户可控字符串如何变成网络连接目标”。重点关注以下输入来源:
- HTTP 参数、JSON 字段、表单字段和导入文件中的 URL。
- Webhook、回调地址、图片地址、文件下载地址和代理配置。
- 经过数据库、消息队列或缓存转发后再次进入请求模块的值。
常见的危险汇点包括 Python 的 requests、urllib、Java 的 URLConnection、HttpClient,Node.js 的 fetch、axios,以及 Go 的 http.Client。审计时还要继续跟踪封装层,很多项目会把实际请求隐藏在“下载器”“预览器”或“远程资源解析器”中。
一个有价值的审计记录至少应包含:输入来源、URL 解析位置、协议限制、主机校验、端口限制、DNS 解析方式、是否允许重定向、连接超时、响应大小限制和出口网络策略。只标记“这里调用了 HTTP 客户端”通常不足以判断实际风险。
三、URL 校验中最容易出现的误区
- 只判断字符串是否以某个域名开头。解析 URL 时应以标准解析器得到的 hostname 为准,而不是直接进行字符串比较。
- 只阻断 127.0.0.1。还需要考虑 IPv6 回环地址、IPv4 映射地址、私有地址、链路本地地址、未指定地址和其他保留网段。
- 只校验域名一次。域名可能解析出多个 A 或 AAAA 记录,不能只检查第一个结果。
- 允许自动跟随重定向。每一次 Location 跳转都必须重新执行协议、主机、端口和 IP 策略校验。
- 将 DNS 解析和建立连接分成两个不受控制的步骤。校验通过的解析结果不应在实际连接时被重新解析成另一组地址。
- 把“域名在白名单中”当成完整安全边界。白名单域名被接管、配置错误或返回重定向时,仍可能成为风险入口。
四、一个用于审计的安全校验示例
下面的代码只演示解析和地址判定,不负责实际发起请求。生产实现还需要结合固定出口代理、连接地址绑定和完整的错误处理。示例的重点是:先规范化解析,再检查所有解析结果,而不是用字符串替换或正则表达式代替 URL 解析。
import ipaddress
import socket
from urllib.parse import urlsplit
ALLOWED_SCHEMES = {'http', 'https'}
ALLOWED_PORTS = {80, 443}
def validate_target(raw_url):
parsed = urlsplit(raw_url)
scheme = parsed.scheme.lower()
if scheme not in ALLOWED_SCHEMES:
raise ValueError('scheme denied')
if parsed.username or parsed.password:
raise ValueError('userinfo denied')
if not parsed.hostname:
raise ValueError('host missing')
try:
port = parsed.port or (443 if scheme == 'https' else 80)
except ValueError:
raise ValueError('invalid port')
if port not in ALLOWED_PORTS:
raise ValueError('port denied')
host = parsed.hostname.rstrip('.').lower()
addresses = set()
for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM):
addresses.add(item[4][0])
if not addresses:
raise ValueError('no address')
for address in addresses:
ip = ipaddress.ip_address(address)
if not ip.is_global:
raise ValueError('non-public address denied')
return scheme, host, port, addresses
这个示例仍有几个工程化要求:生产环境应明确处理域名大小写和尾部点号,统一 IPv4 与 IPv6 的表示;应记录实际解析到的地址;建立连接时要确保使用经过校验的地址,避免再次触发不受控解析;同时还要设置连接超时、读取超时、响应大小上限和总耗时上限。
五、推荐的防护架构
单靠业务代码中的黑名单往往难以长期维护,更稳妥的做法是将应用层校验与网络出口控制结合起来。
- 协议和端口最小化:业务不需要的协议全部禁用,端口使用明确的允许列表,不因“兼容性”开放任意端口。
- 目标白名单:对于图片、回调等固定场景,优先使用域名或服务标识白名单,而不是允许任意公网地址。
- 统一出口代理:让业务容器不能直接访问网络,由代理集中执行域名、IP、端口、证书和流量大小策略。
- 网络分段:应用运行区与管理区、数据库区、控制面隔离,拒绝不必要的东西向访问。
- 重定向重新校验:建议默认关闭自动重定向;确有业务需求时限制跳转次数,并对每一跳重新校验。
- 响应最小化:不向用户返回内部服务的完整响应,避免把状态码、响应头和错误堆栈原样暴露。
- 可观测性:记录请求来源、规范化后的目标、最终连接地址、跳转次数、拒绝原因和响应大小,日志中注意脱敏凭据及敏感查询参数。
六、在测试环境中如何验证修复有效
验证应围绕边界行为设计,而不是只用一个正常公网 URL 测试。可以在隔离网络中部署一个简单的模拟目标服务,并准备以下测试类别:
- 合法的 HTTPS 公网测试域名,以及业务允许的固定端口。
- 回环、私有、链路本地、IPv6 和 IPv4 映射形式的地址。
- 带用户名密码的 URL、异常端口、空主机和解析失败的域名。
- 返回 3xx 的模拟服务,用于确认重定向目标会被再次校验。
- 解析到多个地址的域名,用于确认所有结果都经过检查。
- 慢响应和超大响应,用于确认超时、大小限制和资源释放逻辑。
自动化测试应同时检查业务结果和审计日志。例如,拒绝请求时应返回统一错误,不泄露底层 DNS、连接库或内部地址;日志则应能够说明请求被哪条策略拒绝。修复后还要通过依赖升级、配置检查和网络连通性测试确认没有旁路路径。
七、事件复盘应关注什么
如果已经发现疑似 SSRF,不要只把某个参数加入黑名单。复盘时应回答四个问题:
- 入口在哪里:是公开接口、后台功能、第三方回调,还是异步任务?
- 能力有多大:能访问哪些协议、端口、地址段,是否支持重定向和长响应?
- 影响如何确认:哪些内部服务收到过请求,是否出现异常认证、配置读取或管理操作?
- 为什么没有及时发现:是否缺少出口日志、异常请求告警、网络隔离或安全测试用例?
处置阶段应先保留应用访问日志、代理日志、DNS 查询记录和相关时间窗口内的网络流量摘要,再按最小影响原则收紧出口策略、轮换可能暴露的凭据,并检查内部服务的认证和访问日志。对于无法确认影响范围的情况,应以时间线和证据链为基础进行判断,避免只依赖应用层返回结果。
八、总结
SSRF 防护的核心不是记住几个内网 IP,而是建立一条完整的控制链:正确解析 URL,限制协议和端口,检查所有 DNS 结果,控制重定向,在实际连接时固定经过校验的地址,并通过代理和网络分段降低单点失误的影响。
从审计角度看,最有效的方法是沿数据流确认“用户输入如何到达网络连接”,再结合代码、配置、容器网络和日志进行验证。只有把应用校验、出口控制、监控告警和事件响应放在一起,才能让修复从一次性补丁变成可持续的安全能力。
推荐意见