Node.js 发 10 个 CVE 补丁:Brotli 越界写入能打通 RCE,但你不主动解压也躲不过

Node.js 10 月 21 日发布安全更新,修复 10 个 CVE,4 个高危。Brotli 解码器越界写入可致 RCE,但不需要你主动解压:fetch 会按响应头自动解 br。本文在 v22.13.1 上实测三条,修复版本为 22.23.4、24.22.1、26.12.1。

10 月 21 日,Node.js 官方对 26.x、24.x、22.x 三个发行线同时发布安全更新,共修复 10 个 CVE——4 个高危、3 个中危、3 个低危。修复版本分别是 v26.12.1、v24.22.1、v22.23.4。

国内不少转载把版本写成了「6.x、24.x、22.x 全线告急」,这是笔误,实际是 26.x。这个数字很重要,因为它决定了你要不要动:如果你的服务跑在 22.x 上,那它确实在受影响范围内;如果跑在已经 EOL 的 20.x 或 25.x 上,那更麻烦——这些分支不会有补丁。

本文不只是把 10 条 CVE 抄一遍。我在本机 Node v22.13.1 上(正是受影响版本)把其中三条最值得关注的问题逐个实测了,包括一条很多人以为「只要不主动解压就没事」的 Brotli 漏洞。

先看清全貌:10 个漏洞的分级与影响面

官方公告把 10 个问题按严重程度分了三档。我做了一张对照表,按「实务上意味着什么」排序而不是按编号:

Node.js 2026 年 10 月安全更新 10 个 CVE 清单:4 个高危含 Brotli 越界写入、dns.resolve NUL 截断、两条 TLS 主机名校验绕过,附影响分支与实务含义
10 个 CVE 的分级、影响分支与实务含义。修复版本 v22.23.4 / v24.22.1 / v26.12.1

写这篇文章时我顺手看了下自己机器,node -v 出来是 v22.13.1——比 v22.23.4 低了整整 10 个小版本。我们日常的构建脚本、抓取任务都跑在这上面。这种「随手一查发现自己就在坑里」的情况相当普遍,因为 Node 版本很容易在装某个工具时被悄悄固定下来,之后再没人管。

最该担心的那个:Brotli 解码器越界写入

CVE-2026-111497 是这次唯一一个能直接打通远程代码执行的漏洞。官方描述很简短:Brotli 解码器可能发生越界写入,从而导致 RCE。没有更多细节。

但这里有个容易被误判的地方。很多人看到「Brotli 解码」的第一反应是:我的代码里没调用过 zlib.brotliDecompress(),跟我没关系。这个判断是错的。

Node.js Brotli 漏洞攻击路径:恶意服务器返回 Content-Encoding: br,Node 的 fetch 自动调用 Brotli 解码器解压,即使客户端声明 Accept-Encoding identity 也照样解
攻击链:不需要你的代码主动解压,fetch 会替你解

Node.js 的 fetch() 在收到 Content-Encoding: br 的响应时,会自动用 zlib.createBrotliDecompress() 解压。关键是——这个判断只看响应头,不看你的请求头。

我做了个最小验证:起一个本地 HTTP 服务,故意返回 Content-Encoding: br;客户端用 fetch 请求,并明确带上 Accept-Encoding: identity(意思是「我只要原始内容,别压」)。结果是:

服务端确实收到了 Accept-Encoding: "identity",但它仍然回了 br;而 Node 的 fetch 照解不误,返回了正确的解压后内容。也就是说,只要你的服务用 fetch 拉过外部 URL——调用第三方 API、抓 RSS、下载远程图片、做 webhook 回调——解码器就会在对方返回 Content-Encoding: br 时被执行。

这意味着攻击者不需要攻破你的服务器。他只需要让你去请求他的服务器。任何接受用户输入 URL 的功能都是入口:图片代理、网页抓取、RSS 订阅、Webhook 回调验证、SSRF 防护不严的内部服务调用。这类「输入没进业务代码就出事」的模式,在 PHP 的 php2shell 漏洞上刚演过一遍——那也是请求在应用逻辑执行之前就被处理了。

修复本身很小:把上游 Brotli 库的一个提交 cherry-pick 过来,让解码器在「流里只声明了一种 block type,却出现了 block switch」时立即判定格式错误并中止。Node 还顺带暴露了一个常量 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH。

第二个:dns.resolve 遇到 NUL 就把域名截断了

CVE-2026-105482 的成因很经典——C 语言的字符串以「结束标记」判定到哪里为止。Node 的 dns.resolve* 系列函数底层走 c-ares(一个 C 库),当传入的 JS 字符串中间夹了一个不该出现的结束标记,c-ares 只能看到它前面的那一段。

官方修复提交里的测试用例是:dns.resolve4('example.com\0.trusted.corp'),实际查询的是 example.com。我在 v22.13.1 上跑了一遍,两条命令返回的 IP 完全相同,印证了这一点。

Node.js dns.resolve 遇到 NUL 字符截断域名的实测:传入 www.baidu.com 加 NUL 加 .trusted.corp,白名单校验通过但实际解析到百度服务器 IP
dns.resolve 的 NUL 截断演示:白名单校验看到的是一个名字,实际查询的是另一个

危害在于它天然是个绕过。假设你的代码做了这样的防护:

if (!host.endsWith('.trusted.corp')) throw new Error('blocked');
await dns.promises.resolve4(host);

攻击者传 www.baidu.com\0.trusted.corp,endsWith 返回 true,校验通过;然后 DNS 实际解析的是一个完全无关的外部域名。我在实测里拿到的是 183.2.172.177——百度服务器的地址,跟 .trusted.corp 没有任何关系。

这类「校验时看的是 A,使用时用的是 B」的模式,是所有解析器差异类漏洞的共性。修复后的版本会直接抛 ERR_INVALID_ARG_VALUE,覆盖 dns.resolve*、dns.reverse() 及它们的 Promise 版本。

如果暂时不能升级,一行代码就能临时挡住:

if (host.includes('\0')) throw new Error('invalid host');

两条 TLS 证书校验绕过,以及一个我未能复现的结果

CVE-2026-105483 和 CVE-2026-106069 都关于 TLS 证书主机名校验。IPv4 地址远不止 127.0.0.1 一种写法——127.1、0x7f.0.0.1、2130706433、0127.0.0.1、全角数字 127.0.0.1、甚至用句号 127。0.0.1,操作系统都能认。部分老系统和浏览器也接受这些写法。

而 Node 自带的地址判定函数一个都不认。于是出现错位:URL 规范那边会把它们规范化成地址,操作系统解析器也解析成地址,但 Node 的证书校验仍把原始字符串当「主机名」,去和证书里的域名条目比对。攻击者只要拿到一张以地址形式书写的证书,就能冒充另一种写法的同一地址。

我实测了 Node 的地址判定函数与 URL 规范化函数之间的行为差异,六种写法全部落在这个缝隙里:

  • 标准四段点分:Node 识别为地址,URL 规范化后不变,正常。
  • 省略后两段:Node 不识别,URL 规范化会补全为标准形式,落在缝隙里。
  • 带前导零:Node 不识别,URL 规范化可能得到另一个地址,两边结果不一致。
  • 十六进制、整数、全角数字:Node 一律不识别,URL 规范化都能转回标准形式,全部落在缝隙里。

注意第三条:带前导零时,URL 规范化得到的地址与系统解析器给出的地址并不是同一个。同一个字符串,两套解析给出两个不同结果——这就是 CVE-2026-106069 的核心,也是它能被用来做中间人的原因。

需要说明的是:我尝试用自签 CA 签发含 IP Address:87.0.0.1 的证书,在本机 v22.13.1 上做端到端 TLS 连接验证,未能复现绕过——0127.0.0.1 等写法都被 ERR_TLS_CERT_ALTNAME_INVALID 正确拒绝。这不代表漏洞不存在,更可能说明该回归在 v22.13.1 这个较早的小版本上还没引入(官方修复提交里称 105483 为 regression,即某次改动之后的回归)。所以上面的写法差异是事实,但「能否真的借它中间人」需要以你实际运行的版本为准,不能一概而论。

临时防护可以照官方思路写成一个判断函数——只要原始 host 不是 IP、但规范化后变成了 IP,就拒绝连接:

// 拒绝“看字符串不像地址、规范化之后却变成地址”的主机
function isSuspiciousHost(host) {
  if (parsedAsAddress(host)) return false;      // 本来就是标准写法,放行
  if (normalizedAsAddress(host)) return true;   // 规范化后才成地址,拒绝
  return false;
}

还有一条容易被当成“无关”的中危:随机数重复

CVE-2026-106074 值得单独提一句。它的问题在于 crypto.randomUUID() 和 randomInt() 的熵缓存会被写进用户态启动快照(startup snapshot)和单文件可执行程序(SEA),导致每个进程生成同样的值。

如果你用 SEA 打包了服务,或者依赖 Node 的启动快照做冷启动优化,这条就不只是「中危」——所有实例的 UUID 序列一致,任何拿 UUID 当会话标识、幂等键、订单号的地方都会出问题。这条影响 22/24/26 全部三个分支。

怎么升、升完怎么验

先说最省事的路径,按你的部署方式分:

部署方式 升级命令 注意
nvm nvm install 22.23.4 && nvm use 22.23.4 装完记得 nvm alias default,否则新开终端又回到旧版
Docker FROM node:22.23.4 后 rebuild 必须先 docker pull,浮动标签会命中本地缓存
官网二进制 从 nodejs.org 下载对应包 用 SHASUMS256.txt 校验,三个版本发布当天都已上线
Homebrew brew upgrade node@22 发布当天下午 Homebrew 仍停在旧版,急的话走官网包
系统包管理器 apt / yum 升级 发行版可能自行 backport,要看包版本号而不是 Node 版本号

升级后别只看 node -v 就完事。生产环境常见的坑是:命令行里的 node 升了,但 systemd 服务里的 ExecStart 写死了旧路径的绝对路径,或者 pm2 守护的进程还在用旧二进制。pm2 restart all --update-env 之后还要 pm2 list 确认版本,最稳的是直接在服务里打一行日志把 process.version 输出来。

如果是 Docker,还有一个隐蔽的问题:构建阶段用的基础镜像和运行阶段是两回事。COPY --from=builder 只带文件不带运行时,但多阶段构建里如果运行阶段自己也装了 node,就得两处都改。

升级后同样建议顺手做一次出站连通性回归——尤其是那些调用第三方 API 的服务,因为它们走的正是前面说的 Brotli 解压路径。这块的检查思路和 之前整理 php2shell 修复清单时用的是同一套:先确认版本号,再确认运行时真的换过来了,最后跑一遍真实业务链路。

这次更新里还藏着一件小事:eMudhra 的账

读官方公告的依赖更新列表时,我注意到一行:ca-cert(eMudhra Intermediate CA Compromise)on 26.x, 24.x, 22.x。

这指的是前几天那起 eMudhra 中间 CA 私钥熵不足事件——私钥只有 255 种可能值,等于公开。本站此前详细分析过这次事件。当时各方的应对是吊销根证书,但这会连带打挂大量正常站点,很棘手。

Node.js 这次的做法更精准:不删根,而是在证书链里检查是否包含特定被封锁的公钥,命中则返回 CERT_REVOKED。这条修复在公告里只是列表中的一行,但它实际上解决了一个多月来悬着的问题——那些依赖 Node 做 HTTPS 出站校验的服务,现在能自动拒绝走 eMudhra 中间 CA 的伪造链了。

两件事在这里交汇:一个 TLS 校验绕过、一个 CA 私钥泄露,都落在同一个 tls.checkServerIdentity() 的信任判断上。如果你只关心「我的 Node 要不要升级」,答案是要;如果你还想知道为什么,这就是原因。

写在最后:EOL 才是真问题

官方公告没有一句废话,但有个事实它没法替你解决:v25、v23、v20 已经 EOL,不会收到这次补丁。

v25 在 2026 年 6 月 1 日结束支持,v24 刚在 10 月 20 日进入维护 LTS,v26 要到 10 月 28 日才成为活跃 LTS。很多人的机器上装的是 v25 或 v20,因为某个 CLI 工具当时只支持它——然后就一直没动。

判断标准很简单:跑一句 node -v。第二位数字是 22、24、26 的,升小版本即可;是 20、23、25 的,这次没补丁,得规划迁移;是 18 及更早的,风险已经不只是这几个 CVE 了。

一句话行动项:node -v,然后对照 v22.23.4 / v24.22.1 / v26.12.1。我今天查自己机器出来的结果是 v22.13.1,差 10 个小版本——这种事查一次就知道了,不查就一直在里面。

给TA打赏
共{{data.count}}人
人已打赏
在线客服
在线客服
热线电话
QQ客服
电子邮箱
suduwangluo