Security Guide
MCP server WebAssembly Memory security — cross-module aliasing, memory.grow() DoS, Import Object injection, Spectre via shared memory, SSRF staging
WebAssembly's linear memory model is designed for performance: a flat, contiguous byte array that Wasm modules read and write directly, without the overhead of JavaScript object heap allocation. MCP servers increasingly use Wasm for performance-sensitive operations — cryptographic parsing, image processing, natural language tokenization. The security implications of Wasm's memory model are distinct from JavaScript heap security: two Wasm modules sharing a WebAssembly.Memory instance have full read-write access to each other's data regions unless the module code explicitly enforces access boundaries; memory.grow() called with attacker-controlled values exhausts the page heap and crashes the process; the Import Object passed at instantiation time can be poisoned to replace legitimate host functions with attacker-controlled implementations; a shared Wasm Memory with a SharedArrayBuffer backing re-enables Spectre-class CPU cache timing attacks that browsers tried to close; and Wasm linear memory enables SSRF payload assembly character-by-character in memory to evade string-level static analysis tools.
Cross-module memory aliasing — shared WebAssembly.Memory between modules
WebAssembly modules do not have a private heap by default. When two Wasm modules are instantiated with the same WebAssembly.Memory object in their import object, they share the entire linear memory address space. Module A can write to byte offset 0 through the end of the memory; Module B with the same memory object can read the same bytes at the same offsets. There is no access control at the Wasm spec level — the isolation boundary is only the module boundary, not the memory boundary.
// Cross-module memory aliasing — two modules share private data
// Create a shared memory instance
const sharedMem = new WebAssembly.Memory({ initial: 64, maximum: 128 });
// Module A: legitimate MCP tool component
// Stores sensitive user data at offset 0
const moduleA = await WebAssembly.instantiate(trustedWasmBytes, {
env: { memory: sharedMem }
});
// moduleA stores API key at bytes [0..32] in sharedMem
// Module B: attacker-controlled plugin component
// Instantiated with the SAME memory object
const moduleB = await WebAssembly.instantiate(maliciousWasmBytes, {
env: { memory: sharedMem }
});
// moduleB reads bytes [0..32] from sharedMem — gets the API key
// Safe design: each module gets its own private Memory instance
const privateMemA = new WebAssembly.Memory({ initial: 64 });
const privateMemB = new WebAssembly.Memory({ initial: 64 });
// Now module A and B cannot alias each other's data
Memory sharing is sometimes intentional. Wasm threading (via SharedArrayBuffer-backed memories) requires shared memory between workers. The defense is not to ban shared memory but to enforce that only explicitly exported functions mediate inter-module data access, and that sensitive data regions are never placed in the shared region. Use separate WebAssembly.Memory instances for modules from different trust levels.
memory.grow() as denial-of-service via unbounded allocation
The memory.grow(delta) instruction grows the Wasm linear memory by delta pages (1 page = 64 KiB). It returns the previous size in pages on success, or -1 if the grow fails. There is no built-in rate limit or budget tracking on memory growth — if an MCP tool accepts user-controlled input that determines how much memory a Wasm computation allocates, an attacker can provide an extreme value that causes the browser tab or Node process to exhaust available heap memory and crash.
// memory.grow() DoS — attacker-controlled allocation size
// Wasm module exported function:
// (func $allocate (param $size i32) (result i32)
// (memory.grow (i32.div_u (local.get $size) (i32.const 65536)))
// )
// On the JavaScript side:
const instance = await WebAssembly.instantiateStreaming(fetch('/tool.wasm'));
// MCP tool accepts user-specified buffer size for processing
async function processUserData(userInput) {
const sizeBytes = parseInt(userInput.size);
// If userInput.size = 4294967295 (2^32 - 1), memory.grow requests 65536 pages = 4 GiB
// Browser enforces maximum from Memory constructor, but if max was not set:
const result = instance.exports.allocate(sizeBytes); // may crash tab
return result;
}
// Defense: always set maximum pages in WebAssembly.Memory constructor
const safeMem = new WebAssembly.Memory({
initial: 16,
maximum: 256 // hard cap at 256 pages = 16 MiB
});
// Validate size before passing to Wasm: cap at application maximum
const MAX_ALLOCATION = 1024 * 1024; // 1 MiB application limit
const safeSize = Math.min(sizeBytes, MAX_ALLOCATION);
Import Object injection — replacing host functions at instantiation time
WebAssembly modules declare their dependencies as imports: functions, memories, tables, and globals that the host JavaScript environment must provide at instantiation time. These imports are passed via the import object argument to WebAssembly.instantiate(). If the import object is constructed from attacker-influenced data — for example, if an MCP tool's configuration file specifies which host functions to expose, or if an eval-adjacent pattern builds the import object from tool output — an attacker can substitute malicious implementations for legitimate host functions.
// Import Object injection — replacing a legitimate host function
// Legitimate import object:
const legitimateImports = {
env: {
memory: new WebAssembly.Memory({ initial: 64 }),
log: (offset, length) => {
// Read string from Wasm memory and log it safely
const bytes = new Uint8Array(memory.buffer, offset, length);
console.log(new TextDecoder().decode(bytes));
},
fetch_url: (urlOffset, urlLen) => {
// Legitimate SSRF-protected fetch — validates URL before requesting
const url = readString(memory, urlOffset, urlLen);
if (!isAllowedOrigin(url)) throw new Error('blocked');
return performFetch(url);
}
}
};
// Attacker-controlled import object (if tool config specifies imports):
const injectedImports = {
env: {
memory: legitimateImports.env.memory, // aliased — shared memory attack too
log: legitimateImports.env.log, // same
fetch_url: (urlOffset, urlLen) => {
// Bypass: no SSRF check — fetch any URL including internal services
const url = readString(memory, urlOffset, urlLen);
return performFetch(url); // SSRF without validation
}
}
};
// Defense: never construct import objects from tool-provided data
// Pin import objects in trusted host code; validate Wasm module hash before instantiation
SharedArrayBuffer + WebAssembly.Memory — Spectre-class timing attacks
Browser vendors introduced two countermeasures against Spectre: coarsening performance.now() to 100µs resolution, and requiring COOP/COEP headers before allowing SharedArrayBuffer access. A Wasm module instantiated with a SharedArrayBuffer-backed memory defeats the timing countermeasures: the Wasm computation loop runs in the same thread but at native instruction speed, and the memory's shared nature enables Atomics-based inter-thread signaling for timing precision approaching nanoseconds. This re-enables the Flush+Reload and Prime+Probe cache-timing techniques that Spectre used to extract data across isolation boundaries.
// Spectre-class timing via SharedArrayBuffer-backed Wasm Memory
// Requires COOP: same-origin + COEP: require-corp headers on the page
// Check if high-precision timing is available (SharedArrayBuffer enabled)
if (typeof SharedArrayBuffer !== 'undefined') {
// Create SharedArrayBuffer-backed Wasm memory
const sab = new SharedArrayBuffer(65536);
const sharedMem = new WebAssembly.Memory({
initial: 1,
maximum: 1,
shared: true // backed by SharedArrayBuffer
});
// Spawn Worker with shared memory — Wasm compute loop in worker
const worker = new Worker('timing-worker.js');
worker.postMessage({ mem: sharedMem }, [sharedMem]);
// Worker busy-loops incrementing a counter in shared memory
// Main thread reads counter value before and after a speculative access
// The delta gives sub-microsecond timing resolution
// — sufficient for Flush+Reload L3 cache timing on modern CPUs
// Result: ability to measure cache line access times even though
// performance.now() is coarsened to 100µs
// The COOP/COEP headers that enable SharedArrayBuffer also enable this attack
}
COOP/COEP is a double-edged sword. The cross-origin isolation headers required for SharedArrayBuffer also re-enable high-precision timing for all same-origin code on the page — including any Wasm module with shared memory access. MCP clients that set COOP/COEP for legitimate reasons (Wasm threading, AudioWorklet) must also audit every Wasm module loaded in that context for timing attack capability.
Wasm linear memory as SSRF payload staging area
String-level static analysis tools — including many SAST scanners — detect SSRF patterns by looking for URL strings like http://169.254.169.254/ (AWS metadata endpoint), http://localhost:, or file:// in JavaScript source code. An MCP tool author can evade this detection by assembling the target URL character-by-character in Wasm linear memory, then passing the assembled bytes to a host-imported fetch function. The JavaScript source contains no readable URL strings; the Wasm binary contains the URL bytes as a sequence of i32.const store instructions at specific memory offsets.
// SSRF payload assembly in Wasm memory — evades string-level static analysis
// Wasm module (WAT source, compiled to .wasm):
// (func $build_url (param $mem_ptr i32)
// ;; Assemble "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
// ;; character by character — no URL string visible in JS source or JS analysis
// (i32.store8 (local.get $mem_ptr) (i32.const 104)) ;; 'h'
// (i32.store8 (i32.add (local.get $mem_ptr) (i32.const 1)) (i32.const 116)) ;; 't'
// (i32.store8 (i32.add (local.get $mem_ptr) (i32.const 2)) (i32.const 116)) ;; 't'
// ... (continues for each character)
// ;; After construction: call host-imported fetch(ptr, len) with the assembled URL
// )
// In JavaScript:
const instance = await WebAssembly.instantiateStreaming(fetch('/tool.wasm'), {
env: {
memory: mem,
fetch_url: (ptr, len) => {
// Host function receives pointer to assembled URL in Wasm memory
const url = new TextDecoder().decode(new Uint8Array(mem.buffer, ptr, len));
// url is now "http://169.254.169.254/latest/meta-data/..." — SSRF target
// Static analysis of THIS JavaScript file sees no suspicious URL string
return fetch(url); // SSRF succeeds
}
}
});
instance.exports.build_url(0); // builds URL at memory offset 0
// Defense: validate URLs in host functions regardless of how they were constructed
// Wasm binary analysis: scan for suspicious byte sequences in .wasm bytecode
// SkillAudit performs binary analysis on uploaded .wasm files, not just JS source
| Attack class | Wasm mechanism | Defense |
|---|---|---|
| Cross-module aliasing | Shared WebAssembly.Memory instance — modules read each other's data | Private Memory per trust level; never share memory across trust boundaries |
| memory.grow() DoS | Attacker-controlled grow delta exhausts page heap | Set maximum pages in Memory constructor; validate size input before Wasm call |
| Import Object injection | Attacker-provided import replaces validated host function | Hardcode import objects in trusted JS; validate Wasm module hash pre-instantiation |
| Spectre via shared Wasm memory | SAB-backed Memory + Worker busy-loop = nanosecond timer | Audit all Wasm modules in COOP/COEP contexts; restrict SharedArrayBuffer usage |
| SSRF payload in Wasm memory | URL assembled character-by-character bypasses string-level SAST | Validate URLs in host functions at runtime; binary-analyze .wasm payloads |
SkillAudit findings for Wasm Memory misuse
WebAssembly.Memory object to both a trusted module and an untrusted plugin or user-supplied module. Any data written by the trusted module to any memory offset is readable by the untrusted module. Grade impact: −28.
memory.grow() with a value derived from MCP tool input, and no maximum pages parameter was set when the memory was created. Grade impact: −18.
WebAssembly.instantiate() includes function references or values derived from external MCP tool data. An attacker can substitute alternative implementations for any imported function. Grade impact: −20.
fetch() with a URL read from Wasm linear memory without checking it against an allowlist. Any URL the Wasm code assembles in memory — including internal services and cloud metadata endpoints — will be requested. Grade impact: −22.
Audit your MCP server for Wasm Memory risks
SkillAudit analyzes both JavaScript source and WebAssembly binary payloads for memory aliasing patterns, unbounded grow calls, import object injection, and SSRF payload assembly. Paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →