Node 里这个洞两头咬人,先看你在哪边

Hello,我是飞飞。
这几天安全圈在传一个漏洞,编号 CVE-2026-111497,有人给它起名叫 Node2Shell。传得最凶的是「未认证远程代码执行」,也就是 RCE。听着像谁都能隔着网线把你服务器端走。
我把 OpenAI 的原文、Node 的公告、还有两拨人的分析都翻过一遍。你要是也做后端、也写脚本拉别人的接口,这篇值得花五分钟。因为大部分人先问的是「我有多危险」。真正该先问的是「我站在哪一边」。
OpenAI 自己写得很清楚:
an unpatched server that accepts compressed input could be taken over by a malicious client, and an unpatched client that accepts compressed input (such as “curl –compressed”) could be taken over by a malicious server.
翻成人话。一边是别人往你这儿发压缩数据,你解压的时候中招。另一边是你去拉别人的数据,你解压响应的时候中招。两个方向,共用底层同一份解码器。
你要是既写服务端接口,又天天 fetch 别人的地址,那你可能两边都站着。
两边的入口在哪
服务端这一面的入口窄得多。裸的 http.createServer() 收到一个 Content-Encoding: br 的请求体,它不会自己去解。Imperva 的原话是:A bare Node.js HTTP server does not decode incoming Brotli bodies simply by receiving them.
真正会解的是中间件。Express 5 配 body-parser 2,inflate 默认是 true,而且明确支持 br;Express 5.3.0 依赖的正是 body-parser ^2.3.0。再往上,Koa 配 co-body、Fastify 配 @fastify/compress,还有你自己手写 createBrotliDecompress() 的地方,都算入口。Imperva 实测覆盖了 Express 5.0 到 5.3、body-parser 2.0 到 2.3。
客户端这一面更容易被漏掉。Node 内置的 fetch() 会自动解 Brotli 响应,curl –compressed 算一个。
Python 那边装了 Brotli 或者 brotlicffi,requests、urllib3、httpx 也会自动解。浏览器同理,只是据第三方清单,主流浏览器已经带上修复,具体版本号我没核到。
这个库用得有多广?W3Techs 说 38.4% 的网站开着 Brotli 压缩。这个数算的是服务器给浏览器发压缩,不是请求体解压。请求体解压没有可靠统计,我不编。
我查了自己的两处
看完这些,我先查线上那台,把响应头打出来:
1 | $ curl -sS --compressed -D - -o /dev/null https://www.vance.xin/ |
gzip,不是 br。这个博客的 Dockerfile 第一行就是 FROM nginx:latest,纯静态托管,仓库里 grep 不到任何 brotli 配置。官方 nginx 镜像默认也不带 brotli 模块。
线上这台不在服务端受影响范围。它给读者发的也是 gzip,不会反过来拿 br 打别人。
本机不一样。我在这个仓库里敲了两条:
1 | $ node -v |
package.json 里 engines 写的是 >=24 <25。这个项目本来就要求 Node 24,我一直跑着 22。而这次受影响的是 22.x 里低于 22.23.4 的版本。我停在 22.23.2,差两个补丁版本,正好在区间里。
我没想到的是,升到 24.22.1 以上,项目要求的版本和这次的安全修复,一件事一起解决。
老实说,我以为自己在栈里,结果只有一半在。线上那半没事,天天开着的这半有事。
而且这不是我第一次只查一半。上次说要挨个查自动化流水线,最后只查了最顺手的那两条。这回我先查的又是最好查的那台,照旧从博客开始。
容器镜像、CI runner、Lambda、我那几个跑在别处的服务,一个都还没查。今晚要补的就是这几处。
这次的代码在 Node 的二进制里,一行都不给我看。我能做的,是数清楚自己跑着几份。
补丁为什么躺了七个月
这个漏洞最扎人的地方,是时间。
修复提交是 0ed4268a,作者是 Google 的 Evgenii Kliuchnikov,提交时间 2026 年 2 月 27 日。提交信息只有一句:Detect and report invalid stream earlier. 里面没有一个字提到安全。
Brotli 上一个正式版本 1.2.0 是 2025 年 10 月 27 日发的。下一个要等到 2026 年 10 月 21 日,那天一口气发了 1.0.10、1.1.1、1.2.1 三个分支。修复从公开到发版,隔了 236 天,七个多月。两个正式版本之间,隔了 359 天。
CVE 记录里有个字段叫 datePublic,填的就是 2026 年 2 月 27 日。也就是说,这份修复在公开仓库里躺了七个月,会 diff 的人都看得到那几行改动。
OpenAI 自己的解释是:当时没人知道它是个安全漏洞,之后也没有新版本把它带出去。原文是 This root cause issue was actually fixed in the Brotli source code in February, but it wasn’t known at the time that it gave rise to a security vulnerability and there was no more recent release of Brotli.
前天我写 PHP 那个洞,结论是「没人有义务修」。PHP 8.1 及更早已经停止维护,不会再发补丁,那批机器永远等不到。
这一篇正好反过来。补丁二月就写好了,只是没人把它发出来。同一个「等不到」的结果,上次卡在没人修,这次卡在修了没发版。
开源库的「修了」和「发布了」,中间可以隔七个月。
而你的依赖树只认发版。
还有一条时间线要说清。OpenAI 这条帖挂在那篇训练环境加固博客的同一条线程下面,帖子里写的是 During this red-teaming exercise。但根因修复在二月,比九月二十号的 DNS 事件和十月这轮红队都早。十月二十三号那篇博客的正文里,Brotli 这个词一次都没出现。准确的说法是:同一轮对外发布,同一条线程,但时间线对不上,官方没解释。把它算成「十月红队打出来的」,现在没有依据。
报告者是 OpenAI Codex Security 的 Khai Tran 和 Sylvie Mayer。这条线程里,比署名更值钱的是那个时间差:二月修好,十月才发版。
打没打成,三方没对上
同一个洞,三份说法。
CVE 记录写的是 potentially execute arbitrary code。CVSS 4.0 给了 9.2,Critical。但这个分是 Google 打的,向量里带 AT:P,意思是目标侧得满足特定条件才能打,不是无脑通杀。Node.js 自己的公告只写 High,没给分。
Imperva 那边说打成了。他们在官方 Node.js 24.22.0 的 Linux arm64 镜像上,用 Brotli 数据里的代码创建了一个应用本来不会创建的文件。同样的输入在 24.22.1 上被拒绝。原话里还有两句限定:execution took multiple attempts in our setup,We did not test every Node.js build or application。利用代码没有公开,只测了 arm64 和他们自己的应用配置。
Qi Deng 的 brotli2crash 说打不成。原话是 The vulnerability is real but structurally unreachable,实际影响是 DoS,也就是把服务打瘫,不是 RCE。他的论证是规范里的长度上限让那条路径永远走不到。但他全文没提 Imperva 的实测,没给 fuzz 方法、迭代次数和 Node 版本,README 里还把报告者写错了。
一方说打成了,另一方说结构上打不成,中间没有人复现对方的结论。我不替任何一方下判决。「RCE 已确凿」和「其实只是 DoS」,这两句话现在都站不住。
这不影响今天该不该升。公开的 PoC 在标准构建上主要是崩溃和有限的内存读取,崩溃已经够升了。截至十月二十三、二十四号,没有看到在野利用的可靠报道。CISA 的已知被利用目录里没有它,第三方给的利用概率估值是 0.0048。不用恐慌,也不用拖。
出站那条路没人看
很多团队的第一反应是上 WAF。WAF 就是网站前面替你挡请求的那一层。入站这一面,它确实能挡。Imperva 说自己加了专用规则,验证能挡住他们复现的攻击。但同一段里他们也写了:Complete remediation requires updating Node.js.
出站这一面,WAF 根本不在那条路上。你的服务去 fetch 一个用户给的地址,响应是对方发回来的。解压发生在你的进程里,门口的墙看不到。
把 Accept-Encoding 里的 br 删掉也不够。Patch the Planet 的原话:Removing br from Accept-Encoding alone does not stop a server from sending it. 恶意服务器可以硬发一份 br 响应,客户端按响应里的 Content-Encoding 选解码器。
nginx 的 ngx_brotli 是压缩模块,管把响应压小,不管解压请求体;nginx 主线和 Apache 默认都不解 Brotli。所以「开了 br 压缩」不等于「会解 br 请求体」。第三方有个 ngx_unbrotli 会解响应,版本状态我没核到,不下结论。
完整的修复只有一条:升级运行时,然后重启进程。跑着的进程会一直用旧解码器,不重启等于没升。
麻烦在于「运行时」不是一个。本地、镜像、CI、serverless,各一份,每一份都要单独重建、单独确认。
对我来说,真正要紧的是这台天天开着的 Mac。十几个 skill、CLAUDE.md、memory、一堆 API key 和登录态,全在它上面。它一天要 fetch 多少个地址,我自己都数不清。它跑的 Node 是 22.23.2。
今天先把版本查了
清单不长,照着跑就行,建议直接存下来。
先记住一条,它比查版本更要紧:升完要重启。跑着的进程会一直用旧解码器,只换个版本号不重启,等于没升。
Node.js:22.x 升到 22.23.4 或以上,24.x 升到 24.22.1 或以上,26.x 升到 26.12.1 或以上。20 及更早、23、25 没有补丁,只能迁移。查法是每个环境各跑一次 node -v。
- 本机:node -v
- 容器镜像:docker run –rm 你的镜像 node -v
- CI:看 workflow 里 setup-node 读的是 .nvmrc 还是写死的版本号,再按那个版本对一遍
- Lambda 这类 serverless:看运行时配置里钉的版本,不是你本地那个
别只看 process.versions.brotli。Node 是 cherry-pick 修复,修复版也可能还报 1.2.0,以 Node 自己的版本号为准。
Python 那边:pip show Brotli brotlicffi。用不上就卸掉,requests、urllib3、aiohttp 会自动退回 gzip。要用就升到 Brotli 1.2.1、brotlicffi 1.2.1.0。注意不 import 也会中招,装了包,客户端库就会自动替你解。
其他运行时: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。用 curl –compressed 的,还要看系统的 libbrotli,dpkg -l | grep -i brotli,或者 rpm -qa、apk info -v 那套。
上游 Brotli 库本身:1.0.3 到 1.2.0 受影响,修在 1.0.10、1.1.1、1.2.1。
清单之外还有一条容易忘的。你的服务有没有 fetch 用户给的 URL?有的话,入站 WAF 救不了它,这条只能靠升级。
我今晚先把 node 从 22.23.2 换到 24.22.1 以上,再把没查的那几个环境列出来,一个一个跑 node -v。这次我尽量不只在博客上查完就发文章。
开头我写,该先问的不是「我有多危险」,是「我站在哪一边」。现在有答案了:线上那台不用管,真正要换的是这台装着所有钥匙的 Mac。
你查完 node -v 是多少,打在评论区。要是也停在 22.x,说一声;要是数到第三份就不想数了,更该说一声。