SSRF(Server-Side Request Forgery,服务端请求伪造)在实际项目中并不一定表现为一个明显的“请求外部 URL”接口。图片抓取、Webhook 调试、文件导入、URL 预览、第三方回调检测等功能,都可能让服务器代替用户发起网络请求。审计这类问题时,单纯搜索 requests.get 或 HttpClient 往往不够,更重要的是沿着“用户可控输入—URL 解析—网络连接—响应处理”的完整链路确认风险边界。
一、问题背景与典型场景
SSRF 的核心不是“服务器访问了一个 URL”,而是攻击者能够影响服务器访问的目标、协议、端口或请求内容,并借助服务器所在网络环境获得普通客户端无法获得的访问能力。
- 图片或网页预览:后端接收 URL,下载内容后返回给前端。
- Webhook 测试:用户输入回调地址,服务端向该地址发送测试请求。
- 文档解析:服务端根据文档中的外部资源地址加载图片、字体或样式。
- 云资源集成:后端根据用户提交的地址访问对象存储、内部 API 或代理服务。
- 健康检查功能:管理员输入地址,系统主动探测服务是否可用。
这些功能本身未必存在问题,真正需要关注的是:目标地址是否完全可控,校验发生在 DNS 解析前还是后,是否允许跟随重定向,以及请求是否携带了内部身份凭据。
二、审计时先画出完整数据流
建议不要从某一个危险函数开始,而是先建立数据流图。一个典型链路如下:
HTTP 参数 / JSON 字段
↓
URL 规范化与解析
↓
协议、主机、端口校验
↓
DNS 解析
↓
建立 TCP / TLS 连接
↓
发送请求并处理重定向
↓
读取响应、保存文件或转发给用户
审计时可以分别回答以下问题:
- 输入来自哪里,是否经过了鉴权和权限校验?
- 校验的是原始字符串、解析后的主机名,还是最终连接的 IP 地址?
- 是否允许
http以外的协议,例如file、gopher或自定义协议? - DNS 解析是否可能返回多个地址,程序是否逐个检查?
- 请求是否自动跟随 30x 重定向,重定向后的地址是否重新校验?
- 是否存在代理配置、IPv6、Unix Socket 或其他非预期的网络路径?
- 响应内容是否会被当作 HTML、XML、图片或压缩包继续解析?
三、最容易被忽略的校验绕过点
1. 只做字符串黑名单
例如仅判断 URL 中是否包含 127.0.0.1、localhost 或某个内部域名。这种做法很脆弱,因为同一个地址可能存在不同表示形式,IPv4、IPv6、十进制整数、短格式地址以及大小写和尾点等写法,都可能导致字符串判断与实际连接目标不一致。
正确思路是先使用标准 URL 解析器拆分协议、主机、端口和路径,再对规范化后的主机进行解析,并对解析结果执行 IP 分类判断。校验对象应该是“最终要建立连接的 IP”,而不是未经解析的原始字符串。
2. DNS 解析与连接之间存在时间窗口
有些实现先调用 DNS 解析判断地址是否安全,之后又把原始域名交给 HTTP 客户端建立连接。两次解析的结果可能不同,尤其是在攻击者可控 DNS、短 TTL 或代理环境下,检查结果和实际连接目标可能不一致。
更稳妥的做法是:
- 由应用统一完成解析并取得地址列表。
- 对所有解析结果进行安全判断,而不是只检查第一个地址。
- 建立连接时固定使用已校验的地址,同时保留正确的 Host 和 TLS Server Name。
- 在网络层再增加出口控制,避免应用层校验失效后直接访问敏感网段。
3. 重定向导致校验失效
初始地址可能是公开站点,但服务器返回重定向后,目标变成了内网地址。如果 HTTP 客户端默认跟随重定向,初始校验就失去了意义。
推荐默认关闭自动重定向。如果业务必须支持重定向,应当在每一次跳转前重新执行协议、主机、端口和解析结果校验,并限制最大跳转次数。
四、一个防御性 URL 校验示例
下面示例展示的是审计和防护思路,不负责发起请求。实际项目中还需要结合网络出口策略和 HTTP 客户端的连接控制。
from ipaddress import ip_address
from urllib.parse import urlparse
import socket
ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443}
def resolve_all(hostname, port):
result = socket.getaddrinfo(
hostname,
port,
type=socket.SOCK_STREAM,
)
return {item[4][0] for item in result}
def is_public_ip(value):
ip = ip_address(value)
return not (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def validate_url(raw_url):
parsed = urlparse(raw_url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("unsupported scheme")
if not parsed.hostname:
raise ValueError("missing hostname")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if port not in ALLOWED_PORTS:
raise ValueError("unsupported port")
addresses = resolve_all(parsed.hostname, port)
if not addresses:
raise ValueError("hostname has no address")
if not all(is_public_ip(addr) for addr in addresses):
raise ValueError("destination is not public")
return {
"scheme": parsed.scheme,
"hostname": parsed.hostname,
"port": port,
"path": parsed.path or "/",
}
这个示例有几个值得注意的点:只允许业务需要的协议;端口采用白名单而不是简单排除少数端口;对 DNS 返回的全部地址进行检查;同时拒绝回环、私有、链路本地、组播、保留和未指定地址。
不过,应用层代码仍然可能受到解析与连接之间的竞态影响。因此生产环境不应只依赖这段逻辑,还应通过防火墙、容器网络策略、云安全组或 egress proxy 限制服务端出站访问。
五、HTTP 客户端配置中的关键防线
审计代码时,除了查找 URL 参数,还要检查 HTTP 客户端的默认行为。以下配置项经常决定漏洞是否可以被稳定利用:
| 配置项 | 风险 | 建议 |
| 自动跟随重定向 | 初始地址通过校验后跳转到受限网络 | 默认关闭;每次跳转重新校验 |
| 连接超时 | 内网不可达地址造成线程、协程或连接池耗尽 | 设置较短的连接和读取超时 |
| 响应体上限 | 下载超大文件导致内存或磁盘资源耗尽 | 限制响应大小并流式读取 |
| 代理继承 | 环境变量或全局代理改变实际网络路径 | 明确配置代理,不要无意继承 |
| 协议处理器 | 非 HTTP 协议带来本地文件或其他资源访问能力 | 显式限制为业务所需协议 |
| 请求头转发 | 把用户请求中的 Cookie、Authorization 等凭据转发给目标 | 建立明确的请求头白名单 |
六、代码审计中的搜索与回溯方法
在大型代码库中,可以先进行关键词粗筛,再结合调用链回溯。不同语言的关键词不完全相同,但思路一致。
- 输入来源:
url、uri、callback、webhook、image、preview、proxy。 - 网络请求:
requests、urllib、HttpClient、RestTemplate、WebClient、axios。 - 底层连接:
socket、connect、getaddrinfo、net.Dial。 - 重定向和代理:
followRedirects、redirect、proxy、NO_PROXY。
找到请求函数后,不要立即下结论。需要继续向上确认输入是否可控,向下确认是否存在 DNS 校验、重定向限制、超时和响应体限制。对封装较深的项目,还应关注统一 HTTP SDK、网关代理和异步任务消费者,因为真正的请求可能不在控制器或路由函数中。
七、在隔离环境中进行安全验证
漏洞验证应尽量使用自建的测试服务,不要扫描或请求真实内网、云平台管理接口及第三方系统。可以在本地或专用测试网络中启动两个服务:一个作为公开入口,另一个模拟受限服务,并在日志中记录访问来源和请求内容。
# 启动一个仅用于测试的本地 HTTP 服务
python3 -m http.server 18080 --bind 127.0.0.1
# 调用待测应用的 URL 预览接口,具体路径按测试项目调整
curl --max-time 5 \
-H 'Content-Type: application/json' \
-d '{"url":" \
验证重点不是获取敏感信息,而是确认应用是否真的代替客户端发起了请求。可以通过测试服务日志、应用审计日志、出站防火墙日志和请求链路追踪共同确认。测试结束后应清理临时服务、测试数据和放行规则。
八、修复方案不能只停留在黑名单
较完整的防护通常分为四层:
- 业务层:尽量不要让用户直接提交任意 URL。对于图片、Webhook 等功能,使用域名或资源 ID 白名单。
- 应用层:限制协议、端口、跳转次数、响应大小和超时时间;对解析后的全部 IP 做分类检查。
- 网络层:服务默认禁止访问管理网段、容器网段、云平台链路本地地址和不必要的内部服务,仅放行明确的外部目的地。
- 监控层:记录目标域名、解析地址、端口、响应状态、耗时、重定向链和拒绝原因,建立异常访问告警。
如果业务确实需要访问外部资源,可以考虑引入专用出站代理。应用只向代理提交经过规范化的目标信息,由代理统一执行域名白名单、DNS 校验、IP 限制、内容大小限制和审计记录。这样比让每个业务模块自行实现一套校验逻辑更容易维护。
九、容易遗漏的延伸风险
- 响应内容二次解析:下载的 XML、SVG、HTML 或压缩包可能继续触发外部资源加载或路径穿越问题。
- 凭据泄露:统一 HTTP 客户端可能自动附加服务账号令牌、内部 Cookie 或代理认证信息。
- 资源耗尽:目标服务长时间不响应、持续返回大文件或返回大量重定向,都可能形成拒绝服务。
- IPv6 双栈差异:只检查 IPv4 而忽略 IPv6,会造成策略不一致。
- 域名白名单过宽:允许整个组织域名后,子域接管、测试环境和遗留服务可能扩大访问范围。
- 异步任务绕过鉴权:前端接口做了限制,但后台任务队列消费时没有重新校验目标地址。
十、审计结论应如何记录
一份可复用的漏洞记录至少应包含以下内容:
- 受影响功能、接口和参数。
- 从输入到网络请求的完整调用链。
- 是否允许任意协议、端口、重定向和响应类型。
- 解析前校验与最终连接目标是否一致。
- 在隔离环境中的验证结果和日志证据。
- 业务影响、受影响网络范围以及是否可能泄露服务端凭据。
- 短期修复、长期架构改进和回归测试用例。
回归测试不应只测试一个被禁止的地址,还应覆盖重定向、多个 DNS 记录、IPv6、异常端口、超大响应、超时、非法协议和异步任务等场景。否则修复很容易只针对当前样例,而没有真正收紧请求能力。
总结
SSRF 审计的关键不在于记住若干黑名单地址,而在于明确“用户控制的 URL 最终连接到了哪里”。可靠的判断应覆盖 URL 解析、DNS 解析、实际连接、重定向和响应处理全过程,并由应用策略与网络出口策略共同兜底。
从工程角度看,最值得优先落实的措施是:限制业务需求范围、关闭或重新校验重定向、对所有解析地址进行检查、设置超时和响应上限、禁止转发敏感请求头,并在网络层实施默认拒绝的出站策略。这样即使某个业务模块的校验出现遗漏,也不会轻易演变成对内部服务和凭据的横向影响。

服务器、云资产与权限边界梳理示意
推荐意见