在代码审计的日常里,我们盯着参数、追着调用链、翻着配置文件,但有一种漏洞特别容易被忽略——它藏在一个本该“安稳”的网络请求里。这个家伙就是SSRF,全称服务端请求伪造(Server-Side Request Forgery)。简单说,攻击者让服务器替他发起请求,被请求的目标是内网、云元数据或其他隔离网络里本该受保护的服务。做Java这块几年,我见过太多因为一个“顺手”的URL参数引发的SSRF,也亲自动手挖过、修过不少,这篇文章就把我在这类漏洞上的审计经验和实战技巧完整写出来,适合正在做代码审计的开发、安全测试同学,以及想系统了解Java服务端请求风险的读者参考。
1. 先搞懂SSRF的形成逻辑和危害边界
很多刚接触安全的同学会把SSRF和"服务端请求别人提供的链接"混为一谈,但问题恰恰就藏在"服务端到底应该请求谁、能不能被攻击者说了算"这两点上。
1.1 从信任边界看SSRF的根因
任何一个网络系统都存在"信任边界":应用服务器能访问的内网资源,是外部用户直接访问不到的。外部攻击者通过公网只能摸到对外暴露的那几台机器,但内网的后端数据库、消息队列、配置中心、监控系统、Docker管理接口等,默认不对公网开放。SSRF的核心就是攻击者利用服务器作为跳板,把请求发到了这些原本够不到的内网地址。
我在审计中总结过一句话:只要代码里存在"由用户输入拼凑目标地址、服务端主动发起请求"这样的模式,就极大概率存在SSRF风险。而Java里发起请求的方式太多了——HttpURLConnection、HttpClient、RestTemplate、OkHttp、WebClient,甚至一些第三方SDK内部封装的请求方法,都可能是SSRF的藏身之处。
这里有个关键点:SSRF不只是"请求了一个内网IP"那么简单。它真正的危害在于服务器处在内网的可信位置,可以绕过防火墙的源IP校验、可以携带内网服务必需的认证上下文、可以访问云环境的管理元数据,这些都不是一台外部攻击机可以直接搞定的。
1.2 影响判定:什么样的SSRF是致命的
不是所有SSRF都能打穿你的系统。我一般从三个维度判断严重性:
- 能否读取云元数据。在部署于通用云环境的Java应用里,如果SSRF能访问到
http://169.254.169.254/latest/meta-data/这类地址,攻击者能直接获取云服务器的临时密钥,这意味着整个实例的权限都可能被接管。这是最严重的场景。 - 能否访问内网管理端口。比如Redis的6379、Elasticsearch的9200、Docker的2375等。能访问意味着攻击者可能进一步执行命令或批量拉取数据。
- 能否利用协议走私做更多操作。比如
file://协议读取服务器本地文件,或者gopher://协议向内网服务发送二进制数据包,把单纯的回显读取变成主动交互攻击。
在代码审计报告里,这三个场景我至少会写两个进去,这样甲方才能直观理解为什么一个"不起眼的URL参数"要按高危来修。
2. Java服务端请求的常见入口:审计时要盯紧的代码模式
在Java生态里,SSRF的代码形态有一定规律可循。下面这些模式是我每次代码审计时重点盯的对象。
2.1 直接拼接URL的接口方法层
这类模式最容易发现。看代码时你只要追踪参数是否有"URL"、"url"、"link"、"redirect"、"imgUrl"之类的字段,然后看它是否进入了请求类方法。典型代码大概是:
@GetMapping("/fetch") public String fetch(String url) { URL target = new URL(url); HttpURLConnection conn = (HttpURLConnection) target.openConnection(); conn.setRequestMethod("GET"); // ... return IOUtils.toString(conn.getInputStream(), StandardCharsets.UTF_8); }这种直接把url参数交给openConnection()的写法,是最原始的SSRF漏洞,没有任何限制,http://、https://、file://都可以打。
还有一种变体是"间接拼凑":用户传域名或主机名,后面加上固定路径的拼接,比如:
String base = request.getParameter("host"); String fullUrl = "http://" + base + "/api/v1/info"; // fetch(fullUrl)表面看只传了主机名,但配合URL解析的边界问题,一样能搞出乱子。审计时不要只看参数名是否叫"url",凡是可以影响最终请求地址的输入,都要当作可疑点。
2.2 使用HttpClient和RestTemplate的坑
我在实际审计中发现,不少人觉得用HttpClient或RestTemplate封装过的请求一定更安全,其实毫无关系,封装只是让代码更好看,该打的还是打。
看下面这段用RestTemplate的代码,在真实项目里经常出现:
@Service public class DataCenterClient { public String query(String host, String path) { String url = "http://" + host + path; ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class); return resp.getBody(); } }getForEntity内部会转成HttpURLConnection或者SimpleClientHttpRequestFactory去执行请求,如果host可被用户控制,那就是一个标准的SSRF。同理,像OkHttp的Request.Builder().url(userInput).build(),以及Java 11之后java.net.http包里的HttpClient,都存在同样的风险。
提示:审计时不能只搜
RestTemplate、OkHttp、HttpClient这些关键词,还要看它们所在的方法参数是否透传到了入口层。一个"看起来只是内部工具类"的方法,如果上层的Controller直接拿来用了,风险就完全不一样。
2.3 URL解析类的边界问题
我看到不少团队在修SSRF时会加一段"校验"代码,大概意思是判断用户传入的URL以http/https开头就放行:
String url = request.getParameter("url"); if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new IllegalArgumentException("非法地址"); }这个校验错漏百出。像http://127.0.0.1当然是http开头,但你能说它安全吗?还有利用URL解析不一致绕过的情况,比如Java的URL类解析某些畸形地址时和HttpClient实际请求的目标不一致,造成"校验的是A、请求的是B"。
我印象很深的一个类是这样的:攻击者提交http://company.com@127.0.0.1:8080/admin,很多粗略的校验会认为host是company.com,但实际Java的HttpURLConnection会去请求127.0.0.1。这就是"用户信息字段"和"真实host"之间的认知差。审计时必须结合具体的URL解析器行为来看,不能拿字符串函数做安全结论。
3. 利用视角:SSRF在内网和云环境下的破坏力
理解漏洞如果只停在"能访问内网IP"这个层面,写出来的报告说服力和实际防御效果都会打折扣。我习惯把SSRF的利用分成几条递进的攻击线来讲。
3.1 内网拓扑探测与端口扫描
没有回显过滤的SSRF,可以直接当端口扫描器用。攻击者提交:
http://192.168.1.1:80 http://192.168.1.1:8080 http://192.168.1.1:3306通过响应时间、响应内容和报错差异,判断端口是否开放。这里我做过多轮对比实验,结论是响应时间差是最稳定的判断依据:连接被拒绝时通常毫秒级返回,而黑洞地址或防火墙DROP策略下请求会卡住几秒甚至超时。
这种探测不需要很复杂的工具,本身利用的就是应用的正常请求参数。防御端要意识到:只要SSRF存在,内网IP段、端口开放情况就会被快速摸清,后续的深入利用只是时间问题。
3.2 云环境元数据的访问问题
在云上部署的Java应用,几乎都有一个内网可见的元数据服务,通常地址是169.254.169.254。攻击者利用SSRF访问这个地址,能拿到实例的角色凭证,这在一些配置不当的场景下甚至可以直接换取临时API密钥:
http://169.254.169.254/latest/meta-data/iam/security-credentials/这就是为什么我在评估SSRF漏洞严重性时,首先看应用部署环境——如果是云主机,危害级别直接拉满。修复时必须堵住对这段地址的访问。
这里有个容易忽略的点:很多团队用域名白名单方式做防护,但元数据服务有时能通过http://metadata.google.internal这类内网域名访问,而不仅仅是IP。所以白名单校验不仅要过滤IP,还要把云厂商的内网域名纳进去。
3.3 利用file和gopher协议扩大影响
如果说HTTP协议的SSRF还只是"请求转发",那么file://协议直接让漏洞升级为任意文件读取:
file:///etc/passwd file:///proc/self/environJava的HttpURLConnection在遇到非HTTP协议时,会进入URL.openConnection()的不同处理器分支。如果代码没有做协议限制,file://协议就能读到服务器上的本地文件。想一下,拿到配置文件里的数据库口令、加密密钥意味着什么,不用我多说。
gopher://协议更激进,攻击者可以指定要发送的原始字节内容,从而与内网服务进行非HTTP协议交互,比如向Redis发送命令。这个利用链比较重,但审计时有个简单判断方法:看代码使用的请求库是否支持自定义协议处理器。Java自带的URL类天然支持多种协议,第三方HTTP库大多只限制为HTTP,这也是很多团队转向HttpClient等库后自认为"改善了安全"的原因之一。
4. 代码审计实操:从定位可疑点到构造验证请求
前面说的是原理和观察面,下面讲我自己做审计的完整路径,可以照着抄作业。
4.1 第一步:定位入口参数和服务端请求代码
我会先做一步代码检索,把潜在的危险方法全部拉出来。检索词包括但不限于:
# 常用检索模式 URL.new URL( openConnection RestTemplate HttpURLConnection HttpClients Request.Builder WebClient拿到这些调用点之后,沿调用链向上追溯,看方法参数是否最终被Controller入口直接接收。一个非常常见的现象是:漏洞点不在Controller本身,而在某个Service方法里,但Service方法的上层是Controller,Controller的@RequestParam直接进入了Service。
审计时还有一个技巧:多搜一下参数名带"url"、"src"、"target"、"endpoint"的@RequestParam和@PathVariable。参数命名本身不带安全属性,但这类名字常常绑定到"用户可控的请求地址"逻辑上,命中概率高。
4.2 第二步:分析协议处理器与URL解析行为
当确定某个代码片段确实会把用户输入传进openConnection()或HttpClient的URL时,我需要做一次"解析行为分析"。以HttpURLConnection为例,它的实际行为取决于项目依赖和JDK版本:
URL url = new URL(userInput); URLConnection conn = url.openConnection();如果userInput是file:///etc/hostname,openConnection()返回的是sun.net.www.protocol.file.FileURLConnection。也就是说,只要代码没有判断协议,所有协议都按"合法"处理。这也是审计时重点看的一块:协议白名单是否存在、是否作用于真实的请求连接之前。
4.3 第三步:快速构造验证请求
代码审计讲究"有证据"。我通常用下面的方式快速验证,而不是只写"疑似存在":
验证HTTP协议的SSRF:在测试环境的可回显接口上发起请求,目标用一个我能控制响应内容的地址。比如我在内网部署一个简单监听服务,然后通过漏洞参数访问它,确认服务端确实把响应内容带回来了。
验证端口扫描型SSRF:用/etc/hosts或本地监听的端口来做对照,比如请求http://127.0.0.1:8080,如果代码内网本身就跑着这个端口,通过响应差异就能确认可达性。
验证file协议:尝试fetchfile:///etc/passwd看是否返回了文件内容。有些代码框架会主动限制协议,有些不会,这是最快能确认漏洞真实性的验证手段。
这里要特别提醒:验证请求不能对生产环境做,也不要在没有授权的前提下扫别人的内网。代码审计一般是甲方授权范围内的行为,我都是在测试环境或者本地搭建的模拟环境中做验证。
4.4 写审计报告时的证据记录
一份好的SSRF审计记录至少包含:触发接口、完整请求示例、关键代码位置、受影响范围、验证过程中看到的响应差异。举个例子,我报告的常见格式是这样的:
GET /fetch?url=http://127.0.0.1:6379/test HTTP/1.1 Host: target.example配合代码截图和响应结果,开发修复的时候根本不需要反复找你来确认。
5. 修复方案与安全编码:不是加个if就完事
承上启下地说,了解攻击是防守的第一步,但真正考验功底的是修复质量。这一章我把值得落地的修复方案按优先级展开讲,每一步都结合我踩过的坑。
5.1 网络层兜底:在出口统一管控
我在不少公司里看到一个常见错误:只靠代码白名单修SSRF,结果内网别有其他出口路径,各种协议代理直接溜出去了。所以网络层兜底非常重要。
最有效的方案是让服务器所在安全组和防火墙做一个默认拒绝策略,再按需放行必须访问的外部地址白名单。比如业务只需要访问支付回调域名和对象存储域名,那安全组就只放行这两个域名对应的出口IP段,其他外部访问一律拒绝。这个策略天然把SSRF打向公网地址的利用面收敛住了。
对于内网访问,也建议按业务拆分:需要访问内部配置中心的服务器,就只允许访问配置中心的IP;不需要互相访问的微服务之间,网络策略就互相隔离。
5.2 应用层白名单:域名和IP双重校验
只靠网络层的兜底还不是完整答案,因为代码内部的请求逻辑可能被"合法请求"夹带私货。应用层的第一个修复动作是:必须做白名单校验,而不是黑名单。
白名单的实施有几个注意事项要讲清楚:
- 校验要发生在URL解析成真实host之后,不能只对字符串做规则匹配。
- 要对host做域名到IP的解析,再把IP放行列表比对。
- 解析后的IP要做反向校验,防止DNS重绑定绕过。
一段我实际用过的白名单逻辑大概是:
private boolean isAllowed(String host) { try { InetAddress addr = InetAddress.getByName(host); String ip = addr.getHostAddress(); return allowedIpSet.contains(ip); } catch (UnknownHostException e) { return false; } }注意,这个方案本身还要配套超时和重试限制,避免DNS查询被恶意拖慢。更稳妥的是使用内部DNS解析,并且缓存解析结果,外部用户不可控解析过程。
5.3 协议限制:掐断非HTTP协议通道
如果业务场景根本不需要传递file://、gopher://、ftp://这类协议,直接在入口处把协议校验死:
URI uri = URI.create(rawUrl); String scheme = uri.getScheme(); if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { throw new SecurityException("仅允许 http/https 协议"); }在Java中,这一步必须放在发起请求之前,并且要基于规范化后的URI判断,避免用户使用大小写变体或编码变体绕过。如果是用HttpClient封装的请求,天然只支持HTTP协议,那从协议层面已经安全了一大半,但地址校验依然不能少。
5.4 改造请求客户端:统一入口方便收口
在比较大的项目里,到处newHttpURLConnection会让后续加固非常被动。我建议把服务端对外请求收敛成一个统一的HTTP客户端Bean,所有出口都走这一个入口:
@Configuration public class HttpClientConfig { @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }然后业务代码全部注入这个RestTemplate,审计和应用层拦截器只要盯着这一个Bean做校验和审计日志,覆盖面就完整了。
实操心得:在统一客户端里做一层"请求前校验器",公司内部所有业务请求进入之前都过一遍。这个方案比在每个接口写一遍校验代码维护成本低很多,也避免"某个新接口忘了加白名单"的情况。
5.5 禁止重定向跟随
很多HTTP客户端默认会跟随重定向。即使你校验了原始URL是白名单地址,如果在请求过程中服务器返回一个302跳转到内网地址,客户端会继续跟随,照样可以到达内网。比如白名单域名302跳转到http://169.254.169.254,利用链就成功了。
修复方法有两个:
- 关闭自动跟随重定向:在
HttpURLConnection中设置setInstanceFollowRedirects(false),在HttpClientBuilder中禁用disableRedirectHandling()。 - 如果业务确实需要跟随重定向,那么在每次跳转后对新的URL重新做白名单校验。这要非常小心,因为多层跳转和编码变形很容易让校验失效。
我在实际修复时通常强制方法一,因为服务端业务很少真的需要HTTP自动重定向。真需要时,建议自行实现重定向逻辑,每次跳转都走统一校验。
5.6 限制返回内容,避免回显侧信道
有些SSRF即使不能直接回显,攻击者也能通过响应时间长短、响应状态码差异把内网信息盲打出来。所以修复时建议在客户端层面对响应大小做限制,比如超过多少KB就截断或报错。这样至少避免"一个响应100多KB直接原样返回给前端"这种把内网内容整包泄露的情况。
同时,在网关和WAF层针对这类接口设置响应内容的敏感信息过滤规则,虽然不能根治,但能降低被扫描识别出来的概率。
6. 审计和实战中的常见误区与避坑经验
我到目前为止反复强调的,其实是安全审计的"全局观":一个SSRF漏洞,从参数入口到网络出口,每一层都有机会被攻破或被修复。下面整理一些我真实踩过、也常见别人踩的坑,按场景分组写,方便你排查时对照。
6.1 域名解析循环校验导致的"看上去安全"
一种很常见的错误修复是:拿到URL后先往白名单域名集合里做字符串匹配,比如判断url.startsWith("https://safe.example.com"),这完全不对。攻击者只要构造https://safe.example.com@evil.com,你看到的是safe.example.com开头,但Java解析后真正请求的是evil.com。
正确的姿势是分层解析:先取URI.getHost(),再解析出IP,然后再和IP白名单或域名白名单比对。而且要注意URI.getHost()对某些畸形输入会返回null,此时应该直接拒绝请求而不是继续往下走。
6.2 只过滤内网IP地址段,却漏了云内网地址
常见的黑名单过滤会挡住127.0.0.1、192.168.x.x、10.x.x.x这些段,但很容易遗漏云厂商的元数据地址和保留地址。169.254.169.254不在常规内网段里,很容易被漏掉。
此外,某些云环境里元数据服务还提供IPv6地址形式,如果你的代码库支持IPv6,攻击者用压缩格式的IPv6地址也能绕过IPv4黑名单。黑名单这条路本质上走不通,因为要维护的"不该访问的地址"实在太多了,还是白名单靠谱。
6.3 忽略重定向和小数点变异
重定向的坑前面已经说了,我再补一个"证据":我测试过,一个HTTP客户端如果自动跟随了3次302,第三次跳转到了http://127.0.0.1:9200,应用层日志里打印的只有最初请求的URL,安全人员很难发现真实访问目标。修复时不留心这个细节,等于白修。
还有一种IP变体写法,比如127.0.0.1写成2130706433(十进制整数形式),或者0177.0.0.1(八进制形式)。很多黑名单和正则碰到这种写法直接失效。所以校验时最好统一先让InetAddress解析成标准字符串,再参与规则判断。
6.4 Java的Sockets类也值得查一遍
很多SSRF审计只盯着HTTP库,忘了Java的Socket也是服务端请求的一种方式。比如:
Socket socket = new Socket(host, port);如果host和port来自用户输入,那它本身就能做内网端口连接,只是没有HTTP响应回显。这类问题虽然不能直接读取文件或发HTTP,但攻击者能用来做端口探测、发送自定义数据包,威胁依然存在。
代码审计时我会把这类原生Socket调用的搜索单独做一轮,和HTTP库的风险合并评估。如果业务完全没有原生Socket需求,建议从代码层面禁止或统一收口到内网专用服务里。
6.5 多个请求点拼凑成一条利用链
单点看可能只是"低危"的SSRF,但组合利用时危害完全不同。我曾在一个模拟项目里看到:某接口存在SSRF能访问内网,另一个接口存在Fastjson反序列化,两者结合后攻击者在内网一台主机上弹出JNDI连接,反向攻击应用服务器。这种组合场景在真实攻击里很常见。
所以做审计判断时,SSRF的等级不能只看这个接口单独能做什么,还要看它能到达哪些内网组件、这些组件上还有没有其他可利用漏洞。这种"服务端请求入口 + 内网服务漏洞"的叠加,才是SSRF真正让人头疼的地方。
7. 代码审计中我是怎么一步步把SSRF写进报告的
最后把这套方法落到实际操作上,分享一个我最近做过的小型审计流程样本,只说思路,不涉及具体项目数据。
入口是一个企业内部的信息拉取服务,功能是根据前端传入的"数据源地址"去后台汇总文档。我在读代码时发现Controller层有个参数叫sourceUrl,直接传给了Service里的downloadFile(String url)方法,方法内部用HttpURLConnection发起请求,并把返回内容写入本地临时文件。听到这里,敏感的同学应该已经条件反射了:这是典型的SSRF入口。
我先判断协议是否可绕。方法内部没有判断scheme,那意味着file://协议也能利用。我顺着代码往下看,临时文件后续还会被解析并返回内容预览,这就是一个可回显的任意文件读取SSRF,利用链非常清晰。
然后我快速验证:本地起一个HTTP服务,通过漏洞参数访问它,确认服务端会发起请求并把响应返回;再尝试file:///etc/passwd,确认能否回显文件内容。两项验证通过后,我对接入层溯源一遍,发现这个接口在管理后台和部分内部工具里都有调用,影响面可能覆盖管理后台的在线预览、接口文档导出等场景。
我给的修复建议是:统一走一个带IP白名单、协议限制、禁止重定向的HTTP客户端Bean,并且在网关层把外部直接调用改为只允许内网调用。这套组合下来,既堵住了入口,也堵住了出口。
审计报告里我特意写了"不要只修入口校验"这句话。因为就算入口校验做好了,如果某个后续跳转或协议解析绕过去,依然是漏洞。只有把请求入口的协议和地址限制、客户端的重定向策略、网络层的出网策略三层都补上,才算真正把一个SSRF漏洞堵死。
我在实际项目中见过太多次"修一半留一半"的情况,常见的比如加了IP白名单但忘了关重定向,或者关了重定向但业务允许自定义协议头导致被用作HTTP走私。每次复盘都发现,安全编码不是某个单一技巧能搞定的,而是要从数据流经过的每一步去问一个问题:这里用户能不能再控制一次?能的话,后果是什么?
如果你正在做Java代码审计,SSRF这套方法论的落地,我建议从一个小接口开始练手,完整走一遍"定位、确认、验证、修复、复测"的闭环。一旦把这种思路培养成习惯,你再看其他漏洞类型(比如任意文件上传、SQL注入)时,也会不自觉地用同样的数据流视角去拆解。安全这东西,说到底拼的就是看问题的框架深不深。