ARTICLE / 安全

你没 import 过它,它却能 RCE 你:Brotli 越界写

图:b_attack_paths 图:b_attack_paths

先做一个测试:让你的团队说出项目里所有会解压 Brotli 数据的代码路径。绝大多数团队的答案是"我们没有用 Brotli"。

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[1]。

这件事真正的杀伤力不在漏洞本身,而在它的传播模型:你的代码一行都没引用它,它依然在你机器上等着被触发。 我把这类存在叫"隐形依赖",这篇文章就讲清楚它是怎么形成的、扫描器为什么集体沉默、以及八个月窗口期里发生了什么。

一、漏洞本体:一个返回值扛了两种语义

缺陷在 Brotli 解码器的 DecodeBlockTypeAndLength() 函数——处理块切换(Brotli 流更换 Huffman 树的位置)的代码。受影响版本里,这个函数返回普通布尔值,导致 BROTLI_FALSE 同时表达两种含义:“流格式非法”和“还需要更多输入数据”。调用方无从区分,而快速路径上干脆没人检查这个返回值。CVE 成因组合因此记为 CWE-252(未检查返回值)+ CWE-787(越界写)[1]。

上游修复方式是让函数返回真正的错误码,并新增专门错误 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17),让格式错误在快速路径上也能立即终止解码。研究者对 1.2.0 与 1.2.1 源码做差异比对,确认后者相对前者只多了版本号变更和这一处解码器改动[1]。

触发条件不需要任何特殊配置:一个进程解压攻击者构造的 Brotli 数据,就可能内存泄露、崩溃,极端情况执行任意代码。 OpenAI(2 月发现该漏洞的一方)把它描述为双向成立的未认证 RCE:恶意客户端可以打穿未打补丁的服务端;恶意服务端可以打穿接受压缩响应的未打补丁客户端[1]。

二、隐形依赖:它不在你的依赖清单里,但在你的进程里

图:b_timeline 图:b_timeline

受影响判定很简单:Brotli ≤1.2.0,或 brotlicffi 1.2.0.x;修复版本分别是 1.2.1 和 1.2.1.0[1]。问题是——谁装了它们?

答案:远比你以为的多,而且多数人不是自己装的。当这两个包存在于环境时,一批常用 HTTP 客户端会自动声明 Accept-Encoding: br 并自动解压 Brotli 响应:urllib3(以及依赖它的 requests)、带 brotli extra 的 httpx、装了 Brotli 的 aiohttp;Scrapy 这类框架更是把 Brotli 直接作为依赖自动装上[1]。Brotli 在 PyPI 的月下载量是 8280 万[1]——这就是"无处不在"的计量单位。

于是暴露面出现了一个反直觉的形状:

  • 入站:任何解压带 Content-Encoding: br 请求体的服务,遇到恶意客户端即触发;
  • 出站:任何"你的应用主动访问外部地址"的功能——webhook 投递、URL 预览、爬虫、链接抓取——攻击者只要控制那个服务端,返回构造好的压缩响应即可[1]。

注意出站路径的攻击成本结构:攻击者不需要碰你的基础设施,只需要建一个你会来访问的网站。 对爬虫和聚合类产品,这几乎是必经之路。

Node 侧同理且不限于 HTTP 服务:内置 fetch()、body-parser 这类从不显式引用 Brotli 的路径都在范围内[1]。这就是隐形依赖的完整含义——依赖关系不经过你的代码,直接发生在运行时的编解码协议层。

三、扫描器为什么集体沉默:修复≠可发现

图:b_window 图:b_window

这次事件里最值得写进流程规范的,是一个时间差:根因 2 月就在源码里修掉了,但当时无人认定它是安全问题,直到 10 月才随新版本发布[1]。这八个月里,修复一直躺在代码库里,而全世界没有任何人知道要升级。

更糟的还在后面:Brotli 1.2.1 和 brotlicffi 1.2.1.0 的发布说明都没有提到这个 CVE;截至 10 月 23 日,各漏洞数据库尚未把该 CVE 映射到这两个 PyPI 包——意味着 pip-audit、Dependabot、Safety 对修复版本一律不报警[1]。

把这个状态说透:一个已修复的版本,在扫描工具眼里和一个带漏洞的版本长得一模一样。 原因是结构性的——扫描器的判定依据是漏洞数据库的"受影响版本区间",而数据库的更新永远滞后于代码修复。于是每个漏洞的生命周期里都藏着一段最危险的窗口期:

已修复但未同步期:代码安全了,数据库还没跟上,“升级到最新版"恰好把你升到扫描器看不见的版本。

把安全流程全部押在扫描器告警上的团队,在这段窗口期里是裸奔的,而且毫无感知。

四、排查与处置:三个视角 + 一份确定性清单

依赖树视角。 要回答的问题不是"我装了 Brotli 吗”,而是"我的完整解析后依赖树里有没有 Brotli ≤1.2.0 / brotlicffi 1.2.0.x"。大量项目经由 HTTP 客户端的 extra 间接引入,所以必须看解析后依赖树,不是直接依赖列表。在 CI 里固化全量依赖树输出并做版本断言——这类"数据库没跟上"的场景里,你自己的断言比扫描器的实时告警可靠得多[1]。

运行时视角。 排查所有声明 Accept-Encoding: br 的出站客户端;重点审查"会主动访问外部地址"的代码路径(抓取、预览、webhook)。Python 侧注意:brotli.decompress() 对任何错误只抛通用的 brotli.error: decoder failed,错误码不可见——确认环境是否受影响时,行为特征比错误消息可靠:1.2.0 上向 Decompressor() 喂构造流,解码永远不结束[1]。

环境视角。 笔记本、CI runner、服务器、容器逐一确认;Brotli 若来自 Linux 发行版而非 PyPI,修复要通过发行版安全公告跟踪[1]。

处置动作清单(来自 Aikido,可直接执行)[1]:

  1. pip install -U "Brotli>=1.2.1" 或 pip install -U "brotlicffi>=1.2.1.0";用 Poetry/uv/pip-tools 的项目改约束后重新生成锁文件
  2. 升级顺序:优先处理会调用外部服务器或接受压缩请求体的服务
  3. 暂时无法升级时,卸载 Brotli/brotlicffi 是确定性缓解——urllib3 等客户端会退回 gzip/deflate;注意若经由 httpx[brotli] 这类 extra 引入,需一并去掉 extra,否则下次安装会被带回
  4. 残余风险提示:在边缘拒绝 Brotli 编码请求只保护入站,保护不了你的应用主动抓取的 Brotli 响应

五、收尾:漏洞的两大半场

按时间轴排一遍,这个漏洞的一生分成极不对称的两半:2 月到 10 月,修复已存在,可发现性为零;10 月披露后,数据库映射又要追赶到某个时刻,扫描器才开口。如果用一条曲线画"可发现性",它比"可利用性"晚出发了至少八个月。

这就是我看完整个事件后留下的核心判断:一个漏洞的修复和它的可发现性,是两个独立工程,而行业的投入几乎全压在前者。 CVE 编号、CVSS 评分、厂商公告都服务于"修复"叙事,但真正决定你死活的,是"可发现性"这条线的迟到程度。

对团队,落地动作就一条:为关键传递依赖(压缩库、解析器、序列化器)维护自己的版本基线,在依赖解析阶段做断言,不等任何数据库。 扫描器是好的发现渠道,但你的 CI 断言才是最后的堤坝。

Brotli 事件里它不是被攻破的目标,而是被绕过的通道——攻击者挑中解码器,因为这里的返回值语义含糊了太久,而所有人都默认压缩层是可信基础设施。下一个"所有人都默认可信"的层是什么?值得现在就想。


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

参考

  1. Aikido Security,HTTP2Shell:CVE-2026-111497 Brotli 解码器越界写分析与处置建议(2026-10)——含 Node.js 三条版本线安全更新、CVSS 9.2、DecodeBlockTypeAndLength() 返回值语义、OpenAI 2 月发现说明、urllib3/requests/httpx/aiohttp/Scrapy 隐形引入路径、PyPI 月下载 8280 万、发布说明未提 CVE 及数据库映射滞后(截至 10-23)、brotli.error 行为特征、处置清单。快照:sources/aikido.txt
  2. Node.js 官方安全发布公告(2026-10-21,26.x/24.x/22.x)。快照:sources/nodejs.txt
  3. Glonce,Brotli flaw CVE-2026-111497 (Node2Shell) hits Python and other HTTP software(2026-10-25)——OpenAI 2 月发现并将其描述为双向成立的未认证 RCE、受影响路径包含内置 fetch() 与 body-parser、根因 2 月修复但无版本携带该修复。快照:sources/glonce.txt

本文事实层以上述一手来源为限;「隐形依赖」「已修复未同步期」「可发现性曲线」为作者分析框架,非来源原文观点。