SSRF(服务器端请求伪造)在代码审计中并不难被识别,难点通常在于判断它是否真的能够触达高风险网络资源。很多审计结果停留在“用户可控 URL 传入 HTTP 客户端”这一层,没有继续分析 URL 解析、DNS 解析、重定向、代理配置和出站网络策略,最终容易出现误报或低估风险。
本文以常见的“服务端抓取 URL”功能为例,整理一套适合代码审计和安全验证的分析方法。示例均限定在本地测试环境,不涉及对真实内网、云平台元数据服务或第三方系统的访问。
一、问题场景与审计入口
常见业务包括图片代理、网页预览、Webhook 调试、PDF 转换、RSS 抓取和远程文件导入。代码形式可能不同,但通常具备以下数据流:
- HTTP 参数、JSON 字段或消息队列内容进入 URL 变量。
- URL 经过字符串拼接、模板替换或简单过滤。
- 程序调用 HTTP 客户端发起服务端请求。
- 响应内容被返回给用户、写入文件,或继续交给解析器处理。
审计时建议先从出站请求 API 反向追踪输入来源,而不是只搜索“ssrf”关键字。Java 中常见的入口包括 HttpURLConnection、Apache HttpClient、OkHttp、Spring WebClient;Node.js 中常见的是 axios、got、fetch;Python 中则包括 requests、urllib 和 httpx。
String target = request.getParameter("url");
URL url = new URL(target);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(5000);
InputStream body = conn.getInputStream();上面的代码存在明显的安全审计价值:用户输入直接决定了请求目标,且代码中没有体现协议限制、目标地址校验、重定向策略和响应大小控制。
二、先区分“可控请求”与“有效 SSRF”
并非所有服务端请求都构成同等严重的 SSRF。审计时可以从四个维度确认实际影响:
- 目标可控程度:用户能否控制完整 URL,还是只能选择固定域名下的路径。
- 网络可达性:应用所在网络是否能够访问其他网段、容器网桥或管理平面。
- 响应可见性:响应正文、状态码、响应时间或错误信息是否会反馈给用户。
- 副作用能力:是否支持 POST、PUT 等方法,是否会携带内部凭据,是否会跟随重定向。
例如,一个只允许访问固定 CDN 域名、强制使用 GET、丢弃响应正文的图片下载功能,风险通常低于一个能够访问任意协议、跟随重定向并将响应原样返回的调试接口。
实际评级不能只依据“存在 SSRF”下结论,还要结合运行账户权限、网络分段、代理配置、容器权限和响应回显情况综合判断。
三、URL 校验中最容易出现的边界问题
1. 只检查字符串前缀
下面这种写法经常被用于限制域名,但它检查的是字符串,不是解析后的主机:
if (!url.startsWith(" {
throw new IllegalArgumentException("unsupported host");
}字符串前缀无法正确区分主机、端口、用户名字段和路径。例如合法域名后追加其他字符、加入用户信息字段,或者利用不同的 URL 解析行为,都可能造成判断结果与实际连接目标不一致。安全校验应当先使用标准库解析 URL,再分别检查协议、主机、端口和路径,避免通过字符串拼接完成判断。
2. 混淆主机名与注册域名
“以 example.com 结尾”并不等于“属于 example.com”。如果业务允许访问某个组织域名,应明确采用主机名精确匹配,或使用经过验证的子域名规则。对于多级公共后缀,不能简单通过最后两段字符串判断注册域名。
URI uri = URI.create(input);
String scheme = uri.getScheme();
String host = uri.getHost();
int port = uri.getPort();
if (!"https".equalsIgnoreCase(scheme) || host == null) {
throw new IllegalArgumentException("invalid target");
}
if (!ALLOWED_HOSTS.contains(host.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("host is not allowed");
}如果业务确实需要允许子域名,建议使用成熟的域名解析库和明确的后缀匹配逻辑,并对国际化域名统一转为 ASCII 形式后再比较。
3. 忽略数字形式和 IPv6
应用只拦截常见的点分十进制私网地址并不充分。不同语言和网络库对十六进制、八进制、整数形式 IPv4,以及 IPv4 映射 IPv6 的解析行为可能不同。IPv6 还需要考虑回环地址、本地链路地址和私有地址范围。
正确做法不是不断增加黑名单字符串,而是将主机名解析为所有地址,对每个地址执行统一的地址分类,并在建立连接前再次确认解析结果。校验逻辑至少应覆盖回环、链路本地、私有网段、保留地址和多播地址等类别。
4. 只校验第一次 DNS 解析
典型风险是校验阶段解析到公网地址,真正建立连接时再次解析却得到内部地址。即使应用层逻辑正确,DNS 结果在校验和连接之间发生变化也会形成竞态。
相对稳妥的方案包括:
- 在受控网络中使用出站代理,由代理统一完成 DNS 解析和目标策略判断。
- 校验后将已确认的地址用于连接,并避免客户端在连接阶段重新解析主机名。
- 通过网络层禁止应用运行环境访问不必要的私有网段,作为应用校验之外的第二道防线。
需要注意的是,单纯在应用代码中缓存 DNS 结果并不能替代网络隔离,也不能解决所有代理和连接池带来的解析差异。
四、重定向是经常被遗漏的第二跳
很多 HTTP 客户端默认跟随 301、302、303 或 307 重定向。即使第一跳目标通过了校验,服务端仍可能把请求引导到另一个未经校验的地址。
防护策略有两种:
- 业务不需要重定向时,直接关闭自动跟随。
- 业务必须支持重定向时,对每一跳重新执行协议、主机、端口和地址范围校验,并限制最大跳数。
client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(3))
.build();审计时应继续追踪客户端封装层的默认配置,不能只看业务代码是否显式设置了重定向选项。
五、协议、端口和请求能力同样重要
如果业务只是下载 HTTPS 图片,就不应该接受任意协议。除 HTTP 和 HTTPS 外,某些客户端或底层处理链还可能支持文件、本地套接字、FTP 等非预期协议。协议限制应在 URL 解析完成后进行精确比较,而不是依赖客户端“通常不会支持”。
端口也应采用白名单策略。对于固定业务,最好只允许标准 HTTPS 端口;如果确实需要自定义端口,应明确列出允许范围。与此同时,还要限制请求方法、请求头、请求体大小和认证信息转发,避免把服务端已有凭据自动带到用户指定的目标。
六、响应处理可能把 SSRF 变成二次风险
服务端请求本身只是第一步。响应内容如果进入 XML、SVG、办公文档、压缩包或图像解析器,可能进一步触发解析器漏洞、资源消耗或二次请求。
建议重点检查以下位置:
- 是否根据响应头自动选择解析器,而没有限制真实内容类型。
- 是否把远程响应保存到可执行目录或静态文件目录。
- 是否没有限制响应大小,导致内存或磁盘耗尽。
- 是否将响应中的 URL、错误信息和内部主机名原样返回给前端。
- 是否把远程内容交给 XML、SVG、压缩包等高风险解析组件。
对于文件下载功能,应设置连接超时、读取超时、最大响应体长度和整体处理时限,并采用流式读取。对于不需要展示正文的业务,可以只保留有限的状态信息,减少敏感内容回显。
七、推荐的安全实现思路
一个相对完整的实现通常分为目标规范化、地址校验、连接控制和响应处理四个阶段:
URI target = parseAndNormalize(userInput);
requireScheme(target, Set.of("https"));
requirePort(target, Set.of(443));
List<InetAddress> addresses = resolveAll(target.getHost());
for (InetAddress address : addresses) {
if (isLoopback(address)
|| isLinkLocal(address)
|| isPrivate(address)
|| isReserved(address)) {
throw new SecurityException("blocked destination");
}
}
HttpResponse<InputStream> response = sendWithLimits(target);
validateContentType(response.headers());
copyWithMaximumSize(response.body(), MAX_BYTES);这里的代码只是结构示意,实际项目应优先使用经过维护的 URL、IP 地址和 HTTP 客户端库。尤其要确认库对代理、连接池、DNS 缓存、IPv6 和重定向的具体行为。
八、如何在本地完成安全验证
验证 SSRF 时不应直接探测真实内网。可以在本机启动两个独立测试服务:一个模拟业务接口,另一个模拟“受保护资源”,仅返回固定文本并记录访问日志。测试重点放在应用是否拒绝不符合策略的目标,以及重定向、超时和大响应是否受到限制。
python3 -m http.server 18080 --bind 127.0.0.1然后在单元测试或集成测试中覆盖以下输入类别:
- 缺少协议、空主机、非法端口和包含用户信息的 URL。
- 大小写混合的协议和主机名。
- 解析后指向回环、私有地址或链路本地地址的主机名。
- 返回重定向的测试服务。
- 慢响应、超大响应和错误内容类型。
验证记录应至少包含原始输入、规范化结果、最终解析地址、是否发生重定向、拒绝原因和耗时。日志中不要记录可能包含令牌、密码或完整查询参数的敏感 URL。
九、代码审计检查清单
| 检查项 | 关注点 |
|---|---|
| 输入来源 | URL 是否来自用户、消息队列、数据库或可间接控制的配置。 |
| 解析方式 | 是否使用标准 URI 解析,是否存在字符串拼接和多次解析。 |
| 协议限制 | 是否只允许业务需要的协议,并拒绝空协议和非预期协议。 |
| 目标限制 | 是否校验主机、端口、解析后的全部 IP 地址。 |
| 重定向 | 是否关闭自动跟随,或对每次跳转重新校验。 |
| 网络边界 | 运行环境是否通过防火墙、代理或安全组限制出站访问。 |
| 响应处理 | 是否限制大小、超时、内容类型和解析器能力。 |
| 敏感信息 | 是否自动转发 Cookie、Authorization 或内部代理凭据。 |
十、总结
SSRF 审计的核心不是寻找一个简单的黑名单绕过点,而是还原“输入如何变成最终网络连接”的完整链路。可靠的判断应同时覆盖 URL 解析、DNS 解析、重定向、代理行为、连接地址、请求能力和响应处理。
从防守角度看,应用层校验、出站代理和网络隔离应当形成纵深防御。应用层负责明确业务允许访问什么,代理负责统一执行出站策略,网络层负责阻断不必要的内部和管理平面访问。三者结合,才能避免单个解析差异或配置疏漏导致整个边界失效。
推荐意见