-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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\")()`.\n\nThe published PoC's exploit loop is not that chain. The chain is four lines. The PoC's exploit loop is a binary search.\n\n## The PoC is a tool, not a probe\n\nThe exploit body, verbatim from the advisory:\n\n```javascript\nconst {VM} = require(\"vm2\");\nconst vm = new VM();\nvm.run(`\nclass E extends Error {}\nfunction so(d) {\n if (d > 0) so(d-1);\n const e = new E();\n e.stack;\n throw e;\n}\nasync function* helper() {\n yield* {\n [Symbol.asyncIterator]: () => ({\n next: v => ({value: v, done: false})\n })\n };\n}\nasync function doCatch(f) {\n const i = helper();\n await i.next();\n const v = await i.return({then(r) { f(); r(); }});\n return v.value;\n}\n(async function f() {\n let min = 0;\n let max = 10000000;\n while (min < max) {\n const mid = (min+max) >> 1;\n const e = await doCatch(() => so(mid));\n if (e.name === \"RangeError\" && !(e instanceof RangeError)) {\n e.constructor.constructor(\"return process\")()\n .mainModule.require('child_process')\n .execSync('touch pwned');\n return;\n }\n if (e instanceof E) {\n min = mid + 1;\n } else {\n max = mid;\n }\n }\n})();\n`);\n```\n\n`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.\n\nThe PoC author does not know that number. They bisect for it.\n\nThree 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.\n\nThe 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.\n\nThe 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.\n\nvm2 is a sandbox. Its job is to run caller-supplied JavaScript safely. A PoC of this shape is the [detector is the target](/patterns/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.\n\n## yield* + return(thenable) crosses into V8 twice\n\nThe 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.\"\n\nEach clause maps to an ES2018 spec section.\n\n`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.\n\nThe 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.\n\nV8'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: , done: ?}` and `v.value` hands the host RangeError to sandbox code.\n\nvm2'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.\n\nThis 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](/posts/vm2-cve-2026-24118-resetpromisespecies-has-a-sibling)). `WebAssembly.JSTag`'s identity is in a C++ slot ([CVE-2026-26956](/posts/vm2-jstag-trytable-is-bytecode)). `SuppressedError`'s construction inside `DisposableStack.dispose` is in C++ ([CVE-2026-26332](/posts/vm2-suppressederror-carries-what-v8-threw)). Now `%AsyncGeneratorPrototype%`'s thenable-resolution path on `.return` is in C++.\n\n## The patch wraps `%AsyncGeneratorPrototype%`\n\nThe fix shipped on May 11, 2026, in commit `093494c`, vm2 v3.11.3. The CHANGELOG entry names the surface and the operation:\n\n> Wrap `%AsyncGeneratorPrototype%.next`, `.return`, `.throw` to sanitize returned values and replace thenable arguments with sandbox-realm wrappers.\n\nTwo 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.\n\nBoth 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:\n\n| Commit | Date | Defense | Closes |\n|--------|------|---------|--------|\n| `26168e6` | 2023-09 | `resetPromiseSpecies` | `Promise.prototype.then`'s C++ species lookup |\n| `1fbdeff` | 2026-02 | Delete `WebAssembly.JSTag` | wasm `try_table`'s C++ tag-identity read |\n| `a6cd917` | 2026-02 | `handleException` sub-error recursion | `SuppressedError.suppressed` reaching from C++ disposal |\n| `f9b700b` | 2026-05 | `neutralizeArraySpeciesBatch` | `ArraySpeciesCreate`'s C++ constructor read on every host-array argument |\n| `093494c` | 2026-05 | `%AsyncGeneratorPrototype%` wrap | thenable-arg resolution in C++ during `.next/.return/.throw` |\n\nThe 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.\n\n## Async generators shipped in 2018. The wrap list named them in 2026.\n\nThe 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.\"\n\nThat 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\")()`.\n\nvm2 is a clean instance of the [design-debt-driver](/patterns/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.\n\nThe [README already admits this is unbounded](/posts/vm2-cve-2026-24120-readme-already-admits). 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.\n\nThis is the [unpatchable primitive](/patterns/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.\n\nPoC: [GHSA-248r-7h7q-cr24](https://github.com/advisories/GHSA-248r-7h7q-cr24). Patch: [`093494c`](https://github.com/patriksimek/vm2/commit/093494c) in vm2 v3.11.3.","closing_line":"AsyncGenerator shipped in V8 in 2018. The wrap list named it eight years later. The list is paced by researchers, not by V8.","hook_md":"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\")()`.\n\nThe published PoC's exploit loop is not that chain. The chain is four lines. The PoC's exploit loop is a binary search.","post_id":290,"slug":"vm2-cve-2026-45411-async-generator-bisects-host-rangeerror","title":"CVE-2026-45411: vm2's Async Generator Escape Bisects the Stack for a Host RangeError","type":"initial","unreadable_sentence":"`e.name === \"RangeError\" && !(e instanceof RangeError)` is true when, and only when, the captured exception was constructed by V8 in the parent isolate."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCat+H0wAKCRDeZjl4jgkQ Jj4dAP4wY0ipmcM1XtcsAsvgIuh5nZxTCtvjwUOdzA3F7FTlLwEAyTMj3fvMpsH7 gc3lLzfJG+Xjnk+0Z+6FwKSl11RLqg4= =AMQT -----END PGP SIGNATURE-----