Safeguard
Application Security

Why Node.js's vm module is not a security sandbox

Node's own docs warn the vm module isn't a security mechanism — vm2, built on top of it, still shipped two CVSS 9.8 sandbox escapes in 2023.

Safeguard Research Team
Research
6 min read

The Node.js documentation says it plainly, in bold text, on the node:vm reference page: "The node:vm module is not a security mechanism. Do not use it to run untrusted code." That warning is not hedging — it is a direct response to years of developers reaching for vm.createContext() and vm.runInContext() expecting the isolation of a real sandbox and getting something weaker instead. The clearest proof the risk is real, not theoretical, is vm2, a library built on top of Node's vm that was for years the default answer to "how do I run untrusted JavaScript in Node." In a single year, vm2 accumulated a run of critical sandbox-escape CVEs, including two rated CVSS 9.8 — the maximum practical severity — for full remote code execution. Node.js itself has an open tracking issue, nodejs/node #40718, specifically to clarify the real-world risks and legitimate use cases of the vm module, because so many teams misunderstood what it actually provides. This post explains why vm fails as an isolation boundary, what the vm2 CVEs reveal about the failure pattern, and which alternatives — isolated-vm and permission-restricted worker threads — actually hold up.

What does the vm module actually isolate?

The vm module gives code a separate global object, not a separate JavaScript engine context. When you call vm.createContext(), Node creates a new global scope so that variables and functions declared inside it don't leak into your main program's globals — useful for templating engines or configuration evaluation, where the input is trusted but you want clean namespacing. What it does not create is a new V8 isolate. Code running inside a vm context still executes on the same underlying engine, sharing the same built-in prototype chain (Object.prototype, Function.prototype, Array.prototype) as the host process. That shared prototype chain is the entire problem: it is the bridge an attacker walks across to get from "sandboxed" script back into the process that spawned it.

How does a vm escape actually work?

The canonical escape walks the constructor chain. Inside a vm context, any object still has a .constructor property, and Function objects have a .constructor too — which is Function itself, evaluated in the host's scope once you chain far enough. A payload conceptually like this.constructor.constructor('return process')() uses that chain to synthesize a new function in the host's execution context and return its process global, even though process was never explicitly exposed inside the sandboxed context. Once an attacker has process, they have process.mainModule.require, which gives them child_process, fs, and net — the entire Node.js API surface, and with it, arbitrary command execution on the host. No API misuse is required; it's a structural consequence of sharing one JS engine instance between "sandboxed" and host code.

What did the vm2 CVEs actually demonstrate?

vm2 tried to patch over vm's structural gap with proxies and sanitization layers, and for years it was treated as production-safe by projects running untrusted plugin code. That assumption broke down publicly in 2023. CVE-2023-37466 (CVSS 9.8) was a Promise-handler sanitization bypass that let sandboxed code reach host-context objects and execute arbitrary code; CVE-2023-37903 (CVSS 9.8) abused Node's custom inspect function hook to achieve the same outcome from a different angle. These followed an earlier 2023 cluster — CVE-2023-29017, CVE-2023-29199, CVE-2023-30547, and CVE-2023-32314 — all sandbox-escape variants in the same library within a few months of each other. The maintainer announced vm2 was being discontinued shortly after, a rare and unusually candid acknowledgment that the underlying approach could not be patched into safety. The project was quietly revived later, with its security page updated in October 2025 claiming active maintenance of a 3.x line — worth verifying independently before trusting it for untrusted code today.

Why can't proxies and sanitization fix this?

Every vm2-style mitigation was a userland patch applied on top of an engine that fundamentally does not separate trust domains. Proxies can intercept property access you anticipate, and sanitizers can strip patterns you've seen before, but V8's object model has a large surface — getters, Symbol.toPrimitive, error stack traces, Reflect, custom inspection hooks — and each vm2 CVE was a different corner of that surface nobody had covered yet. This is the same structural lesson as trying to sandbox by blocklisting dangerous function names: it only ever plugs the holes someone has already found. Real isolation has to happen below the language level, in the engine or the OS, not through property interception written in the language being sandboxed.

What actually isolates untrusted JavaScript?

isolated-vm is the closest thing Node has to a real answer, because it runs untrusted code in a genuine separate V8 isolate — its own heap, its own copy of built-in prototypes, no shared Function.constructor chain back to the host — with enforceable memory and CPU limits and no built-in filesystem, network, or process access unless you explicitly bridge it in. It's used in production for exactly the workloads vm is unsafe for: Screeps runs persistent, arbitrary player-submitted JavaScript through it, and Algolia uses it to safely execute user-provided extraction code in its Custom Crawler product. Node's worker threads, combined with the experimental Permission Model (--experimental-permission), offer a second, complementary layer — restricting a worker's filesystem, child-process, and worker-spawning access — but this is defense-in-depth, not engine-level isolation, and should be paired with OS-level controls like containers or seccomp when the code is genuinely untrusted rather than merely un-reviewed.

What should teams do about existing vm or vm2 usage?

Audit for the pattern first: any code path where a vm context or vm2 sandbox receives input from outside your trust boundary — user-submitted scripts, plugin code, template expressions built from request data — is a live RCE risk regardless of how carefully the sandboxing code was written. Software composition analysis that resolves your dependency tree to exact versions will catch a pinned vulnerable vm2 release directly, since each CVE above maps to specific patched version ranges; the harder gap is that a plain SCA match won't tell you whether the flagged vm2 import sits on a path an attacker can actually reach with untrusted input, which is where reachability analysis on top of the SCA finding earns its keep. For new work, treat isolated-vm or a fully out-of-process sandbox — a separate container with a locked-down syscall surface — as the baseline for anything executing code you don't control, and never treat node:vm alone as sufficient. The Node.js maintainers have already told you it isn't.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.