vm2 Sandbox Documentation

repository·main·Indexed 26 days ago

https://github.com/patriksimek/vm2

vm2 is a sandbox for running untrusted JavaScript code within a Node.js process using Proxies and whitelisted built-in modules. It provides the VM class for synchronous sandboxing and NodeVM for environments requiring module support via require(). The library includes features for precompiling scripts with VMScript, memory allocation capping via bufferAllocLimit, and deep-freezing objects to prevent modification.

Tokens
30.9K
Snippets
42
Records
116
Agent score
87%

What's inside vm2

  1. Understand the Sandbox Escape Progression

    main

    Most sandbox escape attacks follow a specific chain of escalation. To defend against these, security implementations must block at least one step in the following progression:

    1. Access Sandbox Object
    2. Access Host Constructor (Function)
    3. Execute Code Evaluation (e.g., return process)
    4. Access Host Modules (e.g., process.mainModule.require)
    5. Require Host Built-ins (e.g., require("child_process"))
    6. Execute Arbitrary Commands (e.g., execSync("arbitrary command"))

    vm2 uses a bridge to block constructor access and a transformer to block catch-clause access to prevent this chain from completing.

  2. Understand Bridge Internal-State Leak via Sandbox-Realm Array Setter

    main

    This attack category involves reaching bridge-internal containers (lists, maps, or saved-state records used for bookkeeping) from sandbox-realm closures. If an attacker can manipulate the intrinsics (like Array.prototype or Object.prototype) within the sandbox, they can intercept the bridge's own internal writes.

    When the bridge performs an ordinary index assignment like obj[obj.length] = value, V8's [[Set]] mechanism walks the prototype chain if no own slot exists at that index. An attacker-installed setter on Array.prototype[N] will fire during the bridge's internal operations, potentially allowing the attacker to observe or mutate the bridge's raw state, leading to Sandbox Escape or Remote Code Execution (RCE).

  3. Security Disclaimer and Limitations

    main

    Important Security Warning

    vm2 attempts to sandbox untrusted JavaScript code within the same Node.js process as your application using Proxies. Because JavaScript is highly dynamic, building an airtight in-process sandbox is extremely difficult.

    Key Security Considerations:

    • Bypasses: Researchers continuously discover new ways to escape the sandbox. Always check security advisories and keep vm2 updated.
    • Defense in Depth: vm2 should not be your only line of defense. Combine it with network isolation, filesystem restrictions, and resource limits.
    • When to use alternatives: If you require true process or hardware-level isolation (e.g., for arbitrary user submissions), consider isolated-vm, separate processes/workers, containers (Docker/gVisor), or managed services (AWS Lambda).
  4. Understand vm2 Sandbox Escape Fundamentals

    main

    vm2 operates by running untrusted code inside a V8 context created by Node.js's vm module. Because the host and sandbox share the same V8 isolate, they execute on the same thread and in the same heap. There is no OS-level process boundary or memory isolation.

    Key Architectural Concepts

    • Realm Separation: The sandbox uses its own set of global intrinsics (e.g., Object, Function, Array). A 'host-realm object' is any object whose prototype chain leads to the host's intrinsics. The bridge's primary role is to ensure sandbox code only interacts with proxied wrappers of host objects.
    • The Bridge Proxy Model: lib/bridge.js uses two WeakMaps (mappingThisToOther and mappingOtherToThis) to manage the relationship between host objects and their sandbox proxy wrappers. This ensures identity preservation while sanitizing property access.
    • V8 Internal Algorithms: Many V8 specification algorithms (like ArraySpeciesCreate or FormatStackTrace) are implemented in C++. These C++ algorithms often bypass JavaScript proxy traps entirely, reading raw object properties directly. This is a primary source of sandbox escapes.
    • The Transformer: lib/transformer.js uses Acorn to instrument sandbox code. It wraps catch blocks to call handleException(e) (sanitizing caught errors) and instruments with statements to prevent scope chain manipulation.
    • The Escape Chain: Most escapes follow a pattern of: Sandbox Object $\rightarrow$ Host Constructor (Function) $\rightarrow$ Code Evaluation $\rightarrow$ Accessing Node.js globals (e.g., process) $\rightarrow$ RCE.
  5. Avoid the NodeVM `nesting` configuration trap

    main

    When using NodeVM, enabling the nesting option can inadvertently expose the vm2 package to sandbox code, allowing an attacker to construct an inner NodeVM with full host access (RCE).

    The Vulnerability: If nesting is truthy and the require option is not an explicit configuration object (e.g., it is omitted, false, null, or a primitive), vm2 is injected into the sandbox via a NESTING_OVERRIDE. An attacker can then use require('vm2') to spawn a new NodeVM with any host modules they choose (like child_process).

    Safe Configuration: To use nesting safely, you must provide an explicit require configuration object. This makes the security trade-off explicit at the call site.

    Unsafe Configurations (will now throw a VMError):

    • { nesting: true } (omitted require)
    • { nesting: true, require: false }
    • { nesting: true, require: undefined }
    • { nesting: true, require: 'some-string' }
    • { nesting: true, require: 1 }

    Safe Configurations:

    • { nesting: true, require: {} }
    • { nesting: true, require: { builtin: ['fs'] } }
    • { nesting: true, require: myCustomResolverInstance }
  6. Load modules by relative path in NodeVM

    main

    To allow require() to resolve modules via relative paths, you must provide the full path of the script as the second argument to the run method when the script is a string. This also ensures filenames appear correctly in stack traces.

    Alternatively, if using a VMScript object, provide the filename in the VMScript constructor.

    // Using a string script
    vm.run('require("foobar")', '/data/myvmscript.js');
    
    // Using a VMScript object
    const script = new VMScript('require("foobar")', { filename: '/data/myvmscript.js' });
    vm.run(script);
  7. Understand the Bridge `set` Trap Inherited-Receiver Write-Through attack

    main

    This attack pattern (Category 32) exploits a vulnerability where writes to objects inheriting from a bridge proxy are incorrectly forwarded to the host-realm object instead of being installed on the inheriting object (the Receiver).

    Attack Mechanism:

    1. The sandbox obtains a reference to a host-realm object.
    2. The sandbox creates an inheriting object: const child = Object.create(hostObj).
    3. The sandbox writes a property: child[key] = value.
    4. Due to the bug, the BaseHandler.set trap ignores the Receiver (which is child) and writes directly to hostObj on the host realm.
    5. The host-realm object is now polluted with sandbox-controlled data, which can lead to Remote Code Execution (RCE) if the host consumes the polluted slot (e.g., via util.promisify).

    Vulnerability Variants:

    • Object.create(hostObj)[key] = value
    • Object.create(hostObj).key = value
    • Reflect.set(hostObj, key, value, sandboxObj)
    • Object.create(Object.create(hostObj)).key = value
    • Object.assign(Object.create(hostObj), { key: value })
    // (advisory GHSA-c4cf-2hgv-2qv6)
    const util = require('util');
    const { VM } = require('vm2');
    
    const hostFn = function api(cb) { cb(null, 'real-data'); };
    const vm = new VM();
    vm.sandbox.hostFn = hostFn;
    
    vm.run(`
      const kCustom = Symbol.for('nodejs.util.promisify.custom');
      const child = Object.create(hostFn);
      child[kCustom] = function () {
        return Promise.resolve('HIJACKED-VIA-RECEIVER-BUG');
      };
    `);
    
    // Host side:
    util.promisify(hostFn)().then(console.log);   // → "HIJACKED-VIA-RECEIVER-BUG"
  8. Mitigate Symbol Extraction via Bridge and Sandbox defenses

    main

    To prevent symbol extraction and subsequent escapes, vm2 employs a multi-layer defense strategy:

    1. Sandbox-side Defense: Overrides Symbol.for, Object.getOwnPropertySymbols, Reflect.ownKeys, Object.getOwnPropertyDescriptors, and Object.assign to filter or replace dangerous symbols with sandbox-local equivalents.
    2. Bridge-side Defense (Ultimate Enforcement):
      • Symbol-boundary filter: The bridge's primitive-value chokepoints (thisFromOtherWithFactory, thisEnsureThis, and thisFromOtherForThrow) check isDangerousCrossRealmSymbol(other) and return undefined for dangerous symbols. This prevents dangerous symbols from ever reaching sandbox code.
      • Write-trap symbol guard: The bridge's write traps (set, defineProperty, deleteProperty) check isDangerousCrossRealmSymbol(key) and throw VMError(OPNA) if a dangerous symbol is used, preventing attackers from installing host-side hooks.
      • Structural identity collapse: At bridge initialization, well-known prototypes (e.g., Object, Array, Promise, and error classes) are mapped to their sandbox-realm equivalents to prevent prototype walking from surfacing host built-ins.
      • Pre-wrap container scrub: The apply and construct traps use stripDangerousSymbolsFromHostResult(ret) to remove dangerous symbols from host arrays or object descriptors before they are returned to the sandbox.
      • nodejs. prefix denial: The Symbol.for override intercepts the entire nodejs. namespace, mapping any key starting with nodejs. to a sandbox-local symbol.
  9. Address Residual Async Rejection Vulnerabilities

    main

    As of version 3.10.6, certain async patterns bypass localPromise because V8 creates their rejection promises via the realm's intrinsic globalPromise rather than localPromise. This allows payloads to terminate the host process.

    Vulnerable patterns include:

    • async function bodies that throw errors (e.g., with Symbol-named .name).
    • async function* (async generators) that throw on .next().
    • await using (AsyncDisposableStack) with throwing Symbol.asyncDispose.

    Recommended Mitigation for Embedders: Since vm2 cannot intercept these via localPromise, you must install a host-side process.on('unhandledRejection', ...) handler to filter or swallow sandbox-originated rejections. Refer to the README 'Hardening recommendations' for specific code patterns.

  10. Avoid process-wide observability builtins in NodeVM configuration

    main

    When configuring NodeVM, avoid allowing process-wide observability modules via the builtin allowlist (e.g., using the '*' wildcard). These modules can leak sensitive host process data, such as HTTP headers, async context (user/auth IDs), performance marks, and the entire V8 heap, even if they are wrapped in a read-only proxy.

    To prevent data exfiltration, ensure these modules are not reachable from the sandbox. If you need sandbox-local timing or async context, you must provide a custom, controlled shim/wrapper under the same name instead of allowing the default host-passthrough loader.

  11. Mitigate Host prepareStackTrace Fallback attacks

    main

    vm2 employs a four-layer defense strategy to prevent the escape described in Attack Category 19:

    1. defaultSandboxPrepareStackTrace: The sandbox always provides a safe prepareStackTrace at bootstrap. This prevents V8 from ever falling back to Node's host-side prepareStackTraceCallback. The property setter also prevents user code from overriding it with undefined or null.
    2. Host Array constructor neutering: The bridge proxy's .constructor trap on host arrays now returns the sandbox Array instead. This ensures ha.fromAsync(...) returns a sandbox Promise that is subject to handleException wrapping.
    3. handleException recursion: handleException now recursively sanitizes errors within AggregateError.errors[] and SuppressedError.error/.suppressed to ensure host-realm errors are caught.
    4. Bridge-level Promise-boundary sanitizer: The bridge apply trap identifies calls to host Promise.prototype.{then,catch,finally} and wraps sandbox-supplied callbacks with a sanitizing closure. This ensures that even if a sandbox code chains methods on a host-realm promise, the callbacks are piped through handleException or ensureThis before execution.
  12. Mitigate WebAssembly JSTag Exception Catch attacks

    main

    WebAssembly exception handling (try_table/catch) using WebAssembly.JSTag (available in Node 23+) can bypass JavaScript-level sanitization. Because the vm2 transformer only instruments JavaScript catch clauses, exceptions caught within WebAssembly bytecode are invisible to the transformer and can return raw, unsanitized host errors to the sandbox.

    Mitigation: vm2 deletes WebAssembly.JSTag from the sandbox during initialization in setup-sandbox.js. Without this tag, WebAssembly cannot catch JavaScript exceptions, preventing the escape.

    Detection Rules for Security Audits:

    • Accessing WebAssembly.JSTag.
    • Creating a WebAssembly.Instance where the import object contains a tag: property.
    • Wasm modules containing try_table/catch instructions.
    • Wasm module exports returning externref values combined with the Error Generation Primitive.
    // Example of the attack pattern to watch for:
    const err = new Error("x");
    err.name = Symbol();
    
    // Wasm module designed to catch the host error via JSTag
    const wasm = new Uint8Array([...]); 
    
    const instance = new WebAssembly.Instance(
        new WebAssembly.Module(wasm),
        { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } }
    );
    
    const hostError = instance.exports.catch_error();
    const p = hostError.constructor.constructor("return process")();
    p.mainModule.require('child_process').execSync('...');