一个压缩包打穿 Node.js,Brotli 解码器越界写双向攻击
Brotli 越界写让一个压缩包双向打穿 Node.js。本文用版本核对、源码 diff、模糊差分三支柱完成本机实证,首跑命中 5 处受影响副本,附核查工具与修复矩阵。
01 事件:一个压缩包,两个方向
2026 年 10 月 21 日,Node.js 发布安全更新,修复内嵌 Brotli 解码器的越界写漏洞 CVE-2026-111497(Wiz 命名 Broccoli2Shell,早期报道称 Node2Shell,跨生态后也称 HTTP2Shell)。Google 作为 CNA 按 CVSS v4.0 评 9.2 Critical,Node.js 自评 High,Red Hat 按 CVSS 3.1 评 8.1。
时间线里有三个值得防守方注意的细节:
| 时间 | 事件 |
|---|---|
| 2026-02 | OpenAI Codex Security(Khai Tran、Sylvie Mayer)报告该缺陷,定性为未认证 RCE,"两个方向都通" |
| 2026-02-27 | 根因修复进入 google/brotli 源码,但当时未被识别为安全问题,未发版 |
| 2026-10-21 | Node.js 安全发布(22.23.4 / 24.22.1 / 26.12.1),Brotli 1.2.1 同步发出,漏洞正式公开 |
| 2026-10-22 | Imperva(Ron Masas)在未修改的 Node.js 24.22.0 Linux arm64上演示任意代码执行,两个攻击向量均复现;公开 PoC 存在 |
Imperva 的演示证实了双向攻击面:恶意客户端可以把畸形 Brotli 流藏进 HTTP 请求体打服务器(Express 5 的 body parser 会自动解压);恶意服务器可以把畸形流藏进Content-Encoding: br响应打客户端(fetch()及 requests/httpx/aiohttp 会自动解码)。截至 10 月 26 日 Rapid7 未知在野利用,CISA KEV 未收录——但 Wiz 的遥测显示 91% 的云环境存在易受攻击资源,82% 的公网 Node.js 实例暴露,利用代码已在流传。
这个漏洞的真正放大效应在于:每个 bundler 都内嵌一份自己的 Brotli 副本,必须各自修各自的一份。Node.js、Bun、Python brotli/brotlicffi、Envoy、Istio、libcurl、Go 的 andybalholm/brotli 全线中招,Apache mod_brotli 反而免疫(它只压缩、不解压)。
02 机理:一个布尔返回值装不下三种语义
漏洞根因在DecodeBlockTypeAndLength()——处理 Brotli 块类型切换的函数(块切换即流更换 Huffman 树的时机点)。从两个版本的 C 源码 diff 看,问题一目了然:
| 语义场景 | 1.2.0(受影响) | 1.2.1(修复) |
|---|---|---|
无处可切(max_block_type<=1仍被调用切换) |
return BROTLI_FALSE |
return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH(-17) |
| 读取块类型符号失败(确实缺输入) | return BROTLI_FALSE |
return BROTLI_DECODER_NEEDS_MORE_INPUT |
| 读取块长度失败(确实缺输入) | return BROTLI_FALSE |
return BROTLI_DECODER_NEEDS_MORE_INPUT |
| 成功 | BROTLI_TRUE |
BROTLI_DECODER_SUCCESS |
| 函数签名 | BROTLI_BOOL(二值) |
BrotliDecoderErrorCode(错误码) |
BROTLI_FALSE一个值同时表示"流格式错误"和"需要更多输入",调用方无法区分;更糟的是快速路径直接丢弃返回值——1.2.0 的DecodeLiteralBlockSwitch是void函数,调用DecodeLiteralBlockSwitchInternal(0, s)后返回值被扔掉。一个精心构造的流(声明单块类型→耗尽块长度→强制一次不可能成功的切换)会让解码器带着旧块状态继续解码,写越过 8 字节工作缓冲区(CWE-787 越界写 + CWE-252 未检查返回值)。1.2.1 把错误码沿调用链一路冒泡,DecodeLiteralBlockSwitch改为返回错误码,快速路径也检查,遇格式错误两条路径都立即停止。
03 本机实证:三支柱
单靠"跑几个测试流"证明不了任何事。本文用三支柱完成本机实证,全部产物可复现。

3.1 支柱一:版本核对——防守方的第一动作
brotli_inventory.py(纯标准库、只读)盘点本机所有可发现的 Brotli 副本与运行时,对照官方修复阈值判定。自测 14/14 PASS 后,首跑实测:
| 位置 | 类型 | 版本 | 判定 |
|---|---|---|---|
| PATH:node | Node.js | 22.22.2 | 受影响(< 22.23.4) |
| D:\xiazai\node.exe | Node.js | 24.18.0 | 受影响(< 24.22.1) |
| workbuddy node/22.12.0 | Node.js | 22.12.0 | 受影响(< 22.23.4) |
| workbuddy node/22.22.2 | Node.js | 22.22.2 | 受影响(< 22.23.4) |
| D:\tmp\brotli_lab\pybr120 | Python brotli | 1.2.0 | 受影响(< 1.2.1) |

一台开发机就有 5 处受影响副本——这不是演习。版本核对是唯一能立刻给出确定答案的动作,也是后续一切处置(升级、重建容器、轮换凭证)的输入。
3.2 支柱二:源码 diff——C 库的最硬证据形态
Brotli 是 C 库,没有字节码可反汇编,源码 diff 就是最硬的证据。从 PyPI 下载两个版本的 sdist(各 7.4 MB,含完整 C 源码),本地提取c/dec/decode.c的 diff:

diff 实证了 02 节的每一个论断:函数签名从BROTLI_BOOL改为BrotliDecoderErrorCode(decode.c:1179);三个错误出口全部改为显式错误码;DecodeLiteralBlockSwitch从static void改为返回错误码——"快速路径丢弃返回值"的修复现场。完整 diff 已存证(brotli_patch_diff.txt),读者可自行从 sdist 复核。
3.3 支柱三:模糊差分——以及为什么打不中
防御侧自然会想:写个模糊测试喂畸形流,能验证自己受影响吗?brotli_fuzz_diff.py做了这个实验:4 类基础流 × 4 种变异策略(全单比特翻转 / 随机多比特 / 字节替换 / 截断对照),再补两轮定向语料(截断+补尾、头部两位组合、quality 11 大多块流全流采样翻转),双版本 Node + 双版本 Python 共 4 个运行时对照解码,按错误类型分类差分。

三轮语料合计 19,347 个变异输入 × 4 运行时 = 77,388 次解码,差分命中 0,进程崩溃 0。这个"失败"比成功更有信息量:
| 打不中的原因 | 推论 |
|---|---|
| 触发窗口极窄:位流必须精确走到"块长度耗尽且无处可切"的状态 | 随机翻转在到达切换点前,早已被 Huffman 表/块长度校验拦截(两版都报同样的 FORMAT 错误) |
| 快速路径只处理简单 Huffman 场景,大部分变异直接破坏表结构 | 到不了"丢弃返回值"的现场 |
| Imperva 的 PoC 是精心构造,不是变异能碰出来的 | "跑几万畸形输入没崩" ≠ "不受影响" |
模糊差分在这里的正确定位是验证两版本行为边界的方法示范,而不是漏洞探测器。把它当初审手段,会得到虚假的安全感。
04 暴露面:每个 bundler 各自一份

| 暴露路径 | 机理 | 备注 |
|---|---|---|
| Express 5 body parser | json()/urlencoded()/text()/raw()支持 Brotli 解压 |
入站向量:恶意客户端打服务器 |
fetch()/ undici |
自动解码Content-Encoding: br响应 |
出站向量:恶意服务器打客户端 |
| requests / httpx / aiohttp | 装了 brotli/brotlicffi 包就隐式解码 | 不 import 也会中,pip-audit 当时不报此 CVE |
| Envoy / Istio | 开启 brotli 解压 filter 时可达 | 关 filter 亦可消除 |
| 容器镜像 | 构建时打进镜像的副本 | 必须重建镜像,重启不够 |
PyPA advisory 有一个当时的盲区值得记录:截至 10 月 23 日,advisory 数据库尚未把该 CVE 映射到 PyPI 的 brotli/brotlicffi 包,pip-audit、Dependabot、Safety 都不报——依赖审计工具的覆盖滞后,是这类"上游库捆绑"漏洞的常态。
05 检测与加固

根治(唯一可靠):按 04 节矩阵升级到各 bundler 的修复版本,重建容器镜像、重启进程、确认实际运行的版本——本文 3.1 节的首跑命中证明"以为修了"和"真的修了"是两回事。
无法立即升级的兜底(临时手段,不是修复):
| 方向 | 措施 |
|---|---|
| 入站 | 反代/负载均衡/中间件拒绝Content-Encoding含br的请求,在任何脆弱解码器处理之前 |
| 出站 | Accept-Encoding移除br;拒收Content-Encoding: br的响应再解码 |
| Python | 可选依赖直接卸载 brotli 与 brotlicffi(gzip/deflate 不受影响),清理 lockfile 防回装 |
检测:对公网 Node.js 服务优先核查;CI 中加入版本核对步骤(brotli_inventory.py可直接嵌入流水线,退出码即判定);曾暴露在不可信网络的实例按已沦陷处理,轮换该进程可达的全部凭证。
结语
CVE-2026-111497 是一个关于"修复可见性"的教训:根因修复 2 月就躺在源码里,因为没人认出那是安全问题,裸奔了 8 个月;而它跨生态的放大方式(每个 bundler 各自一份副本、审计工具集体滞后)又让"修一处"变成奢望。对防守方,这个漏洞的应动作反而最简单——核对版本、升级、拒绝 br。复杂的是模糊测试给不了的确定性,和"没打中"不等于"不存在"的清醒。
# 资讯 # 漏洞 # 网络安全 # web安全 # 漏洞分析
已在FreeBuf发表 0 篇文章
本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
- 0 文章数
- 0 关注者
文章目录
01 事件:一个压缩包,两个方向
02 机理:一个布尔返回值装不下三种语义
03 本机实证:三支柱
- 3.1 支柱一:版本核对——防守方的第一动作
- 3.2 支柱二:源码 diff——C 库的最硬证据形态
- 3.3 支柱三:模糊差分——以及为什么打不中
04 暴露面:每个 bundler 各自一份
05 检测与加固
结语