ARTICLE / 安全

Brotli 解码器一个越界写,Node 和 Python 同时中招:CVE-2026-111497 复盘

2026 年 10 月 21 日,Node.js 项目发布 26.x、24.x、22.x 三条线的安全版本,最高严重等级为 HIGH。同期安全厂商 Aikido 披露了一个此前未被识别的 Brotli 解码器缺陷 CVE-2026-111497,CVSS v4.0 评分 9.2。几天之内,Python 生态确认中招——PyPI 上的 Brotli 和 brotlicffi 两个包编译了同一份有问题的解码器代码。Aikido 在梳理各语言修复情况时,把这个跨语言的问题统称为 HTTP2Shell。

这件事值得防守方关心的原因有两层:压缩解码器是所有 HTTP 栈的隐式依赖,而普通的依赖扫描器在这次事件里集体失灵。下面分别说清楚漏洞本身、暴露面和扫描器失效的原因,最后给出可执行的排查与处置。

漏洞本身:一个被忽略的返回值

缺陷位置在 Brotli 解码器的 DecodeBlockTypeAndLength() 函数,它负责处理块切换——也就是 Brotli 流改变所用 Huffman 树的那个位置。

问题出在返回值的语义上。受影响的版本里这个函数返回普通布尔值,于是 BROTLI_FALSE 这个返回码同时承担了两种含义:“这个流格式非法"和"我还需要更多输入数据”。调用方无法区分这两者,而在快速路径上,解码器干脆把这个返回值丢掉了。这条 CVE 记录的成因组合是 CWE-252(未检查返回值)与 CWE-787(越界写)。

上游修复的方式是让函数返回错误码,并新增一个专门的错误 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17),让解码在快速路径和安全路径上都能在格式错误时停止。研究者对 Brotli 1.2.0 和 1.2.1 的源码发行版做了差异比对,确认 1.2.1 相对 1.2.0 只多了版本号变更和该提交带来的解码器改动。

触发条件不需要特殊配置:一个 Node.js 进程解压了攻击者构造的 Brotli 数据,就可能导致内存泄露、进程崩溃,极端情况下可以执行任意代码。OpenAI 报告说自己是在 2 月发现这个漏洞的,并把它描述为双向都能成立的未认证远程代码执行——恶意客户端可以接管未打补丁的服务端,恶意服务端也可以接管接受压缩响应的未打补丁客户端。

关键的时间差在于:根因在 2 月就已在 Brotli 源码里修掉了,但当时没人认为这是安全问题,因此直到 10 月才有一个带着修复的版本发布。在这中间几个月,任何依赖该版本 Brotli 的系统都处在暴露状态。

波及范围比想象的大:它自己不会出现在你的依赖清单里

判定受影响的条件很简单:环境里装了 Brotli 1.2.0 或更早版本,或者 brotlicffi 1.2.0.x。修复版本分别是 Brotli 1.2.1 和 brotlicffi 1.2.1.0。

值得注意的是,你的代码不需要 import 这两个包中的任何一个。当这两个包存在时,一批常用 HTTP 客户端会自动声明 Accept-Encoding: br 并自动解压 Brotli 响应——urllib3(以及依赖它的 requests)、带 brotli extra 的 httpx、装了 Brotli 的 aiohttp 都是如此。Scrapy 之类的框架则会把 Brotli 作为依赖自动装上。

这就产生了一个反直觉的暴露面:应用从来没有引用过 Brotli,但只要它发出 HTTP 请求并接受了一个恶意服务端返回的压缩响应,就可能触发解码器缺陷。攻击路径也不限于入站——出站请求同样会中招,攻击者只需要控制一个应用会去访问的服务端,比如 webhook 投递、URL 预览、爬虫抓取目标,或者任何"抓取这个 URL"类功能。

Brotli 这个包在 PyPI 上的月下载量是 8280 万。这个数字解释了为什么它作为传递依赖无处不在。

扫描器为什么没报:一次供应链告警失效

这次事件里最值得记住的不是技术细节,而是一个事实:Brotli 1.2.1 和 brotlicffi 1.2.1.0 的发布说明里都没有提到这个 CVE。截至 10 月 23 日,各家漏洞数据库还没有把这个问题映射到这两个 PyPI 包上。这意味着 pip-audit、Dependabot 和 Safety 这三个工具都不会对这些版本报警。

换句话说,一个已经修好的版本,在扫描工具眼里和一个仍然有漏洞的版本长得一模一样。这是供应链告警机制里一个结构性缺陷:修复版本和受影响版本的判定依赖于漏洞数据库的更新速度,而数据库的更新滞后于代码的修复。

由此形成一个容易被忽略的窗口期:修复版本已经发布、但漏洞数据库还没更新的这段时间里,扫描器保持沉默。任何依赖"升级到最新版本"作为唯一动作的流程,都会在这个窗口里失去保护——因为当时的"最新"就是有漏洞的那个版本。

检测与排查思路

排查可以从三个视角展开。

依赖树视角。 不要只看直接依赖。真正需要回答的问题是:哪些 Python 环境装了 Brotli 1.2.0 或更早版本,哪些装了 brotlicffi 1.2.0.x。大量项目是通过 HTTP 客户端的 extra 间接引入 Brotli,所以要看完整的解析后依赖树,而不是直接依赖列表。在 CI 里固化一次全量解析依赖树的输出并做版本断言,比依赖扫描器的实时告警更可靠——至少在这一类"数据库还没跟上"的场景里。

运行时视角。 任何声明 Accept-Encoding: br 的出站客户端都是潜在触发面。如果服务会抓取用户提交的 URL、预览链接、或处理第三方 webhook,那么攻击者控制的响应就带着构造好的 Brotli 流来了。审查这类"会主动访问外部地址"的代码路径,比审查业务逻辑本身更有价值。

语言与运行时视角。 Node 侧同样受影响,且不限于 HTTP 服务——任何解压 Brotli 数据的进程都在范围内。Aikido 特别提到 Node 内置的 fetch() 和 body-parser 这类无需显式引用 Brotli 的路径。Python 侧的等价情况是框架或中间件解码带 Content-Encoding: br 的请求体。

还有一个技术细节值得知道:Python 扩展里 brotli.decompress() 遇到任何错误都只抛一个通用的 brotli.error: decoder failed,看不到具体错误码。所以如果你在用 Python 尝试确认自己的环境是否受影响,行为特征比错误消息更可靠——1.2.0 上的 Decompressor() 喂进构造流永远不会结束。

处置建议

Aikido 给出的处置步骤可以直接照做:

  • 升级到修复版本:pip install -U "Brotli>=1.2.1" 或 pip install -U "brotlicffi>=1.2.1.0"。用 Poetry、uv 或 pip-tools 管锁文件的项目,还需要改约束并重新生成锁文件

  • 逐一检查笔记本、CI runner、服务器和容器等所有运行环境;如果 Brotli 绑定来自 Linux 发行版而不是 PyPI,修复要通过发行版的安全公告来跟踪

  • 升级顺序上,优先处理会调用外部服务器或接受压缩请求体的服务

暂时无法升级时,卸载 Brotli 和 brotlicffi 是一条确定性缓解手段:上面那些 HTTP 客户端在没有它们时会退回 gzip 和 deflate。如果 Brotli 是通过 httpx[brotli] 这类 extra 间接装进来的,需要一并去掉 extra,否则下次安装又会把它带回来。需要注意这条措施只解决一半问题——在边缘拒绝 Brotli 编码的请求能降低入站暴露面,但保护不了你的应用主动抓取的 Brotli 响应。

收尾判断

按时间轴排一遍就能看清问题的结构:根因修复在 2 月完成,公开披露在 10 月,中间隔了八个月。在这八个月里,修复其实已经在代码里,但没有任何人知道要升级它——发布说明没提,数据库没映射,扫描器不报警。一个漏洞的修复和它的可发现性,是两件独立的事。

对团队来说,这条经验可以推广到任何传递依赖:不能把"升级到最新版"当成唯一防线,因为最新版是否安全取决于你信任谁在什么时候把什么写进了数据库。更稳的做法是自己维护一份关键传递依赖的版本基线,在依赖解析阶段就做断言,而不是等扫描器来通知你。

至于 Brotli 本身,它现在有 8280 万月下载量,是几乎所有 HTTP 栈的默认压缩解码器。这次事件里它不是被攻破的目标,而是被绕过的通道——攻击者挑的是解码器这个环节,因为这里的返回值语义含糊了太久,而所有人都默认压缩层是可信的基础设施。

本文面向授权安全研究与防御建设,未经授权对任何系统实施文中技术属违法行为。