背景SSRF(Server-Side Request Forgery)在 Web 安全里属于非常常见但容易被低估的一类问题。它的核心风险不是“服务端能发请求”本身,而是攻击者可以影响服务端请求的目标、协议或关键参数,从而访问本不该暴露的内部资源。实际项目中,SSRF 常出现在这些功能里:远程图片抓取、URL 预览、Webhook 回调、文件导入、PDF/截图生成、站点连通性检测、第三方资源同步等。本文整理一次偏防御视角的 SSRF 复现与代码审计思路,重点放在如何定位问题、验证风险、修复和回归测试,不包含对未授权目标的攻击利用。问题场景假设某业务提供“远程图片上传”功能,用户提交一个图片 URL,后端下载后保存到对象存储或本地文件系统。简化后的业务流程如下:用户提交一个 URL。后端校验 URL 格式。后端发起 HTTP 请求下载内容。判断 Content-Type 或文件后缀。保存文件并返回访问地址。这类功能如果只校验了 URL 是否以 http:// 或 https:// 开头,而没有限制目标地址范围,就可能导致服务端访问内网地址、云厂商元数据地址、本机服务或管理端口。风险边界SSRF 的风险通常取决于服务端所在网络环境。常见影响包括:访问内网 HTTP 服务,例如内部管理后台、监控面板、未暴露到公网的接口。探测本机端口或内网网段的存活情况。访问云环境中的实例元数据服务,造成临时凭据泄露风险。通过重定向、DNS 解析变化、IPv6、整数 IP、短地址等方式绕过简单黑名单。测试时建议只在授权环境、靶场或自建环境中验证。SSRF 漏洞的危害经常和内网环境绑定,未经授权的内网探测和访问都不应进行。漏洞代码示例下面是一个常见的 Java 示例,问题在于只做了协议字符串判断,没有对解析后的主机、IP 归属、重定向目标进行限制。public String fetchImage(String url) throws Exception {
if (!url.startsWith(\" && !url.startsWith(\" {
throw new IllegalArgumentException(\"invalid url\");
}
URL target = new URL(url);
HttpURLConnection conn = (HttpURLConnection) target.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(5000);
conn.setInstanceFollowRedirects(true);
String contentType = conn.getContentType();
if (contentType == null || !contentType.startsWith(\"image/\")) {
throw new IllegalArgumentException(\"not image\");
}
try (InputStream in = conn.getInputStream()) {
return saveToStorage(in);
}
}这段代码的典型问题:使用 startsWith 判断协议,容易被解析差异影响。未限制访问的主机范围。未判断解析后的 IP 是否属于内网、回环、链路本地等地址段。自动跟随重定向,但没有校验重定向后的目标。只依赖 Content-Type 判断文件类型,容易被服务端响应伪造。复现思路在自建测试环境中,可以准备一个简单的回显 HTTP 服务,用于确认后端是否会代替用户发起请求。python3 -m http.server 8000然后在业务功能里提交: IP 的请求,说明服务端确实会主动访问用户传入的 URL。接下来应在授权环境中验证边界条件,而不是直接扫描内网。常见验证点包括:是否允许访问 127.0.0.1、localhost、0.0.0.0 等本机地址。是否允许访问 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 等私有地址。是否允许访问 169.254.0.0/16 这类链路本地地址。是否跟随 302 跳转到受限地址。是否存在 DNS Rebinding 风险。是否支持 file、gopher、ftp 等非预期协议。关键绕过点的防御分析很多 SSRF 修复失败,不是因为没有过滤,而是过滤位置和解析方式不对。下面几个点在代码审计里尤其需要注意。1. 不要只做字符串黑名单类似下面的过滤方式不可靠:if (url.contains(\"127.0.0.1\") || url.contains(\"localhost\")) {
throw new IllegalArgumentException(\"blocked\");
}原因是 URL 的表现形式很多,字符串黑名单很难覆盖完整。例如域名解析到内网、IPv6 地址、编码变体、整数 IP、八进制或十六进制形式,都可能导致判断和实际访问目标不一致。2. 必须校验解析后的 IP更可靠的方式是:先用标准 URL 解析库解析出 host,再进行 DNS 解析,最后判断所有解析出的 IP 是否允许访问。private boolean isPrivateOrLocalAddress(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isSiteLocalAddress()
|| address.isLinkLocalAddress()
|| address.isMulticastAddress();
}对于云环境,还应明确阻断元数据地址段,例如常见的链路本地元数据地址:169.254.169.254实际生产中建议维护一份 deny range 或 allow range。安全级别更高的做法是白名单,只允许访问业务明确需要的域名或对象存储域名。3. 重定向后要重新校验不少漏洞出现在“初始 URL 合法,但跳转目标非法”的场景。修复时建议关闭自动重定向,手动处理 3xx,并对每一次 Location 目标重新执行完整校验。conn.setInstanceFollowRedirects(false);
int status = conn.getResponseCode();
if (status >= 300 && status < 400) {
String location = conn.getHeaderField(\"Location\");
// 解析 location
// 重新校验协议、host、解析后的 IP
// 限制最大跳转次数
}同时要限制跳转次数,例如最多 3 次,避免循环跳转造成资源消耗。4. 注意 DNS RebindingDNS Rebinding 的核心问题是:第一次解析时是公网 IP,通过校验;真正连接或后续解析时变成内网 IP。防御时需要尽量保证“校验的 IP”和“连接的 IP”一致。常见工程化处理方式:解析域名后固定使用解析得到的 IP 建立连接。连接前后不要重复使用未校验的域名重新解析。对所有 A/AAAA 记录进行校验,而不是只取第一个。对用户可控域名谨慎放行,优先使用白名单域名。5. 限制协议和端口从审计经验看,协议和端口限制经常被忽略。对于图片抓取这类场景,通常只需要 http 和 https,端口也可以限制为 80 和 443,或者限制在明确的业务端口范围内。URI uri = new URI(input);
String scheme = uri.getScheme();
if (!\"http\".equalsIgnoreCase(scheme) && !\"https\".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException(\"unsupported scheme\");
}
int port = uri.getPort();
if (port != -1 && port != 80 && port != 443) {
throw new IllegalArgumentException(\"unsupported port\");
}不要让用户输入影响底层请求库支持的非预期协议。不同语言和库对 URL 的处理差异较大,代码审计时要结合具体框架确认。修复示例下面是一个偏防御的伪代码流程,重点是修复思路,不建议直接复制到生产环境。生产代码还需要结合代理、网络策略、对象存储域名和日志规范处理。public void validateTargetUrl(String input) throws Exception {
URI uri = new URI(input).normalize();
String scheme = uri.getScheme();
if (!\"http\".equalsIgnoreCase(scheme) && !\"https\".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException(\"unsupported scheme\");
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new IllegalArgumentException(\"invalid host\");
}
int port = uri.getPort();
if (port != -1 && port != 80 && port != 443) {
throw new IllegalArgumentException(\"unsupported port\");
}
InetAddress[] addresses = InetAddress.getAllByName(host);
for (InetAddress address : addresses) {
if (isPrivateOrLocalAddress(address) || isCloudMetadataAddress(address)) {
throw new IllegalArgumentException(\"blocked address\");
}
}
}元数据地址判断可以单独封装,避免遗漏:private boolean isCloudMetadataAddress(InetAddress address) {
String ip = address.getHostAddress();
return \"169.254.169.254\".equals(ip)
|| ip.startsWith(\"169.254.\");
}更稳妥的架构方案是:应用层校验 + 出口网络控制一起做。只靠代码过滤很容易出现边界遗漏。网络层加固建议如果业务确实需要服务端访问外部 URL,建议配合网络层做限制:服务所在容器或主机禁止访问内网管理网段。禁止业务容器访问云元数据服务,或为元数据服务开启更严格的访问保护。使用固定出口代理,由代理层做域名、IP、协议和端口控制。对出站请求设置审计日志,记录目标 host、解析 IP、端口、响应状态和耗时。对下载大小、响应时间、重定向次数做限制,避免资源消耗型问题。代码审计检查表审计 SSRF 时,可以按下面的表格快速过一遍:检查项关注点URL 来源是否来自用户输入、第三方回调、可编辑配置协议限制是否只允许 http/https,是否存在非预期协议Host 校验是否只做字符串判断,是否解析后校验IP 范围是否阻断回环、私有、链路本地、组播等地址重定向是否对每次跳转后的目标重新校验DNS是否校验所有解析结果,是否考虑 Rebinding端口是否限制到业务需要的端口请求限制是否设置超时、大小限制、跳转次数限制网络出口是否有代理或防火墙限制出站访问范围日志审计是否记录目标、解析 IP、状态码和异常原因回归测试建议修复后不要只测一个 localhost。建议准备一组固定测试用例,纳入安全回归:正常公网图片地址应允许。http 和 https 之外的协议应拒绝。127.0.0.1、localhost、本机内网地址应拒绝。10.0.0.1、172.16.0.1、192.168.1.1 应拒绝。169.254.169.254 应拒绝。初始 URL 合法但 302 到内网地址应拒绝。超大响应、慢响应、循环跳转应被限制。# 示例:本地启动一个用于测试重定向的服务时,注意只在授权环境内使用
# 重点验证业务是否会在重定向后重新执行安全校验注意点不要把 Content-Type 当作 SSRF 防护手段,它只能作为文件类型判断的一部分。不要只在前端做 URL 校验,后端必须做完整校验。不要假设“没有公网回显就没有风险”,很多 SSRF 的风险发生在内网访问链路。容器环境中要关注宿主机网关、服务发现域名、内部 DNS 名称。云环境中应重点关注元数据服务访问控制和实例角色权限最小化。总结SSRF 的修复难点不在于识别 这种显眼输入,而在于处理 URL 解析差异、DNS 解析结果、重定向、协议端口和网络边界。比较稳妥的方案是:业务上尽量使用白名单;代码层校验解析后的目标;网络层限制出口访问;日志层保留可审计信息。从长期维护角度看,任何“服务端根据用户输入访问远程资源”的功能都应该默认纳入 SSRF 风险评估。上线前做一次专项检查,通常比事后补洞成本低很多。
推荐意见