-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"The patch for CVE-2026-26956 is twenty lines. Nineteen are comment. The functional change is a single deletion: `localReflectDeleteProperty(WebAssembly, 'JSTag')` inside the sandbox bootstrapper. The comment block above the deletion explains why nothing else works. The tag is a V8 internal. It cannot be reconstructed. There is no way to wrap it, intercept it, or proxy around it. The only available defense is to remove the property from the sandbox global before guest code runs.\n\nvm2 is a sandbox built on two mechanisms. A `Proxy`-based bridge stands between the host realm and the guest realm. An AST transformer rewrites guest source before it reaches the inner `Script`. The transformer's most aggressive rewrite is around `catch`: every JavaScript `CatchClause` the parser sees gets a synthetic first statement that pipes the caught value through `__VM_INTERNAL.handleException(e)`. This is how vm2 strips host-realm objects out of guest exception handlers. The defense invariant document checked into the v3.10.5 release calls this out as Defense Invariant #2: \"All caught exceptions are sanitized.\"\n\nWebAssembly's exception handling proposal is bytecode. `try_table` is an instruction. `throw_ref` is an instruction. Neither passes through acorn.\n\nThe text in the invariant document was aspirational. The substrate it described did not exist.\n\n## The transformer rewrites every catch the parser sees\n\nvm2's `lib/transformer.js` runs an `acorn` parse over guest source at `ecmaVersion: 2022` and walks the AST with `acorn-walk`. At every `CatchClause` node, it injects an assignment as the first statement of the catch body:\n\n```js\n} else if (nodeType === 'CatchClause') {\n const param = node.param;\n if (param) {\n if (param.type === 'Identifier') {\n const name = assertType(param, 'Identifier').name;\n const cBody = assertType(node.body, 'BlockStatement');\n if (cBody.body.length > 0) {\n insertions.push({\n pos: cBody.body[0].start,\n coder: () => `${name}=${INTERNAL_STATE_NAME}.handleException(${name});`\n });\n }\n }\n```\n\nThe contract here is total. A guest writes `catch (e) { use(e) }` and the transformer rewrites it to `catch (e) { e = __VM_INTERNAL.handleException(e); use(e) }`. `handleException` walks the caught value and replaces any host-realm reference with a sandbox-local stand-in. The transformer also handles destructuring catches (`catch ({code})`) by wrapping the whole clause through a temporary, and rewrites parameterless catches through a different branch. There is no `catch` shape the transformer leaves alone.\n\nThe contract is exhaustive over the AST. The AST is the parser's view of the source string. The parser is acorn. acorn parses JavaScript.\n\n## WebAssembly.JSTag is the bridge wasm needed to catch JavaScript exceptions\n\nThe WebAssembly Exception Handling proposal shipped to V8 in 2024. It introduces a new control-flow instruction, `try_table`, with handler clauses that name a `tag`. When wasm throws, the runtime walks outward looking for a `try_table` whose handler tag matches. With a matching handler, the engine takes the labeled branch and the exception payload arrives on the wasm operand stack.\n\nA `tag` is a wasm-level identifier. By default a wasm module's tags are private to its own instance. JavaScript exceptions thrown into wasm propagate through the engine but cannot be caught by `try_table`, because no wasm tag matches them.\n\n`WebAssembly.JSTag` is the exception. It is a JavaScript-side handle to a singleton wasm tag that V8 attaches to JS exceptions when they cross into wasm. A wasm module that imports `WebAssembly.JSTag` and uses it as the handler tag in a `try_table` can catch a JavaScript exception. The caught value is the original JS object, with all its references intact, sitting on the wasm operand stack. The wasm module then calls back into JavaScript with that value as an argument.\n\nThe PoC published with the advisory uses precisely this shape. The wasm module imports `WebAssembly.JSTag` and an imported function `bounce`. It calls a JS-side function `victim` that throws. It catches with `try_table` against `JSTag`. It calls `bounce` with the caught value. The wasm bytecode is roughly:\n\n```\ncall $victim\ntry_table (catch $JSTag $handler)\nend\nbr $exit\n$handler:\n call $bounce\n$exit:\n```\n\nThe JS side wires this up:\n\n```js\nconst mod = new WebAssembly.Module(bytes);\nconst inst = new WebAssembly.Instance(mod, {\n env: {\n victim: () => { throw new Error('x'); },\n bounce: (e) => { hostError = e; },\n JSTag: WebAssembly.JSTag\n }\n});\ninst.exports.run();\nhostError.constructor.constructor(\"return process\")();\n```\n\nReading down the chain. `victim` runs in the guest sandbox and throws an `Error`. The throw propagates out of `victim` and into the wasm frame that called it. The wasm frame has a `try_table` whose handler tag is `JSTag`. The runtime matches. The handler branch executes, with the thrown value on the wasm stack. The handler calls `bounce`. `bounce` is a guest-defined arrow function, but it runs without a `try`/`catch` around the call site, because the wasm `try_table` is the catch. `bounce` stores the value in a closure-bound variable. Control returns out of wasm. The guest reads `hostError`.\n\n`hostError`'s prototype chain reaches `Error.prototype` from the host realm, because the path through the wasm frame routes the exception object's allocation and unwinding through the parent realm's `Error` machinery. `hostError.constructor` is the host-realm `Error`. `hostError.constructor.constructor` is the host-realm `Function`. Calling it builds a function in the host realm, not the sandbox. The function body `return process` resolves `process` against the host's global. The sandbox is escaped.\n\nThe transformer never sees this. There is no `CatchClause` node in the guest AST. The catch is a `try_table` instruction in a `Uint8Array` that the guest passes to `new WebAssembly.Module`. acorn at `ecmaVersion: 2022` does not parse wasm bytecode. `handleException` is never called. The host-realm `Error` arrives in `bounce` unsanitized.\n\n## The fix is a deletion\n\nThe patch lives in `lib/setup-sandbox.js`. It is the bootstrapper that runs once inside the sandbox realm before guest code is allowed to execute. The new block is at the end of the file:\n\n```js\nif (typeof WebAssembly !== 'undefined' && WebAssembly.JSTag !== undefined) {\n localReflectDeleteProperty(WebAssembly, 'JSTag');\n}\n```\n\n`localReflectDeleteProperty` is a captured reference to `Reflect.deleteProperty`, snapshotted at sandbox-init time so guest code cannot replace it. After this runs, `WebAssembly.JSTag` is `undefined` in the sandbox. A wasm module that imports `WebAssembly.JSTag` from `env` and is instantiated by guest code will fail to instantiate, because the import resolves to `undefined`.\n\nThe comment block above the deletion is longer than the deletion. The relevant span:\n\n> The tag is a V8 internal and cannot be reconstructed. There is no JS-level constructor for it. Wrapping `WebAssembly.JSTag` in a Proxy does not help because the wasm runtime reads the tag's identity through the C++ slot, not through the JS property. The only defense is removal.\n\nThis is the second time in vm2's history that a fix has taken the form of \"remove this property because the engine reads it through C++ and the bridge cannot intercept.\" The first was the `resetPromiseSpecies` family on `Promise` (CVE-2026-24118, covered in [the resetPromiseSpecies post](/posts/vm2-cve-2026-24118-resetpromisespecies-has-a-sibling)). This is the second.\n\nA defense that takes the form of property removal is a defense that has given up on the bridge. The bridge is a `Proxy`. A `Proxy` traps property reads done from JavaScript. `WebAssembly.JSTag` is read from C++. The trap is never invoked. Deletion is what is left.\n\n## The invariant names wasm try_table. The fix is not where the invariant says it is.\n\n`docs/ATTACKS.md` was added to the repo in the same release branch as this fix. Commit `408fc85` (\"docs: refactor docs\") landed it as part of v3.10.5 hardening. The document is 1518 lines. It enumerates 28 attack categories and 9 Defense Invariants.\n\nDefense Invariant #2 reads:\n\n> All caught exceptions are sanitized. The transformer rewrites every JavaScript `catch` clause to pipe the caught value through `handleException`. Paths that bypass JS-level catch instrumentation (Wasm `try_table`, host-realm `Promise.then` rejection) are closed at the bridge.\n\n\"Closed at the bridge\" is the load-bearing phrase. The invariant claims that even when a catch happens outside JavaScript, with `try_table` named explicitly, the bridge re-imposes the sanitation contract.\n\nThe fix is not at the bridge. The fix is the deletion of `WebAssembly.JSTag` from the sandbox global, which is not a bridge mechanism. It is the removal of the only API that lets wasm see JavaScript exceptions in the first place. There is no code path in `lib/bridge.js` or `lib/setup-sandbox.js` that intercepts a `try_table` catch and routes the caught value through `handleException`. The fix could not have been written that way: a `try_table` handler runs inside the wasm engine, with the JS exception object already on the wasm stack, with no JS frame in between to intercept.\n\nThe invariant document was added in the same release that shipped the deletion. The text and the deletion are contemporaneous. The text describes a defense the codebase does not contain.\n\nThis is not a documentation lag. It is a category mismatch. The invariant document is structured around what the bridge ought to do. The actual defense is structured around what the sandbox global needs to lack. These are not the same kind of statement.\n\n## vm2's design produces a new attack category every time V8 ships a feature\n\n`docs/ATTACKS.md` lists 28 attack categories. Reading down the list, a pattern. Category 4: `import()` expressions; closed by an AST rewrite. Category 7: `using` declarations; closed by an AST rewrite. Category 11: `Promise.prototype.then` re-entry; closed by snapshotting. Category 14: WASI imports; closed by deletion. Category 17: `WebAssembly.JSTag`; closed by deletion. Category 22: `trace_events` capture; closed by deletion. Category 25: stage-3 JSPI (`WebAssembly.promising`); marked \"monitoring.\"\n\nCategories closed by AST rewrite are categories where the dangerous operation has a JavaScript syntax form. The transformer's pre-execution pass can find them and rewrite them. The CHANGELOG between v3.10.5 and v3.11.2 lists seventeen GHSAs in three months. Most are AST-rewrite categories. The rewrite list is large and grows whenever ECMAScript ships a new keyword.\n\nCategories closed by deletion are categories where the dangerous operation has no JavaScript syntax form. They are properties on the sandbox global whose values are read by the engine through C++ slots that the bridge cannot trap. WASI imports. JSTag. `trace_events`. The deletion list is small and grows whenever V8 ships a new host capability.\n\nThe \"monitoring\" category is JSPI, the JavaScript-Promise Integration proposal that lets wasm suspend on JS promises. It is stage 3. When it ships, it will require either a deletion (remove `WebAssembly.promising` and `WebAssembly.Suspender` from the sandbox global) or a new bridge mechanism that does not exist today. The maintainer's note in `docs/ATTACKS.md` says \"monitoring.\" The README at line 179 (post-fix) says \"new bypasses will likely be discovered as JS evolves.\"\n\nThe pattern across vm2's CVE history is direct. Two posts already in this cluster name it from different angles. [The resetPromiseSpecies post](/posts/vm2-cve-2026-24118-resetpromisespecies-has-a-sibling) names it from V8: certain primitives the engine reads through C++ cannot be wrapped, only deleted, and the fix admits it. [The README-admission post](/posts/vm2-cve-2026-24120-readme-already-admits) names it from the maintainer's own security disclaimer, which concedes that further bypasses are expected. This post names it from the parser's blind spot. Every defense vm2 builds on the AST is a defense against whichever V8 features happen to have JavaScript syntax. Features that ship as bytecode, as host-engine slots, as C++-resident state are outside the parser's vocabulary by construction.\n\nThe shape the fix confesses to has a name in the catalog. `WebAssembly.JSTag` is an unpatchable primitive: the wasm runtime reads its identity through a C++ slot, the bridge cannot trap C++ slot reads, and the only available defense is removal. The recurrence is what makes the design a design debt driver. Each new V8 feature with the same shape produces a new entry in the deletion list, and the deletion list will grow as long as V8 ships new features. The driver is not the bug. The driver is the assumption that an AST walker plus a Proxy bridge can reconstruct the engine's view of its own host capabilities. V8 is a moving target. The shape of the work is structural.\n\nThe advisory is at [advisories/GHSA-ffh4-j6h5-pg66](https://github.com/advisories/GHSA-ffh4-j6h5-pg66). The PoC in the advisory matches the regression test landed in `test/vm.js` at commit `1fbdeff`.","closing_line":"The fix closes JSTag. The next V8 feature whose execution path runs outside the parser will require its own deletion.","hook_md":"The patch for CVE-2026-26956 is twenty lines. Nineteen are comment. The functional change is a single deletion: `localReflectDeleteProperty(WebAssembly, 'JSTag')` inside the sandbox bootstrapper. The comment block above the deletion explains why nothing else works. The tag is a V8 internal. It cannot be reconstructed. There is no way to wrap it, intercept it, or proxy around it. The only available defense is to remove the property from the sandbox global before guest code runs.\n\nvm2 is a sandbox built on two mechanisms. A `Proxy`-based bridge stands between the host realm and the guest realm. An AST transformer rewrites guest source before it reaches the inner `Script`. The transformer's most aggressive rewrite is around `catch`: every JavaScript `CatchClause` the parser sees gets a synthetic first statement that pipes the caught value through `__VM_INTERNAL.handleException(e)`. This is how vm2 strips host-realm objects out of guest exception handlers. The defense invariant document checked into the v3.10.5 release calls this out as Defense Invariant #2: \"All caught exceptions are sanitized.\"\n\nWebAssembly's exception handling proposal is bytecode. `try_table` is an instruction. `throw_ref` is an instruction. Neither passes through acorn.\n\nThe text in the invariant document was aspirational. The substrate it described did not exist.","post_id":200,"slug":"vm2-jstag-trytable-is-bytecode","title":"CVE-2026-26956: vm2's Transformer Rewrites Every catch It Parses. WebAssembly's try_table Is Bytecode.","type":"initial","unreadable_sentence":"The text in the invariant document was aspirational. The substrate it described did not exist."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCandifQAKCRDeZjl4jgkQ JsBIAQC8CBl51tHD6OQysYrePXrEjtkMV+SaNp5Az2iQCbE0AAEAyDUNcqTOiMQU T97p5r0Es7E9u4osFK2Zmocbu9ndewA= =EZKd -----END PGP SIGNATURE-----