//nefariousplan

CVE-2026-26956: vm2's Transformer Rewrites Every catch It Parses. WebAssembly's try_table Is Bytecode.

patterns

cve

proof of concept

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.

vm2 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."

WebAssembly's exception handling proposal is bytecode. try_table is an instruction. throw_ref is an instruction. Neither passes through acorn.

The text in the invariant document was aspirational. The substrate it described did not exist.

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.

vm2 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."

WebAssembly's exception handling proposal is bytecode. try_table is an instruction. throw_ref is an instruction. Neither passes through acorn.

The text in the invariant document was aspirational. The substrate it described did not exist.

The transformer rewrites every catch the parser sees

vm2'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:

} else if (nodeType === 'CatchClause') {
    const param = node.param;
    if (param) {
        if (param.type === 'Identifier') {
            const name = assertType(param, 'Identifier').name;
            const cBody = assertType(node.body, 'BlockStatement');
            if (cBody.body.length > 0) {
                insertions.push({
                    pos: cBody.body[0].start,
                    coder: () => `${name}=${INTERNAL_STATE_NAME}.handleException(${name});`
                });
            }
        }

The 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.

The contract is exhaustive over the AST. The AST is the parser's view of the source string. The parser is acorn. acorn parses JavaScript.

WebAssembly.JSTag is the bridge wasm needed to catch JavaScript exceptions

The 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.

A 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.

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.

The 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:

call $victim
try_table (catch $JSTag $handler)
end
br $exit
$handler:
  call $bounce
$exit:

The JS side wires this up:

const mod = new WebAssembly.Module(bytes);
const inst = new WebAssembly.Instance(mod, {
    env: {
        victim: () => { throw new Error('x'); },
        bounce: (e) => { hostError = e; },
        JSTag: WebAssembly.JSTag
    }
});
inst.exports.run();
hostError.constructor.constructor("return process")();

Reading 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.

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.

The 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.

The fix is a deletion

The 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:

if (typeof WebAssembly !== 'undefined' && WebAssembly.JSTag !== undefined) {
    localReflectDeleteProperty(WebAssembly, 'JSTag');
}

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.

The comment block above the deletion is longer than the deletion. The relevant span:

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.

This 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). This is the second.

A 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.

The invariant names wasm try_table. The fix is not where the invariant says it is.

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.

Defense Invariant #2 reads:

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.

"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.

The 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.

The 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.

This 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.

vm2's design produces a new attack category every time V8 ships a feature

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."

Categories 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.

Categories 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.

The "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."

The pattern across vm2's CVE history is direct. Two posts already in this cluster name it from different angles. The resetPromiseSpecies post 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 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.

The 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.

The advisory is at advisories/GHSA-ffh4-j6h5-pg66. The PoC in the advisory matches the regression test landed in test/vm.js at commit 1fbdeff.

The fix closes JSTag. The next V8 feature whose execution path runs outside the parser will require its own deletion.