导语:扫描器全绿,漏洞在跑
做依赖安全的同学最近可以体验一下什么叫工具失灵:跑一遍 pip-audit、Dependabot、Safety——全绿。但你的 Python 环境里,只要装着 Brotli(月下载 8280 万次的包,requests 的常见传递依赖),一个 CVSS 9.2 的远程代码执行漏洞就可能正躺在你的解码路径上。
这就是 CVE-2026-111497,安全圈给它起过一堆名字:Node2Shell、HTTP2Shell、Python2Shell,还有人注册了 broccoli2shell.com(西蓝花2Shell)来吐槽命名乱象。名字闹归闹,事是真大:10 月 21 日到 24 日,这个洞从「Node.js 的问题」滚成了「所有解 Brotli 的软件的问题」。
它最讽刺的地方在于:今年 2 月,OpenAI 的 Codex Security 团队就发现并修掉了根因——修复代码进了 Brotli 源码仓库,但当时没被识别为安全漏洞,从来没有随任何版本发布出去。直到 10 月 21 日 Node.js 发安全公告,大家才发现补丁已经迟到了八个月。
想看一手材料?
- 🌐 官方:Node.js 10-21 安全公告 / 上游修复 commit
- 🔍 发现方:Aikido:HTTP2Shell 影响面全图
- 💥 复现实证:Imperva Red Team:从 Brotli 数据到 RCE
🐛 这是个什么洞:分拣员分不清「包裹破损」和「等下一件」
Brotli 是 Google 的无损压缩算法,网页流量压缩(HTTP 里的 Content-Encoding: br)基本靠它。洞出在解码器的 DecodeBlockTypeAndLength() 函数——它负责处理 Brotli 流里的「块切换」(解码器换一套 Huffman 树继续解的地方)。
在受影响版本(1.0.3–1.2.0)里,这个函数返回一个布尔值,而这个布尔值有两种完全不同的含义:「这流是坏的」和「我还需要更多输入才能判断」。调用方分不清这两者,快路径甚至干脆不看返回值。打个比方:分拣员举的牌子既可能是「包裹破损,退货」也可能是「等下一件凑单」,但传送带不管三七二十一,见到牌子就继续收货。
攻击者构造一个特殊的 Brotli 流,就能让解码器带着错误状态继续把输入字节灌进一个 8 字节的内部小缓冲区——越界写(CWE-787)就此发生,后果是内存泄漏、崩溃,乃至任意代码执行。上游修复(commit 0ed4268)把返回值改成了正经错误码,新增 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17),坏流在快慢两条路径上都会被立刻拒绝。
不需要复现细节也能感受到分量:Imperva 红队 10 月 22 日在官方 Node.js 24.22.0 Docker 镜像上把 RCE 跑通了,同一输入在修复版上被稳定拒绝。目前无在野利用报告(10-22 Node.js 口径),但「能被公开复现的未修补 RCE」这个组合本身就值得当晚行动。
🕐 四天滚雪球:从 Node2Shell 到 HTTP2Shell
把时间线折成一段话:2 月,OpenAI 发现根因、修进源码,但没当安全漏洞、没发版;10 月 21 日,Node.js 发布安全公告修复三个版本线,发现方 Aikido 起名 Node2Shell;10 月 22 日,Python 安全更新确认同一缺陷波及 Python 包,Imperva 复现 RCE;10 月 23 日,Aikido 把影响面铺开到 Python/curl/Envoy/Istio/C++ 并更名 HTTP2Shell,同一天 OpenAI 发推确认「这洞我们二月就报了」;10 月 24 日,各家补丁基本齐备。
为什么一个解码器库能滚成全栈事件?因为 Brotli 的分发面太宽了:Node.js 内置它(fetch()、body-parser 都会走到),curl/git 捆绑它,Bun/Envoy/Istio 全家桶带着它,Python 生态里 requests、httpx、aiohttp、Scrapy 只要发现环境里装了 Brotli 或 brotlicffi 包,就自动对外宣告「我能收 br 压缩」并自动解码。
最后这条是最阴的:你的代码可以一行 Brotli 都没 import 过,照样中招。攻击路径有两条——出站:你的应用去抓攻击者控制的服务器(webhook 投递、URL 预览、爬虫、「取这个链接」功能),对方回一个恶意 br 响应;入站:你的服务直接解码攻击者发来的 Content-Encoding: br 请求体(Express 5 + body-parser 2.x 就是这个姿势)。
🛠️ 谁要修、修到哪、挡不住时怎么办
先说一个容易误会的好消息:Apache 的 mod_brotli 和 Nginx 默认只压缩不解压,作为服务端不受这个洞影响。要动手的是下面这些:
| 组件 | 修复版本 |
|---|---|
| Node.js | 22.23.4 / 24.22.1 / 26.12.1 及更高 |
| Brotli(上游/原生库) | 1.0.10 / 1.1.1 / 1.2.1(按分支选) |
| PyPI Brotli / brotlicffi | 1.2.1 / 1.2.1.0 |
| Bun | 1.4.4+ |
| Envoy | 1.40.0 / 1.39.3 / 1.38.6 / 1.37.8 |
| Istio | 1.29.9 / 1.30.6 / 1.31.2 |
Python 环境三步自查:
# 1. 每个环境(含 CI 和容器)里查一遍
pip show Brotli brotlicffi
docker run --rm <你的镜像> pip show Brotli brotlicffi
# 2. 看是谁把它带进来的(八成是某个 HTTP 客户端的 extra)
pipdeptree -r -p Brotli
grep -i brotli poetry.lock uv.lock requirements*.txt
# 3. 升级 + 重建
pip install -U "Brotli>=1.2.1" "brotlicffi>=1.2.1.0"
升级不了的环境可以临时卸载 Brotli 和 brotlicffi(requests/httpx 会自动回退到 gzip/deflate,只是遇到硬要发 br 的服务器可能拿到读不了的响应)。边拦入站:在反向代理/中间件按 Content-Encoding: br(大小写不敏感、注意列表形式)拒绝请求体;出站:从 Accept-Encoding 里去掉 br,并且在解码发生前按响应头拦截。
要特别澄清一个常见误解:只改出站的 Accept-Encoding 头是不够的——恶意服务器可以无视协商直接回 br 响应,而 urllib3 是按响应头里的 Content-Encoding 挑解码器的。入站出站都得在「解码动作发生之前」拦,解码完再看就晚了。
还有两个容易漏的角落:镜像里锁死的旧版本不会自己升级,构建产物(wheel、编译型可执行文件)要重建;另外 pyarrow 26.0.0 和 Pillow 13.0.0 的 Linux/macOS wheel 捆绑了未修复的解码器副本,目前连修复 wheel 都还没有,只能盯上游。
📌 把话说明白
这事的教训分三层。站得住的部分:漏洞机制、影响面、修复矩阵全部有官方公告、发现方报告和独立复现三方交叉,事实层非常扎实。有争议的部分:社区在吵命名是不是标题党(「谁都听过 HTTP,没谁听过 Brotli」)和 Google 该不该为「2 月修了却没发版」挨板子——后者是比命名正经得多的流程问题:修进 main 不等于发布,漏洞分级流程漏了一步,补丁就在仓库里躺了八个月。没证实的部分:git 捆绑的解码器是否可实际利用还没有公开结论;pip-audit 们何时开始报警也取决于漏洞数据库的映射进度,等工具是等不来的。
对不同的人意味不同:跑 Node/Python 服务的,今晚就把版本矩阵过一遍,出站抓外部 URL 的服务优先;做安全流程的,把「修复进入 main」和「修复进入发布版本」当成两件硬性分别跟踪的事;至于普通开发者——去 pip show 一下,三十秒,值。
参考来源
- Node.js 官方安全公告(一手)— nodejs.org/en/blog/vul…
- google/brotli 修复 commit 0ed4268(上游一手)— github.com/google/brot…
- Aikido:Node2Shell evolves to HTTP2Shell(发现方,垂直安全媒体)— www.aikido.dev/blog/python…
- Imperva Red Team:Node2Shell 从 Brotli 数据到 RCE(垂直安全媒体)— www.imperva.com/blog/node2s…
- OSV:CVE-2026-111497 记录(漏洞数据库)— osv.dev/vulnerabili…
- Hacker News:Node2Shell evolves to HTTP2Shell 讨论(社区)— news.ycombinator.com/item?id=502…
机制、影响面与修复矩阵均有官方公告和独立复现交叉验证,适合做依赖安全与网关运维的同学连夜排查;也提醒把「修进 main」和「进发布版」当成两件事跟踪。