更新日:2026年9月3日

14分で読めます

Node.jsサンドボックスライブラリvm2に、CVSS 10.0のリモートコード実行の脆弱性

GitLabの脅威リサーチグループは、Node.jsサンドボックスライブラリvm2に深刻なサンドボックス脱出の脆弱性を発見しました。公式ドキュメント記載の設定のまま、攻撃者のコードが実行されます。

GitLabの脅威リサーチグループは、Node.jsのサンドボックスライブラリとして広く採用されているvm2に、深刻なサンドボックス脱出の脆弱性を発見しました。攻撃の起点となるのは、vm2自身のREADMEからそのままコピーできる設定です。評価はCVSS 3.1で10.0、区分は「緊急」。発見にはGitLab独自のAI自動化ツールを用いました。require.externalを有効にしたvm2バージョン3.11.6以前を運用しているなら、直接悪用可能な状態だと考えてください。

発見後、GitLabがvm2を使用していないことは確認済みです。 脆弱性はvm2側へ非公開で報告し、メンテナーはバージョン3.11.7で迅速に修正。修正版をあらためて検証したところ、報告したとおりの攻撃はブロックされました。

ただし、vm2に依存しているのであれば、もう一点お伝えしておきたいことがあります。メンテナー自身による修正の説明を踏まえると、今回のパッチだけでは解消しきれない、より広い設定上のリスクが残っています。

TL;DR

深刻な脆弱性:GitLabの脅威リサーチグループは、広く使われているNode.jsサンドボックスライブラリvm2に、リモートコード実行につながる深刻なサンドボックス脱出(CVSS 3.1: 10.0)を発見しました。

根本原因:脆弱性の起点は、ライブラリ自身のREADME「Quick Examples」に記載されたデフォルト設定です。サンドボックスがホストシステムから適切に隔離されず、悪意あるコードが無制限のアクセス権を獲得します。

修正の限界:バージョン3.11.7への更新で、報告された攻撃そのものはブロックされます。ただし根底にある設定上のリスクは解消されず、require.externalを広すぎるrequire.rootと組み合わせて使い続ける限り、危険は残ったままです。

早急に実施すべき対応:バージョン3.11.7へ更新したうえで、設定を手動で強化してください。require.rootは必要なファイルだけに限定し、デフォルトの'host'任せにせずcontext: 'sandbox'を明示的に指定します。

長期的な指針:vm2にはサンドボックス脱出の不具合を繰り返してきた経緯があります。本当に信頼できないコードの隔離にvm2を使うのは避け、コンテナやプロセス分離といった、より堅牢な手段を選ぶことをおすすめします。


サンドボックスライブラリが狙われる理由

完全には信頼できないコードを実行するアプリケーション(プラグインシステム、コーディング用のプレイグラウンド、CIのテストランナー、そして最近ではAIが生成したコードをAI自身に実行させるツール)は、そのコードをNodeの通常のrequire()やeval()にそのまま渡すわけにはいきません。渡してしまえば、そのコードはアプリケーション本体と同じ権限を持ってしまいます。

vm2はこのギャップを埋めるために作られたライブラリです。信頼できないコードには通常のJavaScript実行環境を模した偽の環境だけを与え、その下にある本物のシステムへ手を伸ばそうとする動きを遮断します。ところが攻撃者にサンドボックスから抜け出されてしまえば、その防御はすべて見せかけ。しかもvm2を導入したデベロッパーの目には、何も問題がないように映ります。多くのアプリケーションがvm2に依存している以上、vm2に空いた穴は、その上に構築されたすべてのアプリケーションの穴になるということです。

脆弱性の仕組み

vm2のNodeVMには、サンドボックス内のコードがJavaScriptの組み込みモジュール以外を読み込めるかどうかを制御するrequireオプションがあります。以下は、vm2自身のREADMEの「Quick Examples」に最初に載っている例です。

      const vm = new NodeVM({
  require: { external: true, root: './' },
});

    

external: trueは、サンドボックス内のコードが外部ファイルに対してrequire()を呼び出すことを許可するオプションです。rootはその範囲を安全な1つのフォルダーに限定するためのもの。ところが通常のnpmプロジェクトでは、vm2自身のインストール済みコピーを含む./node_modulesが./の中に入っています。つまりroot: './'は一見すると安全策に見えるものの、実際にはvm2自体を締め出せていません。

lib/resolver-compat.jsにある2つのデフォルト設定が、このすき間を完全なコード実行へと変えてしまいます。

      const {
  external: externalOpt,
  root: rootPaths,
  context = 'host',   // デフォルトは 'host'
  ...
} = options;

isPathAllowed(filename) {
  if (this.rootPaths === undefined) return true;
  // それ以外は、filename が rootPaths の内側にあるかを判定
}

    

require('./node_modules/vm2')は、そのパスがrootの内側にあるためチェックを通過します。さらにcontextのデフォルトが'host'であるため、このファイルはvm2独自のローダーではなく、Nodeの本物の、サンドボックス化されていないrequire()を通じて読み込まれます。

      loadJS(vm, mod, filename) {
  if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename);
  const m = this.hostRequire(filename);   // 本物のNode.js require()
  mod.exports = vm.readonly(m);
}

    

この時点でサンドボックス内のコードが手にしているのは、制限つきでラップされたvm2ではなく、フルの権限を持つ本物そのもの。攻撃者はその本物を使い、自分で決めたルールで2つ目のNodeVMを起動します。

      const real = require('./node_modules/vm2');
const inner = new real.NodeVM({ require: { builtin: ['child_process'], external: false } });
module.exports = inner.run(
  "module.exports = require('child_process').execSync('whoami').toString().trim()",
  'inner.js'
);

    

内側のサンドボックスに、外側のルールは適用されません。攻撃者がchild_processを有効にして自ら作った、まったく新しいサンドボックスだからです。

外側のサンドボックスから直接fsやchild_processに到達しようとすれば、本来どおり失敗します(require('fs')もrequire('child_process')も"Cannot find module"を投げます)。通常のブロックはすべて正常に機能しているわけです。唯一の抜け道が、たまたまvm2自身のパッケージであるファイルをrequireすること。その瞬間、攻撃者が手にしているのはブロックされた偽のコピーではなく、本物になります。

vm2には、同じ結末に至るサンドボックス脱出の不具合が過去に2件あります。nesting: trueを悪用する回避(GHSA-8hg8-63c5-gwmx)と、require.rootのシンボリックリンクを悪用する回避(GHSA-cp6g-6699-wx9c)。どちらの修正も、今回のコードには手が入っていません。過去2件に対処済みのデベロッパーであっても、今回の問題には完全に無防備なままです。

攻撃の連鎖を、順を追って見ていきます。

  1. 前提となる設定。デベロッパーは、vm2のREADME「Quick Examples」に示されたとおりにサンドボックスを作成します。new NodeVM({ require: { external: true, root: './' } }).
  2. 信頼できないコードの実行。vm.run(untrustedCode)が、攻撃者の管理下にあるコードを実行し始めます。
  3. コードがvm2自身をrequireする。require('./node_modules/vm2')が呼び出されます。ここでresolver-compat.jsの中の2点が同時に問題になります。
    • isPathAllowed()がtrueを返す。そのパスがrootの内側にあるためです。
    • contextのデフォルトが'host'であるため、vm2独自のローダーではなくhostRequire()が実行される。結果として、攻撃者のコードはサンドボックス化されたコピーではなく、ラップされていない本物のvm2モジュールを手にします。
  4. 制限のない2つ目のサンドボックスを構築する。その本物のvm2モジュールを使い、独自の設定で新しいNodeVMを作成します。new real.NodeVM({ require: { builtin: ['child_process'] } }).。この2つ目のサンドボックスは、完全に攻撃者の制御下にあります。
  5. 制限なくコードが実行される。inner.run("require('child_process').execSync(...)")が実行され、攻撃者はホスト上で任意のコマンドを実行できるようになります。

修正の内容と、実際のカバー範囲

メンテナーは動作を確認したうえで、GitLabが別途提出していた重複報告(GHSA-w9c4-gw9x-53mq)を本件に統合し、そちらをクローズしました。リリースされた修正はlib/resolver-compat.jsに入っており、サンドボックス内からvm2自身のlib/フォルダーとメインファイルをrequire()する動きをブロックします。これで、require.rootの設定内容にかかわらず、ネストされたVMを使う手口は封じられたかたち。別の不具合であるnesting: trueは、今回の修正では手つかずのままです。

ただし、この修正は見た目よりも適用範囲が狭いのが実情です。require.rootを一切設定せずにrequire.external: trueを有効にした場合、vm2はブロックせず警告を1回出すだけで、コードはそのまま実行されます。

さらに、READMEと同じ設定のまま、vm2自身のパッケージではなくchild_processを露出させている別のファイルを指定すれば、3.11.7でも完全なリモートコード実行が成立します。エラーも警告も出ません。

      // evil-helper/index.js(vm2とは無関係のファイル)
module.exports = require('child_process');

const cp = require('./evil-helper/index.js');
module.exports = cp.execSync('whoami').toString().trim();

    

3.11.7で追加された警告が表示されるのは、rootがまったく指定されていない場合だけです。rootは設定されているものの範囲が広すぎる場合、つまりREADMEの例そのものの状態では、警告は出ないまま。どちらのケースもGitLab側で実際に検証済みです。

補足として、次の2点も押さえておいてください。

  • メンテナーが本件と同時にリリースしたCLIの修正には、別のアドバイザリ(GHSA-jxxv-8r27-vm4p)が割り当てられています。
  • バージョン3.11.7は、無関係な3件の不具合修正も含む統合リリースです。

まとめると、バージョン3.11.7は今回の攻撃経路をしっかり塞いでいます。一方で、厳密に絞り込んだ空のrootフォルダーを使わずにrequire.externalを利用しているアプリケーション全般には、より広いリスクが残ったまま。こちらは規模も難度も大きく、別途取り組む価値のある課題です。

自社の影響範囲を確認する

vm2は長年にわたり、プラグインシステム、コード実行プラットフォーム、CIツールの内部で使われてきました。近年は、AIモデルが書いたコードを実行するAIツールでの採用も増えています。どの指標で見ても利用規模は大きく、npmでは週あたり約125万ダウンロード、月間では500万以上。GitHubのスター数は4,000超、フォーク数は328に達します。

これは、誰かが設定を誤った結果ではありません。vm2自身のREADMEに載っている最初の例そのもの。node_modulesがrootに対してどこに位置するかを自分で確認せずにこの例をコピーしたデベロッパーは、バージョン3.11.7への更新の前後を問わず、require.rootを自ら絞り込まない限りデフォルトで影響を受けます。

vm2が脆弱なままである背景には、次の性質があります。

  • 安全に見えて、実際には安全でないデフォルト設定。root: './'は制限のように読めますが、本当に制限として働くかどうかは、そのフォルダーに他に何が置かれているかで完全に決まります。
  • 警告が添えられていない危険なデフォルト。contextのデフォルトは'host'ですが、その事実はコード中のコメントに埋もれたままです。READMEで太字の警告と専用セクションが与えられているnesting: trueとは対照的といえます。
  • 見落としやすいパターン。過去の不具合もnesting: trueを経由して、同じ「制限のないネストされたVM」という結果に到達していました。その修正も今回の修正も、報告された個別の手口はきちんと塞いでいます。難しいのは、両者に共通する根本原因を一度に塞ぎ切ること。これはvm2の開示履歴に一貫して見られる課題でもあります。2026年に至るまで深刻なサンドボックス脱出の不具合が続いており、いくつかは完全な対処までに複数回のラウンドを要したのも無理はありません。

今、何をすべきか

vm2を利用している場合:まずバージョン3.11.7へ更新してください。ただし、更新だけでは解決しません。require.rootには、サンドボックスが実際に必要とするファイルだけを置いたフォルダーを指定すること。node_modulesや、直接あるいは別のファイル経由でchild_process・fsに到達しうるものは、そこから排除します。contextもデフォルト任せにせず、'sandbox'を明示的に指定してください。さらに、これまでの経緯を踏まえるなら、新規プロジェクトで本当に信頼できないコードを隔離する用途にvm2を選ぶのは避けたほうが賢明。コンテナやプロセス分離のほうが、はるかに強固な壁になります。

サンドボックスツールを開発している場合:「このフォルダーに限定する」という設定の安全性は、そのフォルダーにある最も弱いファイルと同じ水準にしかなりません。何を読み込めるかを決める設定だけでなく、コードがどのように読み込まれるか(サンドボックス経由か、本物か)を変える設定にも、同じだけの注意を払ってください。ライブラリのデフォルト設定は、公式だからという理由で信頼せず、自分で検証すること。そして、手元のテスト攻撃を防げたからといって、その修正が本質的な問題を解決したと決めつけないでください。

開示タイムライン

日付出来事
2026-07-23GitHubの非公開セキュリティアドバイザリ経由でメンテナーに報告
2026-08-18メンテナーが報告を受理
2026-08-18メンテナーの非公開フォークに修正をコミット
2026-08-18非公開フォーク上で、PoCに対する修正の有効性を検証
2026-08-24公開アドバイザリを公表。vm2 3.11.7をリリースし、CVEを申請

報告を受け入れ、同一リリースで修正を届けてくれたvm2のメンテナーに感謝いたします。本記事の内容は、メンテナー自身による修正の説明を土台としたものであり、それに反論するものではありません。

GitLabでできること

GitLab Duo Agent Platformの一部であるGitLab Duoセキュリティ分析エージェントを使えば、同じ問題が自社のコードベースに潜んでいないかを確認できます。まずは「このプロジェクトは、require.externalを有効にしたサンドボックスライブラリ経由で信頼できないコードを実行しているか」といった問いから始めてみてください。

今後に向けて

vm2の経緯が示しているのは、迅速で適切に扱われた修正であっても、より広い設定上のリスクが残りうるという事実です。報告された個別のケースと、その背後にあるパターンは、必ずしも同じものではありません。サンドボックスライブラリのセキュリティ履歴を確認するときに問うべきは、報告が修正されたかどうかだけではなく、その修正がより広いパターンまでカバーしているかどうか。そして、安全だと誰かに伝える前に、実際のコード変更を自分の目で確かめる。この習慣には常に価値があります。

ご意見をお寄せください

このブログ記事を楽しんでいただけましたか?ご質問やフィードバックがあればお知らせください。GitLabコミュニティフォーラムで新しいトピックを作成してあなたの声を届けましょう。

フィードバックを共有する

今すぐ開発をスピードアップ

DevSecOpsに特化したインテリジェントオーケストレーションプラットフォームで実現できることをご確認ください。