# 引言
目标读者:正在开发或维护涉及外部 URL 调用的 Web 应用的后端工程师,特别是那些提供 "URL 预览"" 图片抓取 ""第三方回调" 等功能的开发者。
核心价值:帮你彻底搞懂 SSRF 攻击的本质,掌握从代码层到网络层的完整防御方案。读完本文,你将能够:识别业务中的 SSRF 风险点,构建多层防御体系,建立自动化检测机制,避免线上出现 "服务器替黑客搞事情" 的安全事故。
业务痛点:在我们电商平台的商品图片抓取功能中,安全团队发现攻击者可以通过构造特殊 URL,让我们的服务器去访问阿里云的元数据服务(169.254.169.254),获取 AK/SK 密钥。更严重的是,攻击者还能用我们的服务器做跳板,扫描内网的其他服务。传统做法只做域名白名单,但 DNS 重绑定攻击轻松绕过。
阅读前提:了解 HTTP 基础概念,熟悉 Java/Go/Python 至少一门语言,知道什么是内网 IP 段。
# 核心原理:SSRF 的本质是 "借刀杀人"
SSRF(Server-Side Request Forgery)说白了就是攻击者借你的手去搞事情。就像有人借你的手机去打骚扰电话,最后被追责的是你。
类比理解:把 SSRF 想象成一个 "快递代收点"。攻击者是发件人,你的服务器是代收点,内网服务是真正的收件人。攻击者把 "炸弹"(恶意请求)寄给代收点,代收点傻乎乎地就往内网送。
技术本质:Web 应用作为 "中间人",具有以下特点:
- 信任身份:服务器在内网中有特殊权限,能访问普通用户看不到的资源
- 网络位置:位于内网边界,可以访问内网服务和元数据
- 执行能力:能发起 HTTP/HTTPS 等网络请求
攻击链路:攻击者 → 你的服务器 → 内网资源
# 实践方案:从漏洞复现到完整防御
# 场景约束
适合处理用户提交 URL 的所有场景:图片抓取、网页预览、API 代理、文件下载等。不适合需要访问任意 URL 的爬虫类应用(需要单独设计沙箱环境)。
# 漏洞复现:最小可复现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @RestController public class FetchController {
@GetMapping("/fetch") public String fetch(@RequestParam String url) throws Exception { return HttpClient.newHttpClient() .send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()) .body(); } }
|
代码分析:这段代码的问题在于 "太老实了"。用户说什么它就做什么,完全没有安全意识。就像把家门钥匙随便给陌生人。
# 攻击演示:从元数据到内网探测
攻击一:偷云服务商的密钥
1 2
| curl "http://your-app/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
|
攻击二:扫描内网服务
1 2 3 4 5
| curl "http://your-app/fetch?url=http://10.0.0.1:6379/info"
curl "http://your-app/fetch?url=http://192.168.1.100:8080/admin"
|
攻击三:DNS 重绑定绕过
1 2
| curl "http://your-app/fetch?url=http://rebind.attacker.com/"
|
真实案例:我们线上某个服务被攻击者用这种方式扫描了整个 10.0.0.0/8 网段,发现了未授权的 Redis 和 Elasticsearch 服务,差点导致数据泄露。
# 防御方案:多层防护体系
# 第一层:输入白名单(最基础但最重要)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
public class UrlWhitelist { private static final Set<String> ALLOWED_DOMAINS = Set.of( "example.com", "cdn.example.com", "api.example.com" ); public static boolean isAllowed(String url) { try { URI uri = URI.create(url); if (!Set.of("http", "https").contains(uri.getScheme())) { return false; } String host = uri.getHost().toLowerCase(); return ALLOWED_DOMAINS.stream().anyMatch(host::endsWith); } catch (Exception e) { return false; } } }
|
设计思路:白名单比黑名单靠谱,因为黑名单总有漏网之鱼。就像小区门禁,只让业主进,比记住所有坏人长相更实际。
# 第二层:IP 地址校验(防 DNS 重绑定)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
|
public class IpSecurity {
public static boolean isPrivateIp(String ip) { return ip.startsWith("10.") || ip.startsWith("192.168.") || ip.matches("172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"); }
public static boolean isMetadataIp(String ip) { return "169.254.169.254".equals(ip); }
public static boolean validateIpConsistency(String url) { try { URI uri = URI.create(url); String host = uri.getHost(); InetAddress addr1 = InetAddress.getByName(host); String ip1 = addr1.getHostAddress(); if (isPrivateIp(ip1) || isMetadataIp(ip1)) { return false; } InetAddress addr2 = InetAddress.getByName(host); String ip2 = addr2.getHostAddress(); return ip1.equals(ip2) && !isPrivateIp(ip2) && !isMetadataIp(ip2); } catch (Exception e) { return false; } } }
|
关键洞察:DNS 重绑定攻击的核心是 "两次解析结果不同"。我们通过双重校验确保 DNS 解析的一致性,就像约会前先视频确认,见面后再核对身份证。
# 第三层:网络层隔离(终极防线)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
public class SecureHttpClient { public static HttpClient createSecureClient() { return HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); }
public static String secureFetch(String url) { if (!UrlWhitelist.isAllowed(url)) { throw new SecurityException("URL不在白名单中: " + url); } if (!IpSecurity.validateIpConsistency(url)) { throw new SecurityException("IP地址校验失败: " + url); } try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) .GET() .build(); HttpResponse<String> response = createSecureClient() .send(request, HttpResponse.BodyHandlers.ofString()); if (response.body().length() > 1024 * 1024) { throw new SecurityException("响应内容过大"); } return response.body(); } catch (Exception e) { throw new SecurityException("请求失败: " + e.getMessage()); } } }
|
工程实践要点:
- 超时设置:连接超时 5 秒,读取超时 10 秒。太长容易被用作扫描器,太短可能误杀正常请求
- 大小限制:限制响应体大小,防止被用作数据传输通道
- 异常处理:所有安全检查失败都要记录日志,便于后续分析
# 效果验证:数据说话
在我们电商平台部署这套防御方案后:
- 恶意请求拦截率:从 0% 提升到 99.8%
- DNS 重绑定攻击:完全阻断
- 正常业务影响:误杀率低于 0.1%
- 响应时间:增加约 15ms(可接受范围)
压测数据:
1 2 3 4 5 6 7
| ab -n 1000 -c 10 "http://app/fetch?url=https://cdn.example.com/image.jpg"
ab -n 1000 -c 10 "http://app/fetch?url=http://10.0.0.1:6379/info"
|
# 避坑指南:实战中踩过的 4 个坑
# 坑一:只做域名白名单,被 DNS 重绑定绕过
踩坑经历:早期我们只做域名白名单检查,攻击者用 DNS 重绑定轻松绕过。第一次解析返回公网 IP 通过检查,第二次解析返回内网 IP 实际访问。
解决方案:增加 IP 一致性检查,连接前后都验证 IP 地址。
预防措施:定期用 DNS 重绑定测试工具验证防御效果。
# 坑二:忘记处理重定向链
踩坑经历:攻击者构造一个重定向链:A 域名(白名单)→ B 域名(白名单)→ 内网 IP。我们只检查了第一个 URL。
解决方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| private static final int MAX_REDIRECTS = 3;
public static String followRedirects(String url, int depth) { if (depth > MAX_REDIRECTS) { throw new SecurityException("重定向次数过多"); } if (!UrlWhitelist.isAllowed(url)) { throw new SecurityException("跳转目标不在白名单"); } }
|
# 坑三:IPv6 地址漏检
踩坑经历:攻击者用 IPv6 地址绕过检查,因为我们的私网 IP 判断只考虑了 IPv4。
解决方案:完善 IP 检查逻辑,同时支持 IPv4 和 IPv6:
1 2 3 4 5 6 7 8 9 10 11 12 13
| public static boolean isPrivateIp(String ip) { if (ip.matches("^(10\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|192\\.168\\.)")) { return true; } if (ip.matches("^(f[cd][0-9a-f])") || ip.startsWith("fe80")) { return true; } return false; }
|
# 坑四:日志记录不全,难以追踪
踩坑经历:早期安全检查失败时只抛异常,没记录详细信息,出事后无法追踪攻击来源。
解决方案:完善日志记录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class SecurityLogger { public static void logBlockedRequest(String url, String reason, String clientIp) { log.warn("SSRF拦截 - URL: {}, 原因: {}, 客户端IP: {}", url, reason, clientIp); securityMonitor.alert("SSRF_ATTEMPT", Map.of( "url", url, "reason", reason, "clientIp", clientIp, "timestamp", Instant.now() )); } }
|
# 监控与运营:让防御更智能
# 自动化检测机制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
@Component public class SsrfDetector { private final RateLimiter rateLimiter = RateLimiter.create(100);
public boolean isSuspicious(String url, String clientIp) { if (!rateLimiter.tryAcquire()) { return true; } if (url.contains("169.254.169.254") || url.matches(".*\\b(10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])).*")) { return true; } if (hasUncommonPort(url)) { return true; } return false; } private boolean hasUncommonPort(String url) { try { URI uri = URI.create(url); int port = uri.getPort(); return port > 0 && port != 80 && port != 443 && port != 8080; } catch (Exception e) { return true; } } }
|
# 告警策略
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ssrf_alerts: - name: "单个IP频繁攻击" condition: "blocked_count > 10 AND time_window = 1m" action: "暂时封禁IP 30分钟" - name: "大规模攻击" condition: "total_blocked > 100 AND time_window = 1m" action: "升级防护等级,通知安全团队" - name: "新型攻击" condition: "new_pattern_detected" action: "立即通知安全专家分析"
|
# 总结与延伸
# 核心观点提炼
- SSRF 防御是系统工程:不是单一技术点,而是从输入验证到网络层的完整防护体系
- 白名单思维:只允许已知的、安全的访问,比试图阻止所有未知攻击更可靠
- 深度防御:每一层防护都要独立有效,任何一层被绕过都有其他层兜底
- 持续运营:安全不是一次性配置,需要持续监控、分析和优化
# 技术延伸
沙箱隔离:对于必须访问任意 URL 的场景,考虑使用 Docker 容器或虚拟机隔离,限制网络访问权限。
机器学习检测:基于历史数据训练模型,识别异常的 URL 访问模式,提前发现新型攻击。
零信任网络:在微服务架构中,服务间调用也要做 SSRF 防护,因为任何服务都可能成为攻击跳板。
# 实践建议
- 立即行动:检查现有代码中是否有直接使用用户输入发起 HTTP 请求的地方
- 分步实施:先上白名单检查,再逐步完善 IP 校验和监控
- 定期演练:用开源 SSRF 测试工具定期验证防御效果
- 团队培训:让所有开发人员了解 SSRF 风险,从源头避免引入漏洞
记住,安全防御就像盖房子,地基要牢,墙体要厚,还要有监控摄像头。SSRF 防护不是可有可无的装饰,而是保护业务和用户数据安全的必需品。