SSRF(服务端请求伪造)经常出现在“根据 URL 抓取内容”“图片远程转存”“Webhook 测试”“网页截图”等功能中。它的危险性不只在于访问内网地址,更在于应用自身拥有网络权限、身份凭据和信任关系,一旦请求边界设计不清晰,就可能把一个普通的业务接口变成内部网络访问代理。
本文以一次典型代码审计为背景,讨论如何判断 SSRF 是否成立、如何在不触碰真实敏感系统的前提下完成验证,以及如何从 URL 解析、重定向、DNS 解析和出口控制多个层面建立防护闭环。
一、问题场景:一个看似普通的远程抓取接口
目标功能是让用户提交一个图片地址,服务端下载图片并保存到对象存储。初始代码逻辑大致如下:
public byte[] fetch(String url) throws IOException {
URL target = new URL(url);
HttpURLConnection connection = (HttpURLConnection) target.openConnection();
connection.setConnectTimeout(3000);
connection.setReadTimeout(5000);
connection.setInstanceFollowRedirects(true);
return connection.getInputStream().readAllBytes();
}这段代码的业务意图很明确,但安全边界并不完整:
- 允许用户完全控制目标 URL。
- 没有限制协议,只是通过 URL 类自动选择处理方式。
- 没有校验目标 IP 是否属于内网、回环、链路本地或保留地址。
- 默认跟随重定向,初始地址合法并不代表最终地址合法。
- 没有限制响应大小,可能引入资源消耗问题。
- 应用所在网络环境可能可以访问管理面板、服务发现接口或内部调试端点。
需要注意的是,SSRF 是否能够造成实际影响,取决于应用部署位置、网络分段、身份权限和出口策略。代码存在缺陷不等于已经证明可以访问某个敏感系统,审计结论应区分“代码级风险”和“环境级影响”。
二、审计时先确认请求链路
分析 SSRF 时,不要一开始就围绕某个特殊地址构造测试。更稳妥的做法是先梳理完整请求链路:
- 用户输入从哪里进入,是否经过 URL 解码、JSON 反序列化或模板拼接。
- 最终使用的是哪一种 HTTP 客户端,是否自动跟随重定向。
- 域名解析发生在应用进程、代理还是网关。
- 是否存在代理环境变量,例如 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY。
- 响应内容是否会回显给用户,还是只保存到文件或对象存储。
- 请求是否携带了默认 Cookie、Authorization 或云平台身份凭据。
可以在测试环境中增加结构化日志,但不应记录完整敏感 URL 或认证头。推荐记录请求任务编号、目标主机的脱敏值、解析后的地址类别、最终状态码、响应大小和耗时。例如:
{
"event": "outbound_fetch",
"request_id": "8f1c...",
"host": "example.invalid",
"resolved_ip_class": "public",
"redirect_count": 0,
"status": 200,
"bytes": 18432,
"duration_ms": 421
}三、验证方法:只使用自建或授权测试目标
在授权测试环境中,可以准备一个外部测试站点和一个本地测试站点,用于观察请求是否从服务端发出。测试站点只返回固定文本,不包含任何敏感数据。验证重点不是读取内部信息,而是确认以下事实:
- 请求是否确实由服务端发起,而不是浏览器直接访问。
- 服务端是否能访问仅对本机或内网开放的测试端口。
- 遇到 3xx 响应时,客户端是否继续访问新的地址。
- DNS 解析结果变化后,客户端是否重新校验目标地址。
- 响应大小、连接时间和重定向次数是否受到限制。
一个安全的本地验证服务可以只监听测试机的回环接口,并返回固定内容:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"authorized-ssrf-test"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(fmt % args)
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()测试完成后,应删除临时服务并核对访问日志。不要把生产环境的管理接口、容器元数据服务、数据库端口或其他内部系统作为验证目标。
四、核心风险一:URL 解析与 IP 校验不一致
很多防护只做了字符串判断,例如禁止 URL 中出现 localhost 或某个内网网段。这种方式容易受到不同解析器行为差异的影响。实际审计时应关注以下问题:
- 是否只允许 http 和 https,是否会接受 file、ftp 或其他协议。
- 主机名是否经过标准化处理,包括大小写、尾部点和国际化域名。
- 校验的是原始域名,还是实际 DNS 解析后的每一个地址。
- IPv4、IPv6、IPv4 映射 IPv6 的处理是否一致。
- 是否错误地把带用户名信息的 URL 当成普通主机名解析。
推荐采用“解析 URL—规范化主机名—解析全部地址—逐个判断地址类别—再建立连接”的顺序,而不是先用字符串黑名单过滤。
地址判断至少应覆盖回环地址、私有地址、链路本地地址、未指定地址、组播地址、文档保留地址以及企业网络明确禁止的网段。对于 IPv6,不能只用 IPv4 的正则表达式处理。
五、核心风险二:DNS 解析与连接之间的时间差
即使代码先解析域名并确认结果属于公网,也可能在真正建立连接时再次解析,导致检查结果与连接目标不一致。这类问题本质上是“检查时”和“使用时”之间存在时间窗口。
防护思路包括:
- 使用经过安全封装的 HTTP 客户端,在连接阶段固定已校验的地址。
- 对每次连接和每次重定向重新执行地址校验。
- 对域名解析结果设置合理缓存时间,避免无限信任长期结果。
- 在网络层配置出口代理,由代理统一执行目标地址与端口策略。
- 不要把 DNS 校验当成唯一防线,必须配合防火墙和网络分段。
如果业务允许,最简单可靠的方案通常是维护目标域名白名单,并在网关层只允许访问经过审核的目标,而不是试图覆盖所有复杂的地址解析边界。
六、核心风险三:重定向绕过初始校验
重定向是实际项目中最容易遗漏的路径。应用可能先校验了一个公开站点,但该站点返回 302,将客户端引向另一个未授权地址。如果客户端自动跟随,初始校验就失去了意义。
有两种常见处理方式:
- 默认关闭自动重定向,由业务代码读取 Location 后重新解析、校验并决定是否继续。
- 如果必须自动跟随,则限制重定向次数,并确保客户端在每一跳都执行协议、域名、端口和 IP 校验。
伪代码示例如下:
response = request_once(url)
for i in range(MAX_REDIRECTS):
if response.status not in REDIRECT_STATUS:
break
next_url = resolve_location(url, response.headers["Location"])
target = parse_and_normalize(next_url)
assert target.scheme in {"http", "https"}
assert target.port in {80, 443}
addresses = resolve_all(target.hostname)
if any(is_blocked_address(addr) for addr in addresses):
raise PolicyError("redirect target is not allowed")
url = target.to_string()
response = request_once(url)这里的关键点是:重定向目标必须被当成新的用户输入处理,而不是当成可信的服务器返回值。
七、建立一套可落地的防护策略
1. 协议与端口白名单
仅允许业务真正需要的协议,通常是 HTTPS,必要时再开放 HTTP。端口应采用白名单,而不是简单禁止几个常见端口。若图片转存只需要访问公网 443 端口,就没有必要允许任意端口。
2. 目标域名白名单
对于合作方回调、固定图床或文档系统,优先维护精确域名白名单。白名单匹配应避免简单的后缀判断,例如允许 example.com 时,不应顺带允许 evil-example.com。可以采用规范化后完全匹配,或明确的子域名边界匹配。
3. 禁止访问敏感地址类别
在应用层和出口代理层同时阻断回环、私有、链路本地、组播、未指定及企业内部保留网段。应用层负责业务策略,网络层负责最后一道边界,两者不能互相替代。
4. 限制请求规模
设置连接超时、读取超时、最大响应大小、最大重定向次数和最大下载时间。读取响应时不要无条件调用 readAllBytes,应使用带上限的流式读取:
MAX_BYTES = 5 * 1024 * 1024
received = 0
chunks = []
while True:
chunk = stream.read(8192)
if not chunk:
break
received += len(chunk)
if received > MAX_BYTES:
raise SizeLimitError("response too large")
chunks.append(chunk)5. 限制出站身份
运行抓取任务的进程不应继承无关的云平台凭据、内部服务令牌或宿主机管理权限。生产环境建议使用独立的服务账户、独立网络段和最小化的出口策略,并对出站请求建立审计记录。
八、代码审计中的常见误区
- 只看控制器,不看底层客户端。 真正的重定向、代理和 DNS 行为通常发生在 HTTP 客户端配置中。
- 只验证“能否访问内网”。 即使无法直接访问内网,任意外连也可能造成数据外传、内部探测或资源消耗。
- 只做域名黑名单。 黑名单难以覆盖解析差异和新增内部网段,优先考虑白名单与网络出口控制。
- 把 SSRF 当成单一漏洞修复。 URL 解析、文件类型校验、响应大小、凭据隔离和日志审计都属于同一条请求链路。
- 测试时直接访问真实敏感服务。 这既可能造成越权访问,也会污染事件记录。应优先使用自建的无害验证服务。
九、修复后的验证清单
修复完成后,建议在测试环境执行一组固定回归用例,并将结果纳入自动化测试:
| 测试项 | 预期结果 |
|---|---|
| 允许的 HTTPS 公网域名 | 请求成功,记录目标和响应摘要 |
| 不允许的协议 | 在发起连接前拒绝 |
| 回环或私有地址 | 拒绝,日志记录为策略阻断 |
| 解析到多个地址,其中一个被禁止 | 整体拒绝 |
| 重定向到未授权域名 | 重新校验后拒绝 |
| 超大响应或长时间无响应 | 触发大小或时间限制 |
| 异常 URL 编码和大小写形式 | 规范化后执行同等策略 |
此外,还应检查监控是否能够区分业务失败与安全策略阻断,避免大量策略拒绝被普通网络错误淹没。
十、总结
SSRF 防护的核心不是记住几组特殊地址,而是建立清晰的出站请求边界:谁可以发起请求、允许访问什么协议和目标、解析结果是否可信、重定向是否重新校验、响应资源是否受限、网络出口是否有第二道控制。
在代码审计中,建议沿着“输入—解析—DNS—连接—重定向—响应—存储”的完整链路检查,而不是只搜索某个函数名。对于无法完全控制目标的通用抓取功能,优先采用域名白名单、专用出口代理、最小权限运行和完整审计日志。这样即使某一层校验出现遗漏,也不会轻易演变成对内部系统的任意访问。
安全验证应以最小影响为原则:使用授权目标、固定测试数据和可回收环境证明问题;修复则应依赖应用策略与网络边界的组合,而不是单个字符串过滤。
推荐意见