跳转到帖子

SSRF(Server-Side Request Forgery,服务端请求伪造)并不是“某个参数能访问内网”这么简单。实际审计中,漏洞通常分散在 URL 解析、请求库配置、重定向处理、代理环境变量和权限边界几个环节。只修补一个黑名单,往往只能降低部分风险,无法形成可靠的防护闭环。

本文以一次典型的文件预览接口为背景,整理从现象确认、代码定位、风险评估到修复验证的完整思路。示例均以本地测试环境和无敏感数据服务为前提,重点放在审计方法和工程化修复上。

一、问题背景与业务场景

某系统提供了远程文件预览功能,前端提交一个 URL,后端通过 HTTP 客户端下载内容并转换为预览结果。接口的简化逻辑如下:

public PreviewResult preview(String url) {
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .timeout(Duration.ofSeconds(10))
        .GET()
        .build();

    HttpResponse<byte[]> response = client.send(
        request,
        HttpResponse.BodyHandlers.ofByteArray()
    );

    return converter.convert(response.body());
}

从业务角度看,这段代码很直接;从安全角度看,它把用户可控字符串直接交给了 URL 解析器和网络客户端。只要服务端所在网络能够访问某个地址,攻击者就可能借助该接口让服务器代为发起请求。

这类问题的实际影响取决于部署环境,包括:

  • 应用服务器是否能访问内部管理系统、监控系统或云平台元数据服务。
  • 请求是否携带了默认认证头、代理身份或内部网络信任信息。
  • 响应内容是否会原样返回给调用方。
  • 应用是否允许跟随重定向,以及重定向后的地址是否重新校验。
  • 网络出口和云平台是否有额外的访问控制。

二、审计时不要只盯着黑名单

很多修复方案停留在禁止若干字符串,例如拒绝包含 localhost、127.0.0.1 或内网网段的输入。这种方式的问题是,URL 解析存在多个语义层次,字符串检查和真正发起请求之间可能出现差异。

审计时建议沿着以下数据流追踪:

  • 输入来源:参数、JSON 字段、HTTP Header、数据库任务或消息队列。
  • 规范化过程:是否做了 URL 解码、大小写转换、默认端口补全。
  • 解析过程:域名、端口、协议、用户名密码部分分别如何处理。
  • DNS 解析:校验发生在解析前还是解析后,是否存在解析结果变化。
  • 连接过程:是否经过代理,是否自动跟随重定向,是否复用连接池。
  • 输出过程:响应头、响应体和错误信息是否会返回给外部调用者。

尤其需要注意“校验一次、请求一次”的模式。如果校验阶段解析出的地址与真正连接阶段解析出的地址不一致,单纯依靠一次域名判断就不够可靠。这里不需要把问题简化成某个特殊字符串,而应把它视为“目标地址验证与网络连接之间缺少一致性约束”。

三、建立可控的验证环境

在生产环境验证 SSRF 时,不建议直接探测内部系统。更稳妥的做法是搭建两个本地服务:一个模拟业务应用,另一个模拟允许访问的资源服务,并额外准备一个明确禁止访问的测试端点。通过访问日志确认请求是否到达,而不是依赖扫描或读取真实内部数据。

一个最小化的本地记录服务可以使用 Python 标准库实现:

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        print('received path:', self.path)
        print('received headers:', dict(self.headers))
        body = b'test-resource'
        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)

HTTPServer(('127.0.0.1', 18080), Handler).serve_forever()

验证目标不是证明“能不能访问某个真实内网地址”,而是确认以下控制是否生效:

  • 只允许明确的协议,例如仅允许 HTTPS。
  • 只允许配置中的域名或资源标识。
  • 解析出的所有地址都符合允许范围。
  • 重定向不会把请求带到未授权地址。
  • 连接失败、超时和解析异常不会泄露过多内部信息。

四、推荐的修复策略:从允许列表开始

如果业务并不需要访问任意 URL,最佳方案不是完善黑名单,而是取消任意 URL 能力。可以让用户提交资源 ID,由服务端根据固定配置选择目标;或者仅允许访问预先登记的域名和路径。

例如,业务只需要从对象存储读取公开文件,可以将外部输入改为对象键:

public byte[] loadObject(String objectKey) {
    if (!OBJECT_KEY_PATTERN.matcher(objectKey).matches()) {
        throw new IllegalArgumentException('invalid object key');
    }

    return objectStorage.getObject(
        ConfiguredBucket.NAME,
        objectKey
    );
}

如果确实必须接受 URL,则至少应执行以下步骤:

  • 解析 URL,而不是对原始字符串做关键字匹配。
  • 拒绝非必要协议,通常只保留 HTTPS。
  • 拒绝包含用户名和密码的 URL,避免凭据混入请求。
  • 明确限制端口,默认只允许 443,或使用业务配置中的固定端口。
  • 对主机名做规范化处理,禁止空主机、异常编码和不符合预期的域名格式。
  • 解析所有 A 和 AAAA 记录,并对每个地址进行网络范围校验。
  • 连接前后保持目标约束,避免校验结果与实际连接对象不一致。
  • 关闭自动重定向,或对每一跳重新执行完整校验。
  • 设置连接超时、读取超时、响应大小上限和最大响应时间。

五、一个更完整的校验框架

下面的伪代码展示了校验顺序。它不是可直接复制到所有项目的通用库,但可以作为代码审计和单元测试的检查清单。

URI target = parseUserInput(input);

if (!allowedSchemes.contains(target.scheme())) {
    throw new SecurityException('scheme is not allowed');
}

if (target.userInfo() != null) {
    throw new SecurityException('userinfo is not allowed');
}

if (!allowedPorts.contains(effectivePort(target))) {
    throw new SecurityException('port is not allowed');
}

String host = normalizeHost(target.host());
Set<IPAddress> addresses = resolver.resolveAll(host);

if (addresses.isEmpty() || addresses.stream().anyMatch(this::isRestrictedAddress)) {
    throw new SecurityException('resolved address is not allowed');
}

HttpRequest request = buildRequest(target);
HttpResponse<byte[]> response = client.sendWithoutRedirect(request);

if (response.statusCode() is redirect) {
    URI next = parseLocation(response);
    validateAgain(next);
}

实际实现中还要考虑代理设置。部分 HTTP 客户端会自动读取 HTTP_PROXY、HTTPS_PROXY 或 NO_PROXY 环境变量。如果审计人员只看业务代码,可能忽略请求最终经过了代理。生产配置应明确代理策略,并在测试中验证环境变量、系统代理和客户端默认行为是否符合预期。

六、容易遗漏的边界问题

1. 重定向

即使第一跳目标通过了校验,服务端也可能收到 301、302、307 或 308 响应。若客户端自动跟随重定向,最终目标可能已经发生变化。修复时应优先关闭自动重定向;如果业务必须支持重定向,则每一跳都要限制协议、主机、端口和解析地址,并设置最大跳转次数。

2. DNS 解析与地址校验

只校验域名字符串并不能证明连接目标安全。域名可能解析到多个地址,也可能同时存在 IPv4 和 IPv6 记录。校验逻辑必须覆盖所有解析结果,不能因为其中一个地址合法就忽略其他地址。

3. IPv6 与地址格式

很多内部地址判断只覆盖 IPv4 私有网段,却没有处理 IPv6 回环地址、链路本地地址和本地接口地址。地址判断应使用成熟的 IP 解析库,而不是通过字符串前缀比较。对 IPv4 映射的 IPv6 地址也要进行统一归一化。

4. 请求头与凭据传播

不要把用户请求的 Authorization、Cookie 或内部鉴权头无条件转发到目标地址。代理层、SDK 和全局 HTTP 拦截器都可能自动添加请求头。建议建立明确的出站请求头白名单,只保留业务真正需要的字段。

5. 响应处理

响应体应设置大小上限,并根据业务需要限制 Content-Type。错误信息不要直接返回底层异常堆栈,因为其中可能包含解析地址、代理信息、文件路径和内部服务名称。对于文件转换场景,还应单独审计解压缩、解析器和临时文件目录。

七、测试用例应覆盖哪些情况

安全修复是否可靠,不能只用一个正常 URL 测试。建议把校验逻辑拆成独立组件,并建立表格化测试:

类别测试内容期望结果
协议允许协议、未知协议、大小写混合协议仅允许业务配置中的协议
端口默认端口、显式允许端口、非允许端口非允许端口拒绝
解析单地址、多地址、IPv4 与 IPv6 混合任一地址不符合策略即拒绝
跳转跳转到同域名、其他域名、禁止地址每一跳重新校验
网络连接超时、读取超时、超大响应在限定时间和大小内终止
请求头外部 Cookie、Authorization、代理头不向目标地址传播敏感头

除了单元测试,还应使用本地记录服务做集成测试,确认真实 HTTP 客户端的行为与假设一致。特别要验证重定向、代理环境变量、DNS 缓存和连接池复用等场景。

八、日志与监控建议

安全日志应记录请求是否被策略拒绝、目标域名的脱敏形式、解析结果类别、失败原因和请求耗时,但不要记录完整的 Authorization、Cookie 或可能包含敏感信息的 URL 查询参数。

可以重点监控以下异常信号:

  • 同一账号短时间内请求大量不同域名。
  • 请求频繁触发协议、端口或地址范围校验失败。
  • 大量连接超时、解析失败或响应大小超限。
  • 业务正常流量之外出现异常的重定向链。
  • 出站请求目标集中在不常见的网络区域。

监控的目的不是替代代码修复,而是帮助发现绕过路径和误配置。日志中应避免直接打印用户提交的完整 URL,防止日志成为新的敏感信息泄露源。

九、复盘结论

这次问题的根因不是某一个危险字符串没有加入黑名单,而是业务把“用户输入”直接提升成了“服务端网络访问目标”,同时缺少协议、地址、重定向、响应和出站凭据的统一约束。

从工程实践看,优先级可以按以下顺序安排:

  • 能取消任意 URL,就改成资源 ID 或固定目标。
  • 不能取消时,采用严格允许列表,而不是不断扩展黑名单。
  • 把 URL 解析、DNS 解析、地址判断和重定向控制放在同一安全组件中。
  • 限制协议、端口、响应大小、超时和出站请求头。
  • 通过单元测试和本地集成环境验证真实客户端行为。
  • 配合出站网络策略进行纵深防御,避免应用层失误直接触达敏感网络。

SSRF 防护的关键不在于记住多少种地址写法,而在于建立一条可验证的控制链:输入被规范化,目标被明确授权,解析结果被完整检查,实际连接受到约束,响应和凭据不会越权传播。只有这几个环节同时成立,修复才具有长期稳定性。

服务器与云环境安全示意图
服务器、云资产与权限边界梳理示意

0篇意见

推荐意见

没有意见。

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