一个流式解码器收到不完整数据时,最合理的动作是说“再给我一些”;收到结构上不可能成立的数据时,则应该立即拒绝。Brotli 最近披露的 CVE-2026-111497,问题就出在这两个结果曾被压成了同一个布尔值。
攻击者构造的 Brotli 数据触发非法块切换后,旧解码器没有返回永久格式错误,而是把它当成暂时缺少输入。调用方继续喂数据,解码器也继续向一个 8 字节内部缓冲区追加内容,最终可能写出边界,造成进程崩溃、内存泄露,最坏情况下可导致远程代码执行。
这次更新值得关注,不只是因为 Brotli 常用于网页压缩。更麻烦的是它经常被编译进 Node.js、Python 扩展及其他运行时:项目的 package-lock.json 里可能没有一个叫 Brotli 的依赖,底层原生解码器却仍在进程里。
先确认影响范围:库版本与运行时版本不是一张表
GitHub Advisory Database 将该漏洞评为 CVSS 4.0 9.2,Critical,列出的 Google Brotli 影响范围是 1.0.3 至 1.2.0。上游没有只发布一个“最新修复版”,而是给三个仍需维护的版本线分别发布了补丁:
| 使用方式 | 受影响范围 | 修复版本 |
|---|---|---|
| Google Brotli 原生库 | 1.0.3—1.2.0 | 1.0.10、1.1.1 或 1.2.1 |
| Node.js 22.x | 22.23.3 及更早版本 | 22.23.4 |
| Node.js 24.x | 24.22.0 及更早版本 | 24.22.1 |
| Node.js 26.x | 26.12.0 及更早版本 | 26.12.1 |
Python Brotli | >=1.0.3、<1.2.1 | 1.2.1 |
Python brotlicffi | >=1.0.3、<1.2.1.0 | 1.2.1.0 |
Node.js 公告把自身影响评级写为 High,而 GitHub 的通用库记录是 9.2 Critical。两者观察的层级不同:通用库记录评估原生解码器的最坏影响,Node.js 项目则评估它在特定运行时中的产品影响,不能简单拿一个标签覆盖另一个。
还要注意,可达性取决于应用是否会把攻击者可控的 Brotli 数据交给解码器。Node.js 官方公告限定的触发条件正是:应用通过 node:zlib 解压攻击者提供的数据。“运行时包含漏洞代码”和“当前接口可以远程触发”需要分别盘点,但前者仍然应该升级。
错误不是发生在压缩算法,而是发生在状态语义
Brotli 数据由一系列元块、数据块和编码表组成。流式解码时,输入往往分批到达,因此解码器需要区分三种状态:
- 成功解析,可以继续;
- 当前字节不够,保存状态并等待下一批输入;
- 数据格式已经违法,再多输入也不可能修复。
漏洞前的 DecodeBlockTypeAndLength 返回布尔值。解析成功返回真;读不到足够位数返回假;但发现“当前只有一种块类型,却又要求切换块类型”时也返回假。
可以把它类比成快递柜:柜门暂时没关好,可以提示用户再推一下;柜号根本不存在,则必须终止操作。旧代码却把两种情况都标成“请继续”,后续流程自然无法知道应该等数据,还是立刻报错。
Node.js 官方公告描述了后果:非法块切换被报告为需要更多输入,流式解码器于是继续把输入追加到 8 字节内部缓冲区。原本用于容纳“尚未凑齐的一小段数据”的空间,被错误状态驱动成了持续写入点。
修复的关键:把布尔值改成可传播的错误码
Google Brotli 的修复提交 0ed4268 没有靠扩大缓冲区掩盖问题,而是修改了状态表达:
/* 修复前:成功或失败,失败原因被抹平 */
static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength(...);
/* 修复后:成功、缺输入、格式错误分别返回 */
static BROTLI_INLINE BrotliDecoderErrorCode
DecodeBlockTypeAndLength(...);2
3
4
5
6
当 max_block_type <= 1 却发生块切换时,新代码返回专门增加的 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH;只有真正读不到足够位数时,才返回 BROTLI_DECODER_NEEDS_MORE_INPUT。
调用链也同步改造。原来的 BROTLI_SAFE(...) 只能理解真假,新的 BROTLI_SAFE_WITH_STATUS(...) 会把成功、等待输入和永久错误一直传到顶层。这样非法数据在进入追加输入循环前就被拒绝。
这类问题对所有流式解析器都适用:不要用 false 同时表示“稍后重试”和“永远失败”。如果错误是否可恢复会改变控制流,它就应该是类型或枚举的一部分,而不是依赖调用者猜测。
为什么补丁已经进入源码,风险仍持续了数月
修复提交的作者日期是 2026 年 2 月 27 日,但 Brotli 1.0.10、1.1.1、1.2.1 和 Node.js 安全版本直到 10 月 21 日才发布,CVE 记录随后在 10 月 22 日公开。
这并不等于 2 月时漏洞已经完成公开披露。它说明另一个常见风险:源码仓库的主分支有修复,不代表用户实际下载的发布包、系统库、容器镜像和语言运行时已经获得修复。安全盘点必须落到“正在运行的制品版本”,不能只看上游最新源码。
Node.js 还展示了更隐蔽的回补方式。我在已修复的 Node.js 24.22.1 上运行:
node -p "process.version + ' / brotli ' + process.versions.brotli"实际输出为:
v24.22.1 / brotli 1.2.0原因可以从 Node.js 24.22.1 源码看到:它把上游修复提交回补进 deps/brotli,并增加 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH,但嵌入库的版本头仍写着 1.2.0。因此,不能只看到 process.versions.brotli === '1.2.0' 就判定这份 Node 一定未修复;对 Node 应以官方运行时修复版本为准。
一段可运行的 Node.js 检查脚本
下面的脚本只读取版本,不加载外部数据,也不尝试触发漏洞。保存为 check-brotli-cve.mjs 后直接运行:
import process from 'node:process'
const fixedFloors = new Map([
[22, [22, 23, 4]],
[24, [24, 22, 1]],
[26, [26, 12, 1]],
])
function compare(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i] - b[i]
}
return 0
}
const current = process.versions.node.split('.').map(Number)
const floor = fixedFloors.get(current[0])
if (!floor) {
console.error(`Node ${process.version}: 不在本次受支持版本表中,请检查生命周期与官方公告`)
process.exitCode = 2
} else if (compare(current, floor) < 0) {
console.error(`Node ${process.version}: 需要升级到 ${floor.join('.')} 或更高版本`)
process.exitCode = 1
} else {
console.log(`Node ${process.version}: 已达到 CVE-2026-111497 修复版本`)
}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
我对六个边界版本和一个已终止支持的版本运行了断言:22.23.3/22.23.4、24.22.0/24.22.1、26.12.0/26.12.1 以及 20.20.2,7 项结果全部符合修复版本表。当前环境的 Node 24.22.1 被正确判断为已修复。
Python 项目可以先从实际环境枚举扩展版本:
python -m pip show Brotli brotlicffi如果应用镜像分为构建阶段和运行阶段,应在最终镜像里执行,而不是只检查开发机。系统安装的原生 Brotli、随运行时静态编译的副本和 Python wheel 也是不同制品,更新其中一个不会自动替换其余副本。
我验证了什么
我把 Google Brotli 源码固定到修复发布 v1.2.1,在 Python 3.12.14、GCC 13.3.0 下构建 Python 扩展并运行仓库的测试:
python -m pytest python/tests -q结果是 595 项全部通过。这证明该修复发布在本次环境中能够完成标准压缩、解压和流式接口测试;它不是对所有下游运行时兼容性的保证。
我还直接比较了上游发布标签中的修复标记:
v1.2.0 : BLOCK_SWITCH 格式错误不存在
v1.2.1 : 存在
v1.1.1 : 存在
v1.0.10: 存在2
3
4
四个发布边界均与公告列出的版本一致。没有运行公开攻击载荷,也没有把“测试通过”写成漏洞无法利用的证明;漏洞影响与最坏后果来自 Node.js 公告和 CVE/GitHub Advisory 记录。
修复时不要只更新 package.json
实际处置可以按下面的顺序进行:
- 枚举生产、CI、定时任务和容器基础镜像中的 Node 运行时版本;
- 优先处理会解压不可信 Brotli 输入的服务和抓取程序;
- 把 Node 升级到 22.23.4、24.22.1、26.12.1 或之后的受支持版本;
- 单独检查 Python 的
Brotli、brotlicffi以及系统 Brotli 库; - 重建并重新部署最终制品,确认运行中的进程已切换版本;
- 在资产检查中同时记录运行时版本和嵌入组件来源,避免只依赖 npm 依赖扫描。
如果短期内无法升级,减少不可信 Brotli 数据进入解码器可以缩小暴露面,但不能替代补丁。内存越界发生在进程内的原生代码里,JavaScript 层的异常捕获也不是可靠安全边界。
这次漏洞最值得带走的不是“压缩库也会有 RCE”,而是两个更普遍的工程教训:可恢复状态和永久错误必须在接口上可区分;修复是否生效,要检查真正运行的制品,而不是仓库主分支或某一层依赖清单。
💬 评论