跳转到帖子

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—连接—重定向—响应—存储”的完整链路检查,而不是只搜索某个函数名。对于无法完全控制目标的通用抓取功能,优先采用域名白名单、专用出口代理、最小权限运行和完整审计日志。这样即使某一层校验出现遗漏,也不会轻易演变成对内部系统的任意访问。

安全验证应以最小影响为原则:使用授权目标、固定测试数据和可回收环境证明问题;修复则应依赖应用策略与网络边界的组合,而不是单个字符串过滤。
服务器与云环境安全示意图
服务器、云资产与权限边界梳理示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…

SiteMap HACKHAT © 2026 All rights reserved.