背景
SSRF(Server-Side Request Forgery)这类问题在代码审计和漏洞复现里很常见,但很多复盘文章容易停留在“传一个 URL,服务端帮忙请求”这个层面。实际项目里,SSRF 往往藏在图片抓取、Webhook 校验、文件导入、URL 预览、PDF 生成、对象存储回源等功能中,表面看是业务能力,底层却是服务端主动发起网络请求。
这篇文章整理一个偏真实的审计思路:如何从入口定位 SSRF,如何判断影响面,如何做安全复现,以及如何把修复措施落到代码和网络层。文中不会涉及内网探测、云元数据读取等攻击引导,只关注漏洞分析和防护实现。
典型场景
某业务系统提供“远程图片上传”功能,用户输入一个图片 URL,后端下载图片后保存到对象存储。简化后的调用链大致如下:
Controller: /api/image/fetch
- 接收参数 imageUrl
Service: ImageFetchService.fetch(imageUrl)
- 校验后缀是否为 jpg/png/gif
- 使用 HTTP Client 下载资源
- 判断 Content-Type
- 保存文件从业务上看,这个功能合理;从安全上看,风险点在于服务端会根据用户输入访问任意地址。如果校验只做字符串后缀或 Content-Type 判断,就容易出现 SSRF。
问题定位
审计这类功能时,我一般先找“服务端主动请求外部资源”的代码,而不是直接搜索 SSRF 关键词。常见入口包括:
- 图片、头像、附件的 URL 上传或搬运。
- 文章链接预览、OpenGraph 信息抓取。
- Webhook 回调地址配置和测试。
- 在线导入远程配置、远程模板、远程 CSV。
- PDF/截图服务中引用外部资源。
- 服务端代理接口,例如 /proxy?url=。
代码层面可以重点搜索这些 API 或类名:
// Java 常见关键字
RestTemplate
WebClient
HttpClient
OkHttpClient
URLConnection
URI.create
URL.openConnection
// Node.js 常见关键字
axios(
fetch(
request(
got(
http.get
https.get
// PHP 常见关键字
file_get_contents(
curl_exec(
fopen(
stream_context_create(如果发现请求目标可由用户输入控制,就需要继续检查三个点:是否限制协议、是否限制目标地址、是否处理跳转。
不安全实现示例
下面是一个常见的简化版 Java 实现。问题不在于使用了某个具体 HTTP 客户端,而在于对 URL 的信任边界不清晰。
public byte[] fetchImage(String imageUrl) {
if (!imageUrl.endsWith(".jpg") && !imageUrl.endsWith(".png")) {
throw new IllegalArgumentException("unsupported file type");
}
ResponseEntity<byte[]> resp = restTemplate.getForEntity(imageUrl, byte[].class);
String contentType = resp.getHeaders().getFirst("Content-Type");
if (contentType == null || !contentType.startsWith("image/")) {
throw new IllegalArgumentException("invalid image");
}
return resp.getBody();
}这段代码的问题主要有:
- 仅用后缀判断资源类型,URL 路径和真实响应没有强绑定。
- 没有限制协议,可能接受非预期 scheme。
- 没有限制目标主机,用户可以让服务端访问任意地址。
- 没有处理 DNS 解析结果,无法防止域名解析到保留地址。
- 没有限制重定向,初始 URL 合法但跳转目标可能不合法。
- 没有设置合理的超时和大小限制,可能引发资源消耗问题。
安全复现建议
在测试环境里复现 SSRF,不建议直接对生产内网、云元数据地址、第三方目标做请求。更稳妥的方式是搭一个可控的本地 HTTP 服务,观察服务端是否发起了请求。
# 在测试机上启动一个简单 HTTP 服务
python3 -m http.server 8000然后在业务功能里填写测试 URL,例如:
HTTP 服务日志里出现来自业务服务器的访问记录,就说明该功能存在服务端代请求行为。接下来再验证安全策略是否完善,比如是否允许非白名单域名、是否跟随跳转、是否处理异常 Content-Type 等。如果需要验证重定向场景,可以在测试环境里写一个只跳到另一测试域名的接口,确认后端是否会在跳转后重新做地址校验。这里的关键不是“跳到哪里”,而是确认“每一次最终请求前是否都执行了同一套安全校验”。
技术分析:SSRF 防护不能只靠黑名单
很多修复会写成“禁止访问 127.0.0.1、localhost、内网网段”,这比完全不处理要好,但并不可靠。原因包括:
- 地址表示形式很多,IPv4、IPv6、整数、八进制、十六进制等都可能造成解析差异。
- localhost 只是一个名称,不等同于所有本地地址。
- 域名可以通过 DNS 解析到保留地址。
- DNS 结果可能发生变化,存在 TOCTOU 问题。
- HTTP 重定向会让实际访问目标和初始输入不同。
更推荐的策略是“白名单 + 解析后校验 + 禁止非预期协议 + 每次跳转重新校验 + 网络层兜底”。如果业务能明确只允许访问公司自有 CDN 或指定对象存储域名,就不要开放任意 URL。
修复思路
1. 限制协议
只允许 http 和 https,拒绝 file、gopher、ftp、jar 等非业务必需协议。不同语言和 HTTP 客户端对 scheme 的支持不完全一致,最好在进入请求库前就做显式判断。
URI uri = URI.create(inputUrl);
String scheme = uri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("unsupported scheme");
}
2. 使用域名白名单
白名单应基于业务域名,而不是用户可随意填写的完整 URL。比如只允许:
- img.example.com
- static.examplecdn.com
- bucket.example-oss.com
注意不要用简单的 contains 判断域名,否则 example.com.evil.test 这类字符串会误判。应使用标准 URI 解析结果中的 host,并做精确匹配或受控的后缀匹配。
private static final Set<String> ALLOWED_HOSTS = Set.of(
"img.example.com",
"static.examplecdn.com"
);
private boolean isAllowedHost(String host) {
if (host == null) return false;
String normalized = host.toLowerCase(Locale.ROOT);
return ALLOWED_HOSTS.contains(normalized);
}
如果确实要允许子域名,建议明确后缀边界:
private boolean isAllowedSubdomain(String host) {
if (host == null) return false;
String h = host.toLowerCase(Locale.ROOT);
return h.equals("examplecdn.com") || h.endsWith(".examplecdn.com");
}
3. 解析后校验 IP
即使启用了域名白名单,也建议对解析结果做保留地址过滤,避免 DNS 配置错误或供应链问题带来风险。常见需要拒绝的地址包括:
- 本地回环地址。
- 私有地址段。
- 链路本地地址。
- 组播地址。
- 未指定地址。
- IPv6 本地地址。
private boolean isSafeAddress(InetAddress address) {
return !(address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress());
}
这里要注意,Java 的 isSiteLocalAddress 覆盖常见私有地址,但实际环境里还可以结合网关策略补充更严格的网段控制。应用层校验不是网络边界的替代品。
4. 处理重定向
自动跟随重定向很容易绕过初始校验。建议关闭 HTTP 客户端的自动跳转,手动处理 3xx 响应,并对 Location 指向的新 URL 重新执行同一套校验。
// 伪代码:每次请求前都校验 URL
String currentUrl = inputUrl;
for (int i = 0; i < 3; i++) {
validateUrl(currentUrl);
HttpResponse resp = httpClient.get(currentUrl, followRedirects = false);
if (resp.statusCode() / 100 == 3) {
String location = resp.header("Location");
currentUrl = resolveRedirect(currentUrl, location);
continue;
}
return resp;
}
throw new IllegalArgumentException("too many redirects");
重定向次数也应限制,通常 3 次以内足够满足业务需求。
5. 限制响应大小和超时
远程抓取接口还容易被用来消耗资源。建议设置连接超时、读取超时、最大响应大小,并避免一次性把未知大小响应读入内存。
connectTimeout: 2s
readTimeout: 5s
maxResponseSize: 5MB
maxRedirects: 3
下载文件时可以边读边计数,超过阈值立即中断。仅依赖 Content-Length 不够,因为对端可以不返回或返回不可信值。
6. 内容校验不要只看 Content-Type
Content-Type 是服务端声明,不是可靠的文件类型证明。图片类业务可以读取文件头做基础校验,必要时用图片库实际解码一次。这样可以避免把 HTML、脚本、压缩包等非预期内容保存为图片。
boolean looksLikePng(byte[] header) {
return header.length >= 8
&& header[0] == (byte)0x89
&& header[1] == 0x50
&& header[2] == 0x4E
&& header[3] == 0x47;
}
如果后续文件会被浏览器直接访问,还要配合对象存储的 Content-Disposition、Content-Type 固定化、独立域名隔离等策略,避免上传内容引发其他类型风险。
防护落地示例
一个相对完整的校验流程可以设计成:
validate(inputUrl):
1. 解析 URI,拒绝解析失败的输入
2. 只允许 http/https
3. 提取 host,禁止空 host
4. 校验 host 是否在业务白名单
5. DNS 解析 host
6. 校验所有解析结果都不属于保留地址
7. 发起请求时禁用自动重定向
8. 如遇 3xx,解析 Location 后回到第 1 步
9. 限制超时、响应大小、文件类型
需要强调一点:如果应用在请求前解析了一次域名,而 HTTP 客户端请求时又自行解析一次,理论上仍可能存在解析结果不一致的问题。更严谨的做法是在网络出口层做限制,只允许访问必要的外部服务。应用层负责减少误用,网络层负责兜底。
网络层兜底
SSRF 的影响大小,很大程度取决于服务端所在网络能访问什么。生产环境建议配合以下措施:
- 业务服务默认禁止访问内网敏感网段,只开放必要外联。
- 通过 egress proxy 统一外联,并在代理侧做域名白名单。
- 云环境里对元数据服务启用更严格的访问控制和实例角色最小权限。
- 容器环境限制 Pod 出站访问,避免默认全通。
- 对异常外联做日志审计和告警。
在实际事件里,单纯修代码经常不够。代码遗漏、历史接口、第三方组件都有可能再次引入 SSRF。网络出口策略能显著降低问题扩大化的概率。
审计检查清单
检查项 建议 用户是否可控 URL 确认参数、配置、数据库字段是否能影响服务端请求目标 协议限制 只允许业务需要的 http/https 域名限制 优先白名单,避免 contains 这类字符串判断 DNS 解析 解析后校验 IP,拒绝保留地址 重定向 关闭自动跳转,手动校验每一个 Location 资源限制 设置超时、大小、重定向次数 内容校验 不要只信 Content-Type,必要时校验文件头或解码 网络出口 服务侧最小出站权限,代理统一管控 日志审计 记录目标域名、状态码、耗时、拒绝原因
常见误区
- 只过滤 localhost:本地地址不只有 localhost。
- 只过滤 127.0.0.1:还有 IPv6、其他回环表示和地址变形。
- 只判断 URL 后缀:路径不代表响应内容。
- 只信 Content-Type:响应头可由对端任意设置。
- 认为白名单域名一定安全:DNS 配置、CNAME 链和供应链也需要关注。
- 修了一个接口就结束:类似逻辑通常在多个业务模块重复出现。
日志与监控建议
SSRF 防护不只是拒绝请求,还应该能发现异常使用。建议记录以下字段:
- 调用接口和业务用户标识。
- 原始输入 URL。
- 解析后的 host 和 IP。
- 是否发生重定向,以及最终 URL。
- 拒绝原因或成功状态码。
- 请求耗时和响应大小。
日志里注意脱敏,不要记录敏感 token 或完整鉴权参数。对于短时间大量访问不同域名、频繁触发拒绝规则、请求耗时异常的行为,可以做基础告警。
总结
SSRF 的核心不是某个 payload,而是“服务端把用户输入当成网络请求目标”。审计时先找服务端代请求入口,再看协议、域名、解析、重定向和资源限制。修复时不要依赖黑名单,优先采用业务白名单,并把校验放到每一次真实请求前。
从长期治理角度看,建议把远程抓取能力封装成统一组件,业务方不要各写各的 HTTP 请求逻辑。组件层负责 URL 校验、跳转处理、超时大小限制、日志审计;网络层再做出口白名单。这样比在每个接口里临时补几行判断要稳定得多,也更容易通过代码审计和安全测试。

代码审计、调用链与关键函数定位示意
推荐意见