CVE-2026-41854:Spring Framework UriComponentsBuilder SSRF 漏洞浅析
漏洞分析
UriComponentsBuilder 是 Spring Web 中用于解析和构建 URI 的核心工具类,经历了多个CVE绕过后,在6.2.x版本进行了重构,抛弃了旧版本正则匹配的逻辑,但是在特定场景下还是存在由于解析差异导致的的SSRF绕过。
0x00 前言 ======= `UriComponentsBuilder` 是 Spring Web 中用于解析和构建 URI 的核心工具类,几乎所有涉及"URL 解析 + host 校验"的业务代码(开放重定向校验、SSRF 防护、webhook 回调地址审核等)都会经过它。正因为它常被当作安全校验的信任锚点,其解析结果一旦与下游 HTTP 客户端产生分歧,防护便形同虚设 0.1 历史漏洞一览 ========== | | | | | | |---|---|---|---|---| | CVE | 披露时间 | 缺陷点 | 攻击手法 | 相关分析 | | [CVE-2024-22243](https://spring.io/security/cve-2024-22243) | 2024/2/21 | userinfo 中未过滤 \[ | 构造 userinfo 含方括号的 URL,使 host 校验值与真实归属分叉 | <https://forum.butian.net/share/2781> | | [CVE-2024-22259](https://spring.io/security/cve-2024-22259) | 2024-03(第二份报告) | 22243 修复不完整:userinfo 中方括号组合仍可绕过 | 同一模式的变种输入,绕过补丁后的方括号过滤 | <https://forum.butian.net/share/2810> | | [CVE-2024-22262](https://spring.io/security/cve-2024-22262) | 2024-04-11(第三份报告) | 前两次黑名单式修复不充分 | 在 scheme/host/userinfo/port 中嵌入反斜杠 \\(如 h\\ttp://a.com),再次制造解析分叉 | <https://forum.butian.net/share/2932> | 历史漏洞的利用模式都是大同小异,主要还是因为应用通过 `fromUriString()` 解析外部 URL 并做 host 黑白名单校验。 前三个针对老解析逻辑(userinfo 方括号、反斜杠等),影响面会覆盖 5.3.x。 而CVE-2026-41854 是重写后的 `RfcUriParser` 中的独立缺陷,只影响 6.2.x/7.0.x,下面看看具体的漏洞原理。 0x01 CVE-2026-41854 =================== 1.1 漏洞描述 -------- 在受影响版本中,UriComponentsBuilder 在解析外部传入的 URL 字符串时未正确验证主机部分,攻击者可通过构造包含内部主机名或 IP 的 URL 绕过验证,诱使应用程序向内部服务发起请求,从而实现服务端请求伪造攻击。  1.2 影响版本 -------- Spring Framework: - 7.0.0 - 7.0.7 - 6.2.0 - 6.2.18 0x02 漏洞分析 ========= 2.1 历史版本对比 ---------- 首先看下6.2版本实现的区别,从官方文档https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.2-Release-Notes 可以看到,明确在6.2版本使用用两个新解析器**取代基于正则的解析**,原因是是 CVE-2024-22262 等漏洞:  - org.springframework:spring-web:6.2.18 从代码上看确实放弃了历史版本正则匹配的模式,`UriComponentsBuilder.fromUriString(url)` 在解析时默认以 `ParserType.RFC` 分发给 `RfcUriParser.parse(url)`,正式替代了老版本的正则解析逻辑,这也是本漏洞只影响 6.2.x/7.0.x,而 2024 年的 CVE-2024-22243/22259/22262 影响 5.3.x–6.1.x 的原因):   另一个解析器是WAHT\_WG,两种解析器的说明可以参考https://docs.spring.io/spring-framework/reference/web/webflux/uri-building.html 大致意思是RFC 解析器要求 URI 严格遵循 RFC 3986 语法,不合语法即判定非法,而 WhatWG 解析器依据 WhatWG 现行 URL 标准,会宽松容错处理各类异常输入,也是浏览器所采用的 URL 解析方式。  2.2 漏洞原理分析 ---------- 以6.2.18漏洞版本为例,看看RFC 解析器的具体实现: 具体parse方法实现比较简单,从START状态开始,通过逐字符遍历整个 URL 字符串,每个字符交给当前状态的 handleNext() 决定是捕获组件还是迁移状态,扫完后用 handleEnd() 收尾,最后把过程中捕获的 scheme/host/port/path/query/fragment 打包成一个 UriRecord 对象返回:  这里对HOST的解析设计的也比较巧妙,首先START状态开始后,当解析到第一个`/`时会进入HOST\_OR\_PATH状态:  在HOST\_OR\_PATH状态时,解析到下一个`/`, 如果第二个字符不是 /,就直接 advanceTo(PATH),判定这个 URL 没有 host 可解析(如相对路径 /foo/bar),也就是说**scheme:// 的双斜杠是 host 解析的入口:**  HOST状态的解析逻辑如下: 如果遇到 `[` 立刻切到 IPV6 子状态,如果遇到`@`,那么之前扫到的内容改判为authority,**host 解析重新开始**:  这么一看好像确实挺合理的,似乎没办法通过结合authority的方式进行绕过。 继续看看IPV6状态的解析,这里发现了一个比较有意思的点,也正是CVE-2026-41854的成因: IPV6 状态下,`]` 一出现就立即 index(++i),然后调用 captureHost()方法获取host,那么以`http://[2001:db8::1]@127.0.0.1/`方式请求的话,并不会判定host为127.0.0.1,而是一个iPV6地址`[2001:db8::1]`:  对比官方的修复commit <https://github.com/spring-projects/spring-framework/commit/322eef2942df6d7555090432dd8d651fac7491a3> ,确实也是这里导致了解析差异的可能:  修复手段也比较粗暴,在原有逻辑的基础上,在捕获host后,如果下一个字段不为`:`(端口),`/`(路径),则直接抛Bad authority异常( InvalidUrlException )。 2.3 漏洞复现 -------- 基于上面的分析,本质上还是解析差异导致的绕过风险,如果请求链最终走 JDK内置 的 `java.net.URI`/`java.net.URL`(如 `HttpURLConnection`、Java 11+ `HttpClient`、Apache HttpClient 5),payload 里的 `[` 会被判定为非法 userinfo 字符而**直接抛异常**,即使绕过了也没办法利用。 同理,RestTemplate 默认会用 Spring 自己的 builder 重新组装 URL,最终发起请求的 host 仍是那个 IPv6 地址,也不成立。所以这里需要找一个满足解析差异且正常解析的URL请求解析器。 这里以OkHttp 3.8.1为例,刚好满足解析差异的前提,这里大致的解析逻辑是在循环处理时,每遇到一个 `@` 就把前面内容归入 userinfo、然后下标右移继续匹配,只有碰到 `/ \ ? #` 或结尾才把剩余部分定为 host,那么**类似`http://[2001:db8::1]@127.0.0.1:9101`的请求最后host刚刚好就是127.0.0.1**:  详细的分析可以参考:<https://forum.butian.net/share/2427> 简单编写一个Controller demo进行验证: ```TypeScript @RestController public class FetchController { /** 内网/敏感地址黑名单(演示用,现实中通常还有 DNS 解析后二次校验等) */ private static final Set<String> BLOCKED_HOSTS = Set.of( "localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254", "[::1]"); private final OkHttpClient httpClient = new OkHttpClient.Builder() .connectTimeout(3, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS) .build(); @GetMapping("/fetch") public ResponseEntity<Map<String, Object>> fetch(@RequestParam String url) { Map<String, Object> result = new LinkedHashMap<>(); result.put("input", url); final UriComponents components; try { components = UriComponentsBuilder.fromUriString(url).build(); } catch (Exception ex) { result.put("verdict", "REJECTED"); result.put("reason", "URL 解析失败: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); } String host = components.getHost(); result.put("validatedHost", host); if (host == null || isBlocked(host)) { result.put("verdict", "BLOCKED"); result.put("reason", "host 命中内网黑名单"); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(result); } result.put("verdict", "ALLOWED"); try (Response response = httpClient.newCall(new Request.Builder().url(url).build()).execute()) { String body = response.body() != null ? response.body().string() : ""; result.put("fetchedHost", response.request().url().host()); result.put("fetchedPort", response.request().url().port()); result.put("status", response.code()); result.put("body", body.length() > 500 ? body.substring(0, 500) + "...(truncated)" : body); return ResponseEntity.ok(result); } catch (IOException | IllegalArgumentException ex) { result.put("error", ex.getClass().getSimpleName() + ": " + ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(result); } } private boolean isBlocked(String host) { String h = host.toLowerCase(); return BLOCKED_HOSTS.contains(h) || h.startsWith("127.") || h.startsWith("169.254.") || h.startsWith("10.") || h.startsWith("192.168.") || h.startsWith("172.16."); } } ``` 以请求127.0.0.1为例,因为命中了内网黑名单,请求被拒绝:  使用之前分析的poc进行请求,成功绕过UriComponentsBuilder的防护请求到127.0.0.1:9001端口的内容:  从调试信息上看,确实@以及以后的内容全部都认为是请求path进行处理了:  升级组件后再次请求对应的poc,此时提示Bad authority,无法绕过防护:  0x03 修复与缓解建议 ============ 针对SSRF的场景: 1. **升级**:spring-web 升至 6.2.19+ / 7.0.8+ 2. **校验与执行同源**:杜绝解析 A 库校验、原始字符串给 B 库请求的场景。校验通过后,用同一解析器解析出的 host/port/scheme **重新组装** URL 再发起请求,杜绝解析器差异空间。 3. **校验最终 IP 而非字符串 host**:对 host 做 DNS 解析,把解析到的 IP 与黑名单比对(注意 DNS rebinding,可能的话在建立连接时就固定已校验的 IP)。
发表于 2026-09-30 09:00:01
阅读 ( 10332 )
分类:
漏洞分析
0 推荐
收藏
0 条评论
tkswifty
81 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!