本篇话题 Netty CVE-2024-47535 CVE-2025-25193 2024-11-12
一句话 2024 年 11 月 12 日,Netty 发了新版本,宣布一个能把 Java 服务按死的洞修好了。 90 天后,同一个洞换了新编号又回来,官方公告自己写着「上次修得不完整」------ 因为空字节压根不计入长度,那道 8KB 的上限形同虚设。 又过了 34 天,官方挂在仓库里的那份公告,才把「该升到哪个版本」这一栏填上。
原始报告 github.com/advisories/GHSA-389x-839f-4rhx · github.com/netty/netty/issues/14795
各位老哥,今天这一篇,我得先把话说在前面:
这个洞打不到你的服务。
官方给它的向量是本地 ,要 Windows,要你自己机器上躺着一个 1GB 的文件, 还得赶在进程启动那一刻。没有任何远程攻击者能隔着网络把它递给你。
但它能卡住你的发版。
而且不是「可能」,是已经卡了好几个中文团队------ 下面会给你一条能自己验的依赖链。
还有一件事我想先说清楚 :这一篇里的技术结论, 我把这版和上版的源码拉下来,在真实 JVM 上编译跑过了。 不是读公告转述,是跑出来的。
先把这件事讲明白
不提术语。三句话。
第一句。
Netty 是 Java 那边写网络服务的框架。Dubbo、Seata、RocketMQ、Elasticsearch 后端, 底下都有它。你写的业务代码大概率一行 Netty 都不碰,但你的服务跑在它上面。
第二句。
2024 年 11 月 12 日,Netty 发了 4.1.115.Final, 宣布一个漏洞修好了:某个洞会让 Netty 在启动的时候, 把本地的某个文件整个读进内存。文件要是 1GB,你的 Java 进程就没了。
第三句。
90 天后,同一个洞换了新编号回来了。 新公告里有一句话,是官方自己写的: 「这个洞被修过,但那次修复不完整 ------ 空字节没有算进输入上限。」
第四次「修好了」是 34 天以后,而且改的不是代码,是公告上一个字段。
现场
项目 netty/netty 35,067 ★(2026-10-21 亲测)
这是几分的洞? CVE-2024-47535 给的是 CVSS v4 · 5.4 Moderate ; CVE-2025-25193 给的是 CVSS 3.1 · 5.5 Medium。 (两个号,分数对调了就写错了。)
攻击向量 Local(本地)
要什么条件? 一个 超过 1GB 的文件 + Windows + 进程启动的那一刻 。 (Linux 上 /etc/os-release 归 root 管,普通用户改不了。)
谁报的? CVE-2024-47535 的报告者是 Amossys-PGR ,另外三位分析师; CVE-2025-25193 的报告者是 chrisvest。
那今天还有洞吗?没有。 Netty 官网现在挂着的最新 4.1.x 是 4.1.139.Final(2026-10-06) 。 本文要讲的不是「现在还有洞」,是「当年那 34 天是怎么过去的」。
所以第一屏就得说清楚:这东西打不到你。
但真正会咬到中文工程师的,是另一头:
同一类东西被三套官方系统各记一份,其中一套当时还写着「没修」, 于是你的 CI 扫描器替你决定了发不发版。
这一篇讲的就是这个。
一、2024 年 11 月 12 日:第一次「修好了」
那天 Netty 发了 4.1.115.Final, release 页上的时间戳是 2024-11-12T11:19:02Z。
同一份 release 里挂着一笔修复 commit, 作者是 Norman Maurer (norman_maurer@apple.com)。
这笔 commit 的动机写得明明白白,逐字:
"We didnt verify the amount of data buffered while reading files / streams during startup which could allow an attacker to cause an OMME..."
(我们在启动期间读文件/读流的时候,没有核对读进去多少数据, 这可能让攻击者搞出一个 OOM......)
改法逐字:
"Decorate InputStream with own that will throw an exception once we reach the limit of data read."
(用一个自己写的 InputStream 包一层,读到的数据量一到上限就抛异常。)
同一天,Netty 自己在官网也发了公告,逐字:
"We are happy to announce the release of netty 4.1.115.Final. This is a bug-fix release but also include a fix for (CVE-2024-47535) which we rate as very low as the attacker needs to have access to the local filesystem with the right permissions."
(我们很高兴宣布 netty 4.1.115.Final 发布。 这是一个 bug 修复版本,同时也包含一个针对 CVE-2024-47535 的修复, 我们把它评为 very low,因为攻击者需要能访问本地文件系统,并且有相应的权限。)
「我们评为 very low」------这是官方自己的判断,一个字没改。
它新增了一个文件:common/src/main/java/io/netty/util/internal/BoundedInputStream.java,83 行。
这个类,就是那道判据本身。
它做的事情很简单:你在外面包一层,它就在里面数读了多少字节 , 数过 8KB 就抛 IOException。(这个 8KB 是类里的默认值,源码写的是 8 * 1024。)
顺带一提,同一天还发了一个 advisory: GHSA-xq3w-v528-46rv,也就是 CVE-2024-47535。 受影响 <= 4.1.114.Final,修复版本 4.1.115.Final。
到这儿为止,一切都对。
二、90 天后:同一个洞,换了个编号
2025 年 2 月 10 日 ,4.1.118.Final 发布(时间戳 2025-02-10T15:40:49Z)。
同一天,第二个 advisory 出来了: GHSA-389x-839f-4rhx,也就是 CVE-2025-25193。 受影响 < 4.1.118.Final,修复版本 4.1.118.Final。
距上一份公告,90 天。
顺带一提,4.1.118.Final 这一版在 Netty 官网的标题其实不是这个洞。 它开头写的是:
"This is a bug-fix release but also fixes a (critical CVE) in our SSL implementation."
(这是一个 bug 修复版本,同时也修复了 SSL 实现里的一个严重 CVE。)
这个洞排在这版的变更列表第二条 ,第一条是那个 SSL 的严重 CVE。 换句话说,4.1.118.Final 这版真正的 headline 是 SSL,不是这个。
这份新公告里有一句话,是本篇的支点。逐字:
"A similar issue was previously reported in GHSA-xq3w-v528-46rv This issue was fixed, but the fix was incomplete in that null-bytes were not counted against the input limit."
(有一个类似的问题之前报在 GHSA-xq3w-v528-46rv 下。 这个问题被修过,但那次修复不完整------空字节没有被算进输入上限。)
「上次修得不完整」,是官方自己写在公告里的。
这句话很硬。它意味着:第一次那份「已修复」,是假的。
三、公告说「修得不完整」,但没说完整在哪儿 ------ 我把源码拉下来编译了
公告只说了「空字节没算进上限」,没说这行代码在哪。
我不太信公告里的转述,于是把两个版本的源码原样拉下来, 在 OpenJDK 21 上编译跑了一遍。
先看这两个 tag 下源文件里的 read(),一个字节都没改过 (这是 release 里那份源文件本身,不是 diff):
java
// 4.1.115.Final ------ 单字节 read()
public int read() throws IOException {
checkMaxBytesRead(1); // ← numRead 先 +1
try {
int b = super.read();
if (b <= 0) {
// We couldn't read anything.
numRead--; // ← 读到 0x00 时又减回来
}
return b;
} ...
}
// 4.1.118.Final ------ 单字节 read()
public int read() throws IOException {
checkMaxBytesRead(); // ← 不再预加
int b = super.read();
if (b != -1) { numRead++; } // ← 只有 -1(EOF)不计数
return b;
}
洞就在这两行的顺序里。
checkMaxBytesRead(1) 已经把 numRead 加了 1, 之后 b <= 0 又把它减掉 1。
所以:
- 读到普通字节(
b > 0)→numRead净增 1 → 计数正确; - 读到
0x00(b == 0)→numRead净增 0 → 这个字节压根没被算进上限。
注意,不是「0x00 让计数倒退」。是「0x00 压根没被计数」。
numRead 永远追不上 maxBytesRead, 那道 8KB 的上限就形同虚设。
顺便说一句,官方自己是怎么描述这个洞的。
第二次那笔修复 commit 的说明里,逐字:
"The
BoundedInputStreamhas a bug in counting the number of bytes read, where valid 0-bytes were not counted toward the limit."
(BoundedInputStream 在统计已读字节数上有个 bug, 有效的 0 字节没有被算进上限。)
"The
BoundedInputStreamTesthas also been made repeating, because it was already occasionally finding this problem. The test now passes consistently with the fixes."
(BoundedInputStreamTest 也被改成了重复执行 , 因为它本来就偶尔能抓到这个 bug。 现在这个测试在打了补丁之后能稳定通过。)
这一段是本篇我最想给你看的一句。
他们的测试早就偶尔能抓到这个 bug ------ 只是它「偶尔」出现,被当成了 flaky。 所以第二次的改法不是「找到并修好它」,而是「把测试改成重复执行」,让它稳定复现。
这是这个系列的老形状,又一次:判据在报警,但报警的人当成了噪音。
实测(-Xmx256m,3GB 全零稀疏文件,等价于 fsutil file createnew):
场景 / 4.1.115 / 4.1.118
- 单字节
read()------ 读完整个 1048576 字节,上限完全没拦住 ------ 第 8193 字节拦住 - 批量
read(byte[])------ 第 8193 字节拦住 ------ 第 8193 字节拦住 readLine()(Netty 实际走的那条) ------ 第 8192 字节拦住 ------ 第 8193 字节拦住- 4.1.112 裸读 (= CVE-2024-47535 的根因) ------ ------ ------ OutOfMemoryError,撑爆 256MB 堆
4.1.112 那笔是真的复现成功了 :那个版本还没有 BoundedInputStream, 读 os-release 用的是裸的 new FileInputStream(file),没有任何上限 。 1GB 文件整个进堆,然后 OutOfMemoryError。
这四个格子,是我跑出来的,不是公告上抄的。
⚠️ 但有一条,我跑出来跟公告说的不一样
按 advisory 描述的 PoC(「4.1.115 也会被 1GB 文件撑爆」)------我复现不出来。
我加了个探针,看 readLine() 到底调哪个 read 重载:
lua
readLine 期间: 单字节 read() 调用=0 次, 批量 read(byte[]) 调用=1 次
readLine() 走的是批量路径,单字节路径一次都没碰。 而 4.1.115 的批量路径计数是对的 → 8KB 就被拦住了。
所以准确的说法是:4.1.115 的洞只在单字节路径上,能不能打中取决于调用方怎么读。 本轮我没能定位到真实 Netty 里是哪个路径会走单字节。
这条我写进「本文不成立的结论」了。 公告说的 PoC 和实际调用路径对不上------这本身就是这个系列的老毛病。
四、修完 34 天,官方公告页还写着「没修」
2 月 10 日发完 4.1.118.Final,用户以为完事了。
2 月 11 日,有人来提 issue(#14795):
"The current version 4.1.118.Final does not fix this vulnerability in the description of..."
(当前的 4.1.118.Final 在描述里并没有修掉这个漏洞......)
1 分 36 秒后,维护者 Norman Maurer 回:
"Yes it was fixed... sorry the version range was incorrect. Just fixed this"
(是修了......抱歉,版本范围写错了。刚改了。)
但改的只是版本范围,公告页上「该升到哪个版本」那一栏还是错的。
于是接下来三天,issue 里陆陆续续冒出来:
- 2 月 12 日 07:01 ------ 维护者又回了一句:「It is fixed in 4.1.118.Final」(在 4.1.118.Final 里已经修好了)
- 2 月 12 日 12:35 ------ 「Trivy/Prisma 扫描还是过不去」
- 2 月 13 日 ------ 「我下载的是 netty-common-4.1.118.Final,扫描器还在报 GHSA-389x-839f-4rhx」
- 2 月 14 日 ------ 「+1,我这边也是,4.1.118.Final 一样报」
注意 2 月 12 日那两条的顺序:
上午 7 点,维护者说「已经修好了」。 下午 12 点半,扫描器说「还是过不去」。
中间隔了五个半小时。
2 月 14 日 ,安全团队的人 chrisvest 回了一句:
"The CVE has been updated... You need to contact your scanner tool vendors to get them to update."
(CVE 已经更新了......你得去联系你的扫描器厂商,让他们更新。)
这就是这三天的实况:
用户下载了官方说已经修好的 版本,扫描器照旧报警 , 官方给他的答案是「去催你的扫描器厂商」。
那它到底为什么报警?
3 月 16 日,有人把机制挖出来了(issue #14929):
"There are two GHSA-389x-839f-4rhx (CVE-2025-25193) pages: github.com/advisories/... github.com/netty/netty... The former has "Patched versions 4.1.118.Final", the latter has "Patched versions None". Please replace "None" with "4.1.118.Final" in the latter. Thanks!"
(有两个 GHSA-389x-839f-4rhx 的页面: 前一个写着「Patched versions 4.1.118.Final」,后一个写着「Patched versions None」。 请把后一个的 "None" 改成 "4.1.118.Final"。)
这就是全文最值钱的一段。
GitHub 上同一个编号,有两个「官方」页:
- 全局 advisory database (
github.com/advisories/...)
------ GitHub 统一收录的,有自己的字段;
- 仓库级 advisory (
github.com/<owner>/<repo>/security/advisories/...)
------ 由这个仓库自己的安全团队维护,有自己的字段。
两个字段,由两拨人、两条流程维护。 所以它们会漂移。
一个写着「已修复」,一个写着「没修」。 你的扫描器读哪一份,取决于它怎么实现。
「34 天」这个数是这么来的: 2025-02-10 修复发布 → 2025-03-16 有人提 issue = 34 天 。 chrisvest 在 10 小时 38 分后回了一句「Updated.」,然后关了 issue。
五、然后扫描器替你决定了发不发版
到这儿为止,这个洞在中文侧的真实痛感,还没人说破。
说破了它其实不是个远程 DoS。 本地向量、Windows、本地文件、启动那一刻。 你的服务不会被人隔空打挂。
但它是下面这个形状:
同一类东西被三套官方系统各记一份,其中一套当时还写着「没修」, 于是扫描器替你决定了发不发版。
而这套形状,在中文工程侧是真的会咬人的。
我拿 Maven 仓库的 POM 直接核了一条:
io.seata:seata-core 的最新版就是 2.0.0 (仓库里一共 31 个版本,2.0.0 是最后一个), 它的 POM 里钉着 netty-all 的版本是 4.1.86.Final。
lua
io.seata:seata-core:2.0.0 → io.netty:netty-all:4.1.86.Final
4.1.86 落在两条 CVE 的窗口里 (一条 <= 4.1.114.Final,一条 < 4.1.118.Final)。
Seata 就是那个分布式事务框架。
所以真实发生的事是这样的: Seata 的使用者想升 Netty 躲 CVE, 一看 Seata 自己的 POM 钉着 4.1.86; 不改 Seata 就得自己 override,改了 Seata 就得重新测一遍事务。
这就是「卡发版」。
而在 2 月那几天,这批人收到的官方答案是:「你去催你的扫描器厂商。」
判据在报警,报警的人被官方指去了另一个方向。
六、真正扎人的地方在哪
一件事里有三层「修好了」 ,而且三层全是官方自己说的:
层 / 谁说的 / 说了什么 / 实际是什么
- 第一次 ------ Netty 官方,2024-11-12 ------ 「4.1.115.Final 修好了 CVE-2024-47535」 ------ 判据管不住:空字节不计数,8KB 上限形同虚设
- 第二次 ------ Netty 官方,2025-02-10 ------ 「4.1.118.Final 修好了 CVE-2025-25193」 ------ 代码修了,但公告的版本范围写错 → 扫描器继续报警(2 月 12 日到 14 日)
- 第三次 ------
chrisvest,2025-03-16 ------ 「Updated.」 ------ 把None改成4.1.118.Final(10 小时 38 分后,距修复发布 34 天)
三层的形状还不一样: 第一次是判据本身有洞 (跑了 90 天才被发现); 第二次是修好了但信息送不到 ; 第三次是信息终于对了,但晚了 34 天。
这三条加起来,才是本系列的老形状:
判据在跑,但它管不住; 判据管住了,但结论送不到; 结论送到了,但晚了 34 天。
而且这一次,没有任何第三方用户被当成噪音关掉。 每一层都是官方自己发现、自己承认、自己改的。
这一点必须说清楚 ,否则就变成「厂商甩锅给用户」了------不是的。 官方每一步都认了,认得还挺快(1 分 36 秒、10 小时 38 分)。
问题不是他们不认。
问题是:一个东西要同时对三套官方系统、每一套都得记得手动同步, 它就迟早会漂。而漂了以后,代价落在谁的 CI 上,谁自己扛。
本篇不成立的结论
以下几条不能从本文推出去:
- 先说清楚:这不等于「Netty 不靠谱」。
CVE-2024-47535 是官方自己发现、自己修的(第一次修复在 2024-11-12,当天发版当天修); CVE-2025-25193 是官方自己承认「上次修得不完整」才发出来的。 本文盯的是「同一个洞 90 天后换号回来」这个形状,和它连带的 34 天漂移。
- 别写「这是个远程 DoS,攻击者能控制请求头」。
官方 CVSS 明写 AV:L(Local) ,Netty 官网自己的发布说明里写的是 "which we rate as very low as the attacker needs to have access to the local filesystem" 。 真要触发,得在目标 Windows 机器本地放一个 1GB 以上的文件 , 且在进程启动时 触发。没有任何请求头、包、协议层输入参与。 → 这句话写出来,懂行的人当场就戳穿了。
- 别写「4.1.115 也会被 1GB 文件撑爆」。
我复现不出来。 readLine() 走批量路径(探针实测:单字节 read() 调用 0 次), 而 4.1.115 的批量路径计数是对的,8KB 就被拦。 本文只说:这个类的洞客观存在,且已修复。
- 别写「某个具体的 Netty 路径会被打中」。
4.1.115 的洞只在单字节 路径上;本轮我没定位到真实 Netty 里哪条路径会走单字节。 复现的是机制,不是「Windows 上起 Netty 应用被 1GB 文件打死」这个完整场景。
- 别写「官方公告页现在还写着没修」。
它已经改了。 今天两个页面都是 4.1.118.Final 。 正确写法是「当时 仓库那份写着 None,全局那份写着 4.1.118.Final, 34 天后有人提 issue 才改成一致」。
- 别把「90 天」和「34 天」当成同一口径。
90 天 是两份 GHSA 公告日 之差(2024-11-12 → 2025-02-10),口径一致; 34 天 是修复版本发布日 → issue 提出日(2025-02-10 → 2025-03-16)。
- 别写「CVE-2025-25193 的受影响版本是
<= 4.1.118」。
GitHub Advisory 写的是 < 4.1.118.Final。 但 CVE 官方记录(cvelistV5)自己写的是 affected: "<= 4.1.118" 且状态为 affected ------ 而 4.1.118.Final 恰恰是修复版本。 → 这是第三套官方系统 的第三种说法。本文只陈述这个矛盾,不替它圆。
- 最后一条,关于「几个官方页面」。
本文写的是 两个 GHSA 页面 (全局 advisory database + 仓库级 advisory) 加一套 CVE 官方记录 。不是三个 GHSA。 而 CVE 官方记录那句 affected: "<= 4.1.118" 的矛盾, 我核到的是它写成了这样 ,我没有核到是谁维护的、为什么这么写。
📌 一条自己核出来的坑,记在这 :我第一版查 Netty 官网的发布说明时, 本机 curl 连了三次全是
HTTP:000,我据此在候选档里写了「netty.io 不可达」。 后来发现是本机代理127.0.0.1:7890抖动,加--noproxy '*'直连一次就通了。 → 上面引的那两句官网原文,是直连重取核到的逐字原文 , 页面是netty.io/news/2024/11/12/4-1-115-Final.html(署名 normanmaurer,12-Nov-2024)。 → 教训 :抓不到先分清「路径 404」和「代理抖」。 这条和候选档里那条「HTTP:000 是代理抖动、要--retry 2 --retry-delay 2」是同一个坑, 这次差点又踩一遍,还差点把一句真引语写成「查不到」。
复现
整套东西都在仓库里,不需要 Maven,不联网,JDK 17+ 就能跑:
bash
cd experiments/netty-bounded-stream
./run.sh
它做四件事:
- 把
netty-4.1.115.Final和netty-4.1.118.Final两个 tag 下的
BoundedInputStream.java 逐字放进两个独立包目录编译;
- 造一个 3GB 全零稀疏文件(等价于
fsutil file createnew的效果,不占实际磁盘); - 跑两个探针(
readLine()走哪个read重载 / 单字节路径上限是否失效); - 复刻
PlatformDependent的读法,验证 4.1.112 裸读会 OOM。
关键输出:
bash
[单字节 read()] 读完 1048576 bytes → 上限【没拦住】! ← 4.1.115
[单字节 read()] 被拦于第 8193 bytes ← 4.1.118
[PoC 路径 readLine()] 被上限拦住: Maximum number of bytes read: 8192 ← 4.1.115
→ OutOfMemoryError! 撑爆堆 ← 4.1.112 裸读
再验一下「两个官方页面」今天是不是一致:
perl
# 全局 advisory database
curl -s https://github.com/advisories/GHSA-389x-839f-4rhx | grep -o "Patched versions[^<]*"
# 仓库级 advisory
curl -s https://github.com/netty/netty/security/advisories/GHSA-389x-839f-4rhx | grep -o "Patched versions[^<]*"
再验一下 Seata 那条依赖:
ruby
curl -s https://maven.aliyun.com/repository/public/io/seata/seata-core/2.0.0/seata-core-2.0.0.pom \
| grep -A3 netty-all
再验一下「34 天」:
perl
# 4.1.118.Final 的发布时间
curl -s https://github.com/netty/netty/releases/tag/netty-4.1.118.Final | grep -o 'datetime="[^"]*"'
# issue #14929 的创建时间
curl -s https://api.github.com/repos/netty/netty/issues/14929 | grep -o '"created_at":"[^"]*"'
附:本文引用
都是公开地址,不用登录。
- 第一次那份公告 →
github.com/advisories/GHSA-xq3w-v528-46rv - 第二次那份公告 →
github.com/advisories/GHSA-389x-839f-4rhx - 仓库级那份公告 →
github.com/netty/netty/security/advisories/GHSA-389x-839f-4rhx - 第一次修复 commit →
github.com/netty/netty/commit/fbf7a704a82e7449b48bd0bbb679f5661c6d61a3.patch - 第二次修复 commit →
github.com/netty/netty/commit/d1fbda62d3a47835d3fb35db8bd42ecc205a5386.patch - 第一次发版 →
github.com/netty/netty/releases/tag/netty-4.1.115.Final - 第二次发版 →
github.com/netty/netty/releases/tag/netty-4.1.118.Final - Netty 官网 4.1.115 的发布说明(含「very low」那句)→
netty.io/news/2024/11/12/4-1-115-Final.html - Netty 官网 4.1.118 的发布说明(SSL 严重 CVE 排在前面)→
netty.io/news/2025/02/10/4-1-118-Final.html - 用户被卡的那三天 →
github.com/netty/netty/issues/14795 - 两个官方页面的矛盾 →
github.com/netty/netty/issues/14929 - CVE 官方记录(自带矛盾)→
raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/2025/25xxx/CVE-2025-25193.json - OSV 记录 →
api.osv.dev/v1/vulns/GHSA-389x-839f-4rhx - Seata 的 POM →
maven.aliyun.com/repository/public/io/seata/seata-core/2.0.0/seata-core-2.0.0.pom - Seata 的全部版本 →
maven.aliyun.com/repository/public/io/seata/seata-core/maven-metadata.xml - 本地复现 →
experiments/netty-bounded-stream/
绝对时间一律取 GitHub release 页的 datetime 和 issue 的 created_at。 所有天数都是本机算的,不是估的。
所有引语逐字未改,译文一律放在中文括号里,不进逐字引语块。
📌 一条自己核出来的错,顺手记在这 : 我第一版把 4.1.115 的 CVSS 写成了 5.5。那是 CVE-2025-25193 的分。
CVE-2024-47535给的是 CVSS v4 · 5.4 ,两个号对调着 。 规模数字和引语一个级别,不许拍脑袋填。