背景
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 真正麻烦的地方在于边界模糊:它看起来是一个普通业务功能,但实际连接的是服务端所在网络。只要把这个边界想清楚,修复思路就会清晰很多。"}
推荐意见