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:

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.