跳转到帖子

SSRF(服务端请求伪造)在实际项目中并不总是以一个明显的“URL 参数直接传给 HTTP 客户端”形式出现。更常见的情况是:业务允许导入远程图片、解析 Webhook、抓取网页标题,或者通过内部服务生成预览;随着代码分层和功能迭代,用户输入可能经过多次拼接、解码和重定向,最终形成一条不容易被发现的服务端请求链路。

这类问题的风险也不能只看“能不能访问内网”。真正需要评估的是请求发起位置、解析和连接过程、重定向行为、响应处理方式,以及云环境中的凭证和管理接口是否暴露。本文以一次典型的代码审计为例,整理一套偏工程化的发现、复现、修复和验证方法。示例均使用本地测试环境,不涉及对第三方系统的探测。

一、问题场景:图片预览功能为何容易出问题

某内容系统提供远程图片预览功能,接口接收一个图片地址,服务端下载图片后生成缩略图。初始实现大致如下:

def fetch_image(url: str) -> bytes:
    response = requests.get(url, timeout=5)
    response.raise_for_status()
    return response.content

从业务角度看,这段代码很直观,但安全边界几乎没有建立:

  • 协议没有限制,调用方可能尝试非 HTTP 协议处理器。
  • 域名解析结果没有校验,域名可能解析到本机、内网或链路本地地址。
  • 即使首次解析结果安全,连接前再次解析时也可能发生地址变化。
  • 默认跟随重定向,初始地址安全并不代表最终地址安全。
  • 没有限制响应大小,可能造成内存或磁盘资源耗尽。
  • 如果异常信息直接返回给前端,可能泄露内部地址、连接细节或响应内容。

二、审计重点:不要只搜索一个函数

审计 SSRF 时,建议从“输入源—规范化—请求构造—网络连接—响应处理”五个环节建立数据流,而不是只搜索 requests.get 或 HttpClient.execute。

1. 识别外部可控输入

重点检查以下输入来源:

  • JSON、表单、GraphQL 和 RPC 请求中的 URL、回调地址、图片地址。
  • 导入文件中的远程引用,例如 Markdown、RSS 或配置文件。
  • 数据库中由用户创建的 Webhook、通知地址和头像地址。
  • 消息队列、定时任务或管理后台中的抓取任务参数。

一旦发现 URL 被保存到数据库,不能认为风险已经结束。持久化字段往往会在后续异步任务中被重新取出并请求,审计范围应覆盖同步接口、队列消费者和定时任务。

2. 追踪请求封装层

实际项目通常有统一的网络访问封装,例如 download、fetch、proxy、preview 等方法。建议沿调用链向下追踪,重点确认:

  • 是否允许调用方覆盖代理、请求头和超时时间。
  • 是否对重定向后的每一跳重新执行安全校验。
  • 是否先解析域名再建立连接,还是由底层库自动解析。
  • 是否把响应内容交给 XML、图片、压缩包等二次解析器。
  • 是否将响应头、状态码和异常原文返回给外部调用方。

3. 关注“看起来不是 URL”的输入

有些危险输入不一定直接叫 url。例如 host、endpoint、callback、source、avatar、image、feed、webhook 等字段,经过拼接后可能构成完整请求地址。对这类字段,应该结合数据流和实际调用点判断,而不能只依赖变量命名。

三、地址校验的关键:校验最终连接目标,而不是只校验字符串

常见的错误做法是使用字符串前缀判断,例如判断地址是否以某个域名开头。这种方式无法可靠处理用户信息、端口、大小写、尾随点、IPv6、域名解析和重定向等情况。

较稳妥的策略通常包括以下步骤:

  • 只接受明确允许的协议,例如 http 和 https。
  • 解析 URL,要求存在主机名,拒绝用户名、密码和不必要的自定义端口。
  • 对主机名做规范化处理,避免大小写、尾随点和国际化域名造成判断差异。
  • 解析所有 A 和 AAAA 记录,对每个地址执行内网、回环、链路本地、组播和保留地址检查。
  • 实际连接时绑定经过校验的 IP,同时保留正确的 Host 和 TLS SNI,避免重新解析到其他地址。
  • 禁用或接管自动重定向,并对每次跳转重新执行完整校验。

下面是一个用于本地服务的简化校验示例。它表达的是安全边界和测试思路,生产环境还需要结合网络库的连接绑定能力实现完整的 DNS 重绑定防护。

from ipaddress import ip_address
from urllib.parse import urlparse
import socket

ALLOWED_SCHEMES = {"http", "https"}


def is_public_address(value: str) -> bool:
    address = ip_address(value)
    return not (
        address.is_private
        or address.is_loopback
        or address.is_link_local
        or address.is_multicast
        or address.is_reserved
        or address.is_unspecified
    )


def validate_target(raw_url: str) -> tuple[str, int]:
    parsed = urlparse(raw_url)
    if parsed.scheme.lower() not in ALLOWED_SCHEMES:
        raise ValueError("unsupported scheme")
    if not parsed.hostname or parsed.username or parsed.password:
        raise ValueError("invalid authority")

    host = parsed.hostname.rstrip(".").lower()
    port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
    addresses = {item[4][0] for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)}
    if not addresses or not all(is_public_address(item) for item in addresses):
        raise ValueError("target address is not allowed")
    return host, port

这里有一个容易被忽略的细节:只检查 DNS 查询结果仍然不一定足够。若 HTTP 客户端稍后根据域名重新解析,攻击者控制的 DNS 服务可能返回不同地址。因此生产实现应选择支持“预解析地址并绑定连接”的客户端,或者在网络层增加出口代理和目的地址策略,避免应用层单独承担全部风险。

四、重定向、解析差异与协议处理

1. 重定向必须逐跳校验

很多 HTTP 客户端默认自动跟随 301、302、303 和 307。若第一跳是公开地址,响应再跳转到受限地址,单次入口校验就会失效。建议默认关闭自动重定向;如果业务确实需要跟随,应限制最大跳数,并对 Location 解析、协议、主机、端口和解析结果逐跳校验。

def fetch_with_policy(url: str, session) -> bytes:
    current = url
    for _ in range(3):
        validate_target(current)
        response = session.get(
            current,
            allow_redirects=False,
            timeout=(3, 5),
            stream=True,
        )
        if response.status_code not in {301, 302, 303, 307, 308}:
            return read_limited_body(response, limit=5 * 1024 * 1024)

        location = response.headers.get("Location")
        if not location:
            raise ValueError("redirect location is empty")
        current = urljoin(current, location)

    raise ValueError("too many redirects")

上面的示例省略了导入和响应体读取函数,重点是控制重定向边界。还需要注意:不同语言和库对 URL 解析、反斜杠、编码字符、空白字符以及 IPv6 表示法的处理可能不同,校验器与实际 HTTP 客户端最好使用同一套规范化逻辑,避免“校验看到的地址”和“连接实际使用的地址”不一致。

2. 不要把非 HTTP 协议交给通用请求组件

如果业务只需要获取网页或图片,就不应让通用组件处理 file、ftp、gopher 等协议。某些库还支持自定义协议处理器,或者会根据输入自动选择不同的适配器。限制协议只是第一步,最好在依赖配置和代码层同时确认不加载无关协议处理能力。

五、响应处理同样是攻击面

SSRF 风险不仅在于“请求发出去了什么”,也在于“服务端拿回来后做了什么”。审计时应继续检查以下链路:

  • 响应是否被完整读入内存,是否有最大响应体限制。
  • Content-Type 是否可信,是否会根据文件内容自动识别类型。
  • 图片、XML、PDF、压缩包等解析器是否存在二次解析风险。
  • 响应是否被原样返回,是否可能成为内部服务内容代理。
  • 是否把上游响应头中的 Location、Set-Cookie 或缓存字段传递给客户端。

一个相对稳妥的下载流程应采用流式读取、大小上限和内容类型白名单。例如图片预览只允许 image/jpeg、image/png 等有限类型,并在服务端重新编码后再输出,不直接返回上游字节流。

def read_limited_body(response, limit: int) -> bytes:
    total = 0
    chunks = []
    for chunk in response.iter_content(chunk_size=8192):
        if not chunk:
            continue
        total += len(chunk)
        if total > limit:
            raise ValueError("response body is too large")
        chunks.append(chunk)
    return b"".join(chunks)

六、网络层防护:不要只依赖应用代码

应用层校验适合表达业务允许访问的范围,但无法替代网络隔离。生产环境中建议同时落实以下措施:

  • 将抓取服务放在独立的网络分区,默认禁止访问数据库、缓存、消息队列和管理平面。
  • 通过出口代理统一执行域名、端口、目的地址和审计策略。
  • 限制应用运行身份的云平台权限,不让普通业务实例读取高价值实例凭证。
  • 在防火墙或安全组层面拒绝回环、链路本地、私有网段和云管理地址。
  • 对外联请求记录请求任务、规范化主机、解析地址、最终状态和失败原因,但避免记录敏感查询参数。

日志中的地址信息应经过脱敏和权限控制。安全团队需要能够根据任务 ID 还原完整请求链路,同时避免把内部拓扑、凭证参数或上游响应直接写入普通业务日志。

七、验证方法:建立可重复的本地测试

修复后不要只通过一次接口调用判断问题已经解决。可以在本地搭建几个隔离测试服务,覆盖不同的边界条件:

  • 公开地址直接访问,应符合业务预期。
  • 解析到回环、私有网段、链路本地和 IPv6 保留地址时,应在连接前失败。
  • 公开地址重定向到受限地址时,应在第二跳被拒绝。
  • 多次解析返回不同地址时,应保持连接目标与校验结果一致。
  • 响应体超过限制时,应中止读取并释放连接。
  • 非允许协议、缺失主机、带用户信息或异常端口的 URL,应统一拒绝。

测试重点是验证“是否建立了连接”,而不只是观察接口返回码。可以在隔离网络中使用一个仅用于测试的监听服务记录连接来源和 Host,不要将测试请求指向真实内网系统。

def test_redirect_to_private_target_is_rejected(client, redirect_server):
    public_url = redirect_server.url_for("
    result = client.preview({"url": public_url})
    assert result.status_code == 400
    assert redirect_server.connection_count == 1

除单元测试外,还应加入静态检查和集成测试。例如在代码审查中搜索未经统一封装的网络请求调用:

rg 'requests\.(get|post|request)|urlopen|HttpClient|WebClient' src/

搜索结果不能直接等同于漏洞,但可以帮助发现绕过统一安全组件的新增代码。对于 Java、Go、Node.js 等技术栈,应根据项目实际客户端补充对应规则,并把检查接入代码审查或持续集成流程。

八、常见修复误区

  • 只做字符串黑名单:黑名单容易遗漏地址表示形式,也无法处理解析和重定向。
  • 只禁止常见私网段:还应考虑回环、链路本地、组播、保留地址和 IPv6。
  • 只校验第一次请求:重定向和多阶段抓取会产生新的目标。
  • 只在前端校验:前端校验可以改善交互,但不能作为服务端安全边界。
  • 修复后仍允许任意代理设置:攻击者可能通过代理改变实际请求路径。
  • 错误信息过于详细:应对外返回稳定的业务错误码,对内记录足够的诊断信息。

九、结论

SSRF 的治理核心不是增加一个 URL 黑名单,而是把“服务端是否允许访问某个网络目标”变成明确、可审计、可测试的策略。一次完整的修复应同时覆盖输入源追踪、协议限制、主机解析、连接绑定、重定向控制、响应大小、网络隔离和日志审计。

从代码审计角度看,最有价值的证据不是某个请求函数的存在,而是一条能够闭合的数据流:外部输入如何进入任务,经过哪些规范化和校验,最终连接到哪个地址,响应又被哪些组件继续处理。只要围绕这条链路建立测试和防护,类似的远程抓取、Webhook、文件导入和预览功能就能复用同一套安全基线,减少问题在不同业务模块中反复出现。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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