一条你看不懂的告警
依赖扫描报出来一个包:io.netty:netty-resolver-dns,三条 CVE,名字里带着「DNS 缓存投毒」。
你去翻 pom.xml,没有这个坐标。去全局搜代码,没有一行 DnsNameResolver。 很自然的反应是:我们又不做 DNS,这是某个库顺带进来的,应该用不到。
这篇要说的就是:如果你用 Spring WebClient,这个直觉大概率是错的 ------ 而且可以当场验证。
先把三条编号摆出来:
| CVE | 问题 | 严重度 |
|---|---|---|
| CVE-2026-45674 | CNAME 记录没有校验来源(bailiwick) | GitHub advisory 8.7 high · NVD 10.0 critical |
| CVE-2026-47691 | NS 记录来源校验不足,子域的权威服务器能投毒上级域 | GitHub advisory 8.7 high · NVD 10.0 critical |
| CVE-2026-45673 | 事务 ID 可预测 + 默认固定 UDP 源端口 | 6.8 medium |
三条共用同一个修复版:4.1.135.Final (4.1 线)/ 4.2.15.Final(4.2 线)。
它是怎么进来的:四层传递,每一层都是 compile
markdown
spring-boot-starter-webflux
└ spring-boot-starter-reactor-netty
└ reactor-netty-http
└ reactor-netty-core
└ netty-resolver-dns
这条链我在 repo1.maven.org 上逐个 POM 点开核过:每一环都是 compile 作用域、都不是 optional。 所以只要你引了 starter-webflux,它就一定在你的 fat jar 里 ------ pom 里看不到,是因为它根本不需要你写。
进来了不等于在用 ------ 但这里它默认就在用
在 classpath 上躺着,和真的被调用,是两回事。很多传递依赖确实只是躺着。
这个不是。Reactor Netty 的 HttpClient 解析主机名,默认走的是 Netty 自己的 DnsAddressResolverGroup,不是 JDK 的解析器。源码路径:
HttpClientConfig.defaultAddressResolverGroup()→HttpResources.getOrCreateDefaultResolver()TcpResources.getOrCreateDefaultResolver()→NameResolverProvider.newNameResolverGroup(...)NameResolverProvider.newNameResolverGroup里:new DnsNameResolverBuilder()→new DnsAddressResolverGroup(builder)
(reactor-netty 1.1.x、1.2.x、1.3.x、main 都是这样。)
而 Spring Boot 给 WebClient 挑连接器的顺序是 reactor > jetty > httpComponents > jdk ------ classpath 上有 Reactor Netty,就选它。
读源码还不够,我做了运行时验证。一个默认配置的 Spring Boot 3.5.14 应用,WebClient.builder() 发一次请求,打开 io.netty.resolver.dns 的 DEBUG 日志,里面有这一行:
yaml
DEBUG --- [ctor-http-nio-1] io.netty.resolver.dns.DnsQueryContext : [id: 0xc823de72, L:/[0:0:0:0:0:0:0:0]:53628] WRITE: UDP, [3313: /198.18.0.2:53], DefaultDnsQuestion(example.com. IN A)
这就是 Netty 的 DNS 解析器亲手发出去的查询。 这个应用的代码里没有一个字提到 DNS。
哪些 Spring Boot 版本默认中招
从每个 spring-boot-dependencies-<版本>.pom 的 <netty.version> 逐个读出来的:
| Spring Boot | 默认 netty | 结论 |
|---|---|---|
| 3.4.0 -- 3.4.13 | 4.1.115 -- 4.1.130 | 中 ------ 3.4 线的最后一版 3.4.13 也没带上修复版 |
| 3.5.0 -- 3.5.14 | 4.1.121 -- 4.1.132 | 中 |
| 3.5.15 起 | 4.1.135 | 已修 |
| 4.0.0 -- 4.0.6 | 4.2.7 -- 4.2.12 | 中 |
| 4.0.7 起 | 4.2.15 起 | 已修 |
两个注意点:如果你在 pom 里自己覆盖过 netty.version,以你覆盖的为准;3.4 线在线内升补丁版修不掉,要么覆盖 netty.version,要么升到 3.5.15+。
静态扫描能判到哪一步:五个对照应用
光看版本号只回答了一半。另一半是:你的应用有没有把这个解析器换掉? 有人会写 .resolver(DefaultAddressResolverGroup.INSTANCE),也有人用配置把 WebClient 的连接器整个换成 JDK 的。
我做了五个真实的 Spring Boot fat jar,标准答案用运行时实际发出的 Netty DNS 查询条数,再拿静态扫描的结论去对:
| 应用 | 做法 | 运行时 Netty DNS 查询 | 静态扫描结论 |
|---|---|---|---|
| A | 默认 WebClient.builder() |
1 | 在用 |
| B | .resolver(DefaultAddressResolverGroup.INSTANCE) |
0 | 发现覆盖痕迹 → 请自查 |
| C | spring.http.reactiveclient.connector=jdk |
0 | 发现覆盖痕迹 → 请自查 |
| D | 两个 WebClient,只换了其中一个 | 1 | 发现覆盖痕迹 → 请自查 |
| E | 默认配置,Spring Boot 3.5.16 | 1(但已是修复版 4.1.135) | 不受影响 |
老实说结果:
- 没有覆盖的时候,静态扫描能可靠地说「在用」;
- 有覆盖的时候,它能找到覆盖的痕迹(3 个里找到 3 个,A 上没有误报);
- 但它分不开 B 和 D ------ 一个全换了(0 次查询),一个只换了一半(还有 1 次查询)。两者在字节码里的证据完全一样。
所以正确的做法是:找到覆盖痕迹时,不下「不受影响」的结论,而是让你去自查。 判错的方向必须选对 ------ 把在用判成没用,是让人做错事。
一分钟自查:它到底有没有在发 DNS 查询
- 启动应用时加上
--logging.level.io.netty.resolver.dns=DEBUG - 让应用发一次出站请求 ------ 有几个 WebClient,就各触发一次
- 在日志里找
WRITE: UDP:有,就是 Netty 的解析器在用;没有,这次请求没走它
⚠️ 别拿「DnsNameResolver 这个类有没有被加载」来判。 我一开始就是这么判的,结果应用 B 把解析器全换掉了,照样加载了这个类 ------ 默认解析器组是预先构建出来的。只有真实发出的查询才算数。
怎么修
- 把 netty 升到
4.1.135.Final(或 4.2 线4.2.15.Final)以上。Spring Boot 项目在 pom 的<properties>里设<netty.version>,Gradle 设netty.version属性。 - 或者升 Spring Boot:3.5.15 起、4.0.7 起默认带的就是修复版。
- 修复版在 Maven Central 上都拿得到,正常升级即可。
利用有前提,别读过头
CVE-2026-47691的 advisory 原文:控制某个子域权威 DNS 服务器的攻击者,可以投毒它的上级域。换句话说,你的应用得去解析一个攻击者控制得了的域名 ------ 比如按用户给的 URL 去发请求、抓取回调地址这类场景。CVE-2026-45673是 medium,它降低的是伪造 DNS 响应的难度,前提是攻击者能把伪造的响应送到你的解析器。- 严重度两套分数都在上面的表里:GitHub advisory 给两条 8.7 high ,NVD 自己打的是 10.0 critical ;第三条只有一个分,6.8 medium。
- 我没有做过利用复现。本文证明的是「它在被用、版本在不在窗口内」,不是「你的应用一定能被打穿」。
一个把前面这些做完的小工具
netty-resolver-dns-check:扫 jar / fat jar / war(读的是实际打进去的 BOOT-INF/lib,不是 pom),判版本在不在窗口内、Reactor Netty 在不在、你的代码和 application*.properties/yml 里有没有换掉解析器或连接器的痕迹,然后给出三档结论之一,附上面那个一分钟自查。
单 jar、零运行时依赖、完全离线、Java 17+。
bash
java -jar netty-resolver-dns-check-0.1.0.jar myapp.jar
java -jar netty-resolver-dns-check-0.1.0.jar target/
java -jar netty-resolver-dns-check-0.1.0.jar --version-of 4.1.132.Final
退出码:0 = 不受影响 · 1 = 中了(包括发现覆盖痕迹的情况 )· 2 = 判不了 · 4 = 有文件读不动。 2 和 4 故意不是 0:「我没找到」「我没能读它」和「你是安全的」,对 CI 脚本来说不能是同一个信号。
仓库里的 evidence/ 就是上面五个对照应用的源码,附一个数 WRITE: UDP 的脚本;tools/e2e_real_jars.py 拿这五个真 jar 跑工具、逐个断言结论。你可以把整个对照实验在自己机器上重跑一遍。
这篇能证明什么、不能证明什么
- ❌ 不是说扫描工具报不出来。 三条都是 reviewed、有修复版的 advisory;GitHub 的官方文档写着 Maven 的传递依赖在依赖图里是支持的。报是报得出来的,本文讲的是报出来之后「我没用它」这个判断为什么站不住。
- ❌ 不是「升不上去」。 修复版在 Central 上都有。
- ❌ 不是「任何 WebClient 应用都会被劫持」。 利用有前提,见上一节。
- ✅ 是:这个包你没写进 pom、没在代码里调过,但 WebClient 默认就经它解析域名;而静态扫描能帮你判到「在用 / 请自查」这一步,判不到「全换干净了」------ 那一步要靠那一分钟的日志。
如果你发现哪里错了,欢迎直接开 issue ------ 这类文章的价值全在准确性上。