跳转到帖子
{"title":"一次 SSRF 漏洞分析与内网风险收敛实践","content":"

背景

SSRF(Server-Side Request Forgery)在近几年仍然是 Web 安全里比较常见、也比较容易被低估的一类问题。它的特点不是单点危害一定很高,而是经常作为“入口型漏洞”存在:攻击面看起来只是一个 URL 抓取、图片转存、Webhook 校验、PDF 生成或接口代理功能,但一旦服务端可以代替用户发起请求,就可能影响到内网服务、云元数据接口、管理面板、消息队列、缓存服务等资源。

这篇文章不做攻击教程,主要从代码审计和防护视角整理一次典型 SSRF 问题的分析思路:怎么识别风险点、怎么验证是否存在服务端请求行为、常见过滤为什么失效,以及工程上如何收敛这类风险。

问题场景

在一次代码审计中,业务侧有一个“远程图片转存”功能。用户提交图片 URL,后端下载图片并上传到对象存储,最终返回 CDN 地址。功能本身合理,但风险点也比较典型:

  • 用户可控 URL 被服务端直接请求;
  • 后端服务部署在内网,具备访问部分内部域名和基础设施的能力;
  • 代码只校验了 URL 是否以 http 开头,没有做严格的地址解析和目标限制;
  • HTTP 客户端默认跟随跳转;
  • 没有对响应体大小、Content-Type、超时时间做足够限制。

简化后的问题代码类似下面这样:

public String uploadByUrl(String imageUrl) throws IOException {
    if (!imageUrl.startsWith(\"http\")) {
        throw new IllegalArgumentException(\"invalid url\");
    }

    Request request = new Request.Builder()
            .url(imageUrl)
            .get()
            .build();

    Response response = httpClient.newCall(request).execute();
    byte[] data = response.body().bytes();

    return ossClient.upload(data);
}

这段代码的问题不在于“能下载图片”,而在于“服务端会按用户输入请求任意地址”。在有内网访问能力的环境里,这个差异很关键。

技术分析

1. SSRF 的核心判断点

审计 SSRF 时,我一般先看三个问题:

  • 请求目标是否由用户控制:包括完整 URL、域名、路径、回调地址、代理地址、文件地址等。
  • 请求动作是否由服务端发起:例如后端 HTTP Client、curl、文件读取库、图片处理库、模板渲染库、PDF 组件等。
  • 服务端网络位置是否敏感:是否能访问内网、云元数据、Kubernetes API、Redis、Consul、Prometheus、管理后台等。

只有第一个条件时,通常只是输入校验问题;三个条件同时满足,风险就需要认真评估。

2. 常见薄弱过滤

很多 SSRF 修复容易停留在字符串过滤层面,例如:

if (url.contains(\"127.0.0.1\") || url.contains(\"localhost\")) {
    throw new IllegalArgumentException(\"blocked\");
}

这类方式问题很多:URL 解析存在编码、大小写、用户名密码段、跳转、DNS 解析、IPv6、内网网段等复杂情况。安全判断应该基于规范化后的 URL 和解析后的 IP,而不是简单的字符串包含。

另一个常见问题是只校验第一次请求的 URL,但 HTTP 客户端默认跟随 30x 跳转。这样即便初始域名在白名单内,最终请求目标也可能发生变化。因此需要明确处理跳转策略,至少保证每次跳转后的目标都重新校验。

3. DNS Rebinding 风险

如果只在请求前解析一次域名,然后 HTTP 客户端实际连接时又重新解析,存在 DNS 结果变化导致的绕过风险。比较稳妥的做法是:

  • 请求前解析域名,判断是否落在允许范围;
  • 连接阶段绑定到校验过的 IP,避免再次解析产生偏差;
  • 或者在网关/代理层统一做出站访问控制,应用层只作为补充。

实际工程中,不建议把所有安全逻辑都压在业务代码里。SSRF 防护更适合“应用层校验 + 网络层隔离 + 出站代理审计”组合处理。

关键步骤

1. 梳理服务端出站请求点

建议先做一次全局排查,重点搜索这些关键词:

curl
wget
HttpClient
OkHttpClient
RestTemplate
WebClient
URLConnection
requests.get
requests.post
urllib
fetch
axios
file_get_contents
GuzzleHttp
ImageIO.read
PDF
webhook
callback
url
redirect

排查时不要只看显式 HTTP 请求。一些图片处理、文档转换、XML 解析、模板渲染组件,也可能在解析外部资源时触发请求。

2. 建立统一的 URL 校验函数

不要让各业务点各写一套过滤逻辑。建议沉淀统一的安全 URL 解析与校验模块,至少包括:

  • 仅允许 http 和 https 协议;
  • 禁止 URL 中出现用户名密码段,降低解析歧义;
  • 禁止访问 localhost、回环地址、链路本地地址、私有地址、保留地址;
  • 对域名解析结果做 IP 网段判断;
  • 关闭自动跳转,或对每次跳转重新执行完整校验;
  • 限制端口范围,例如仅允许 80、443,或业务明确需要的端口;
  • 设置连接超时、读取超时、最大响应体大小;
  • 校验响应类型,图片转存场景应限制 Content-Type 和文件头。

下面是一个偏示意的 Java 校验逻辑,重点是思路,不建议直接复制到生产:

private static final Set<String> ALLOWED_SCHEMES = Set.of(\"http\", \"https\");

public void validateUrl(String rawUrl) throws Exception {
    URI uri = new URI(rawUrl).normalize();

    String scheme = uri.getScheme();
    if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) {
        throw new IllegalArgumentException(\"scheme not allowed\");
    }

    if (uri.getUserInfo() != null) {
        throw new IllegalArgumentException(\"userinfo not allowed\");
    }

    String host = uri.getHost();
    if (host == null || host.isBlank()) {
        throw new IllegalArgumentException(\"host required\");
    }

    int port = uri.getPort();
    if (port != -1 && port != 80 && port != 443) {
        throw new IllegalArgumentException(\"port not allowed\");
    }

    InetAddress[] addresses = InetAddress.getAllByName(host);
    for (InetAddress address : addresses) {
        if (isPrivateOrReserved(address)) {
            throw new IllegalArgumentException(\"target ip not allowed\");
        }
    }
}

private boolean isPrivateOrReserved(InetAddress address) {
    return address.isAnyLocalAddress()
            || address.isLoopbackAddress()
            || address.isLinkLocalAddress()
            || address.isSiteLocalAddress()
            || address.isMulticastAddress();
}

需要注意,isSiteLocalAddress 并不能覆盖所有应阻断的地址范围,例如云厂商元数据地址、保留地址、IPv6 ULA 等。生产环境应维护更完整的 CIDR 列表,并定期审查。

3. 控制 HTTP 客户端行为

以 OkHttp 为例,建议显式关闭自动跳转,并设置合理的超时:

OkHttpClient client = new OkHttpClient.Builder()
        .followRedirects(false)
        .followSslRedirects(false)
        .connectTimeout(Duration.ofSeconds(3))
        .readTimeout(Duration.ofSeconds(5))
        .callTimeout(Duration.ofSeconds(8))
        .build();

如果业务确实需要支持跳转,不能简单打开自动跳转,而是应该读取 Location 后重新进入 URL 校验流程,并限制最大跳转次数。

Response response = client.newCall(request).execute();

if (isRedirect(response.code())) {
    String location = response.header(\"Location\");
    if (location == null) {
        throw new IllegalArgumentException(\"redirect without location\");
    }

    URI next = baseUri.resolve(location);
    validateUrl(next.toString());

    // 校验通过后再发起下一次请求,并限制跳转次数
}

4. 限制响应内容

图片转存这类功能经常忽略响应体大小限制,导致内存被大响应撑爆,或者被用于探测内部服务。建议至少做几层约束:

  • 响应状态码只接受预期范围,例如 200;
  • Content-Type 必须符合业务预期,例如 image/jpeg、image/png、image/webp;
  • 读取流时限制最大字节数,不能直接 bytes() 全量读入;
  • 使用文件头魔数做二次校验,避免只信任响应头;
  • 上传对象存储前重新生成文件名和后缀,不使用用户输入路径。

示例:

long maxBytes = 5 * 1024 * 1024;

try (ResponseBody body = response.body();
     InputStream in = body.byteStream();
     ByteArrayOutputStream out = new ByteArrayOutputStream()) {

    byte[] buffer = new byte[8192];
    long total = 0;
    int n;

    while ((n = in.read(buffer)) != -1) {
        total += n;
        if (total > maxBytes) {
            throw new IllegalArgumentException(\"file too large\");
        }
        out.write(buffer, 0, n);
    }

    byte[] data = out.toByteArray();
    // 后续做文件头校验和安全转存
}

5. 网络层做兜底

应用层校验一定会有遗漏,尤其是多语言、多团队、多历史系统并存时。更可靠的方式是将服务端出站流量收敛到统一出口:

  • 业务容器默认禁止访问内网管理网段;
  • 需要访问公网的服务走统一 HTTP 代理;
  • 代理层维护域名白名单或分类策略;
  • 记录出站访问日志,便于审计异常域名和异常端口;
  • 云环境中显式阻断元数据地址,或使用云厂商提供的元数据访问加固机制。

在 Kubernetes 环境中,可以结合 NetworkPolicy、Service Mesh 出站策略或云安全组实现。原则是:应用即使写错了,也不应该具备随意访问内部敏感地址的网络能力。

验证与复现建议

做内部验证时,建议使用自建的安全测试服务,不要对第三方或未授权目标发起请求。可以准备一个简单的 HTTP 服务记录请求来源、请求头和路径,用于确认是否由服务端发起访问。

python3 -m http.server 8080

如果需要更完整的日志,可以用一个简单的 Flask 服务:

from flask import Flask, request

app = Flask(__name__)

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def log(path):
    print('path:', path)
    print('remote:', request.remote_addr)
    print('headers:', dict(request.headers))
    return 'ok'

app.run(host='0.0.0.0', port=8080)

验证重点不是“能访问哪些敏感地址”,而是确认:

  • 服务端是否会请求用户提交的 URL;
  • 是否跟随跳转;
  • 是否限制协议和端口;
  • 是否有超时和大小限制;
  • 异常请求是否有日志和告警。

注意点

  • 不要只靠黑名单:内网地址形式复杂,黑名单很难覆盖完整。优先使用白名单或固定资源源站。
  • 不要信任 DNS 结果一次性有效:校验和连接之间存在时间差,网络层兜底很重要。
  • 不要忽略 IPv6:很多过滤只覆盖 IPv4,实际环境里 IPv6 地址同样需要处理。
  • 不要让业务服务直连所有网络:最小权限原则同样适用于出站访问。
  • 日志避免记录敏感 URL 参数:Webhook 或回调地址里可能带 token,审计日志需要脱敏。
  • 错误信息不要回显内部细节:例如连接失败原因、内网主机名、端口状态等,避免形成探测反馈。

一个更稳妥的设计

如果业务只是“转存用户头像/图片”,最稳妥的方式通常不是放开任意 URL,而是改为:

  • 前端直传文件到后端或对象存储临时凭证;
  • 后端只处理已上传文件,不再主动访问外部 URL;
  • 如必须支持 URL 导入,只允许可信域名,例如已合作的图片 CDN;
  • 独立部署下载服务,放在低权限网络区域,不能访问核心内网;
  • 下载完成后通过异步任务进入内容安全检测和转存流程。

这样即使下载功能出现解析缺陷,影响面也被限制在隔离区域内,不会直接触达核心业务网络。

总结

SSRF 的治理重点不是写几个字符串过滤规则,而是把“服务端出站请求”当作一类高风险能力管理起来。代码层面要做规范 URL 解析、协议端口限制、IP 网段判断、跳转重校验、超时和响应大小控制;架构层面要做出站代理、网络隔离、日志审计和最小权限。

从长期维护角度看,建议团队建立统一的出站请求 SDK 或网关,不允许业务代码随意创建 HTTP Client 访问用户输入地址。这样比在每个功能点临时修补更可靠,也更容易在后续审计中持续收敛风险。

经验上,SSRF 真正麻烦的地方在于边界模糊:它看起来是一个普通业务功能,但实际连接的是服务端所在网络。只要把这个边界想清楚,修复思路就会清晰很多。
"}
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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