MCP Server Security
MCP server WebAssembly security — WASM module sandboxing, WASI permission model, and memory isolation
WebAssembly modules in MCP servers provide strong memory isolation and syscall sandboxing by default — but only when WASI capabilities are configured correctly. A single misconfigured preopened directory grants the WASM module unrestricted host filesystem access.
Why MCP servers use WebAssembly
Some MCP servers load user-supplied plugins or run potentially untrusted analysis code — image processors, document parsers, code evaluators, data transformers. Running this code directly in the Node.js process means a plugin vulnerability becomes a full server compromise. WebAssembly provides an appealing alternative: run the plugin in a sandbox with linear memory isolation, no access to the host's JavaScript heap, and a syscall interface (WASI) that is capability-based by default.
The runtime for WASM in Node.js is the WASI module (experimental) or an external runtime like Wasmtime or Wasmer via their Node.js bindings. Each provides a different API for configuring which host capabilities the WASM module can access.
Attack 1: WASI preopened directory grants full filesystem access
The WASI capability model works by explicitly granting the WASM module access to host resources via "preopened" handles. A preopened directory gives the WASM module a real file descriptor with which it can walk the directory tree.
// DANGEROUS: preopens root — gives WASM module full filesystem access
const wasi = new WASI({
version: 'preview1',
args: [],
env: process.env, // also dangerous: exposes all env vars
preopens: {
'/': '/' // WASM module can read /etc/shadow, ~/.ssh, etc.
}
});
const instance = await WebAssembly.instantiate(wasmBytes, {
wasi_snapshot_preview1: wasi.wasiImport
});
wasi.start(instance.instance);
An attacker who can provide a malicious WASM module (or exploit a vulnerability in a legitimate one) now has full read access to the host filesystem. The WASM memory isolation only protects the JavaScript heap — it does not protect host resources that WASI was explicitly configured to expose.
Fix 1: minimal WASI capability configuration
import { WASI } from 'node:wasi';
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
async function runWasmPlugin(wasmPath, inputData) {
// Sandbox: only preopen a dedicated temp dir, no env vars, no network
const sandboxDir = join(tmpdir(), `wasm-sandbox-${Date.now()}`);
await mkdir(sandboxDir, { recursive: true });
const wasi = new WASI({
version: 'preview1',
args: ['wasm-plugin'],
env: {
// Explicitly enumerate only what the WASM module needs
WASM_MAX_SIZE: '1048576',
WASM_TIMEOUT_MS: '5000'
},
preopens: {
'/sandbox': sandboxDir // WASM module sees /sandbox only
}
});
const wasmBytes = await readFile(wasmPath);
const { instance } = await WebAssembly.instantiate(wasmBytes, {
wasi_snapshot_preview1: wasi.wasiImport
});
wasi.start(instance.instance);
// Cleanup sandbox dir after completion
await rm(sandboxDir, { recursive: true, force: true });
}
Attack 2: WASM module escaping linear memory via shared memory
WebAssembly linear memory is isolated by default — each module instance gets its own linear memory that no other module or the JavaScript heap can access without explicit sharing. But SharedArrayBuffer breaks this guarantee:
// DANGEROUS: sharing a SharedArrayBuffer with WASM
const sharedMemory = new WebAssembly.Memory({
initial: 10,
maximum: 100,
shared: true // SharedArrayBuffer — TOCTOU race possible
});
// WASM module and JavaScript code now share the same memory
// Race conditions between JS writes and WASM reads can produce
// security-relevant state corruption
Shared memory enables TOCTOU race conditions between the JavaScript host and the WASM guest. If the host writes user-controlled data to shared memory while WASM reads it concurrently, a race can produce states that neither side intended. In a processing pipeline where the host checks input size before WASM processes it, a race can allow the WASM module to process a larger buffer than the host validated.
Fix 2: non-shared memory with explicit data passing
// SAFE: non-shared memory, explicit serialization
const memory = new WebAssembly.Memory({
initial: 10,
maximum: 100,
shared: false // No SharedArrayBuffer
});
// Pass data by copying, not by sharing
function callWasmFunction(instance, inputString) {
const encoder = new TextEncoder();
const inputBytes = encoder.encode(inputString);
// Allocate inside WASM linear memory
const allocFn = instance.exports.alloc;
const ptr = allocFn(inputBytes.length);
// Copy: host → WASM (one-way, no sharing)
const wasmMemory = new Uint8Array(instance.exports.memory.buffer);
wasmMemory.set(inputBytes, ptr);
// Call WASM function with pointer
const resultPtr = instance.exports.process(ptr, inputBytes.length);
// Read result back
const resultView = new Uint8Array(instance.exports.memory.buffer);
const resultEnd = resultView.indexOf(0, resultPtr);
const result = new TextDecoder().decode(resultView.slice(resultPtr, resultEnd));
// Free WASM-side allocation
instance.exports.free(ptr);
return result;
}
Attack 3: WASM module size and execution time amplification
A malicious WASM module can consume unbounded CPU and memory. WASM provides no built-in execution time limit — a WASM module that enters an infinite loop will spin one host CPU core indefinitely. Memory growth is bounded by the maximum parameter in WebAssembly.Memory(), but if that parameter is not set, memory is unbounded up to system limits.
// DANGEROUS: no memory maximum, no execution timeout
const { instance } = await WebAssembly.instantiate(userSuppliedWasmBytes, {
// No timeout, no memory ceiling — DoS via infinite loop or memory growth
wasi_snapshot_preview1: wasi.wasiImport
});
Fix 3: resource limits and timeout enforcement
import { Worker } from 'node:worker_threads';
import { join } from 'node:path';
async function runWasmWithTimeout(wasmBytes, inputData, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const worker = new Worker(join(__dirname, 'wasm-worker.js'), {
workerData: {
wasmBytes,
inputData,
// Memory limits enforced at Worker level via --max-old-space-size
resourceLimits: {
maxYoungGenerationSizeMb: 32,
maxOldGenerationSizeMb: 64
}
}
});
const timer = setTimeout(() => {
worker.terminate();
reject(new Error('WASM execution timeout'));
}, timeoutMs);
worker.on('message', (result) => {
clearTimeout(timer);
resolve(result);
});
worker.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
// wasm-worker.js — runs in isolated thread
const { wasmBytes, inputData } = workerData;
const wasmMemory = new WebAssembly.Memory({
initial: 1,
maximum: 16 // 16 pages = 1 MB ceiling
});
// ... run WASM, post result back with parentPort.postMessage()
Running WASM in a Worker thread provides two benefits: the resource limits apply at thread level, and the terminate() call on timeout is immediate — no cooperative cancellation required.
Attack 4: WASM module loading from user-controlled sources
If the MCP server loads WASM modules from a URL or path that is influenced by tool arguments, an attacker can substitute a malicious module:
// DANGEROUS: WASM module path from tool argument
async function runPlugin({ pluginUrl }) {
const response = await fetch(pluginUrl); // SSRF + arbitrary WASM load
const wasmBytes = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
}
This combines SSRF with arbitrary code execution — the attacker controls both where the WASM is fetched from and what it does.
Fix 4: WASM module registry with hash verification
import { createHash } from 'node:crypto';
// Approved modules with expected SHA-256 hashes
const PLUGIN_REGISTRY = new Map([
['image-resize', {
path: join(__dirname, 'plugins/image-resize.wasm'),
sha256: 'a3f8c2d1e...exact-expected-hash'
}],
['text-analyze', {
path: join(__dirname, 'plugins/text-analyze.wasm'),
sha256: 'b7e4a9f0c...exact-expected-hash'
}]
]);
async function loadVerifiedPlugin(pluginName) {
const entry = PLUGIN_REGISTRY.get(pluginName);
if (!entry) throw new Error(`Unknown plugin: ${pluginName}`);
const wasmBytes = await readFile(entry.path);
const hash = createHash('sha256').update(wasmBytes).digest('hex');
if (hash !== entry.sha256) {
throw new Error(`Plugin integrity check failed: ${pluginName}`);
}
return WebAssembly.compile(wasmBytes);
}
SkillAudit detection — Permissions and Security axes
SkillAudit flags the following patterns in MCP servers using WASM:
- WASI preopens root or home directory (HIGH, Security) —
preopens: { '/': '/' }orpreopens: { '/home': '/home' } - WASI env passes process.env directly (HIGH, Credentials) — exposes all environment variables to the WASM module including API keys
- No WASM execution timeout (MED, Security) — WASM instantiated in main thread with no Worker wrapper and no timeout
- WebAssembly.Memory without maximum (MED, Security) — unbounded memory growth enabling OOM DoS
- WASM loaded from argument-controlled URL or path (HIGH, Security) — SSRF + arbitrary code execution combined
- No WASM module hash verification (MED, Security) — WASM loaded from filesystem path without integrity check
MCP servers that use WASM correctly — minimal WASI preopens, no env exposure, Worker thread with timeout, registry-verified modules — typically score A on the Security axis for the WASM-specific checks. The most common finding in our corpus is the preopened root directory combined with full process.env exposure, which together reduce an otherwise clean server to C.
Run a SkillAudit scan on your WASM-using MCP server at skillaudit.dev. The Security axis report includes a WASM-specific section when the scanner detects WebAssembly imports in the source. Related: MCP server sandboxing security covers OS-level sandboxing (seccomp, AppArmor, container hardening) as a complementary layer to WASM isolation.