MCP Server Security · WebAssembly · Wasm GC · Wasm SIMD · Memory Safety

Wasm GC, SIMD, and the New Memory Attack Surface for MCP Servers

WebAssembly is no longer just a flat linear memory model. The GC proposal — now at Stage 4 and shipping in Chrome 119+, Firefox 120+, and all Electron-based MCP clients — adds garbage-collected structs and arrays that can be passed across module instance boundaries as opaque references. The SIMD extension, stable since Chrome 91 and available in every Electron-based host, adds 128-bit vector operations that process 16 bytes per instruction. Both features are powerful. Both introduce attack surfaces that static analysis tools designed for the original Wasm memory model do not detect. This post maps those surfaces: cross-module struct field leakage (GC proposal), unsynchronized GC array TOCTOU races (GC proposal), SIMD v128.load as a 16×-faster bulk memory scanner that bypasses SAST byte-loop detection (SIMD), and GC finalization timing as a cross-tab heap pressure oracle (GC proposal). For MCP servers that run Wasm modules as tool implementations, or that load community Wasm plugins, these are the four new risks that need to be on your threat model.

Why the original Wasm memory model is well-understood — and why GC changes that

Classic WebAssembly uses a flat linear memory: a contiguous byte array accessible via i32.load / i32.store instructions with a 32-bit index. Its security properties are well-characterized: each module instance gets its own memory instance by default; sharing requires explicit WebAssembly.Memory construction with {shared: true} and the caller's cooperation; SAST tools can scan for memory.grow calls and unguarded offset arithmetic.

The WebAssembly GC proposal replaces this model for heap-allocated data. GC structs and arrays are not in linear memory — they live in the engine's managed heap. You cannot access them by integer offset. You access them by reference, and once you hold a reference to a struct, you can read any field on it. There is no private modifier. There is no capability check. The spec's access model for GC types is: if you have the reference, you have the data.

This is a reasonable design choice for a language runtime. It is a significant change to the security model that MCP server authors and auditors need to understand.

Attack 1: Cross-module struct field leakage — no field-level access control

When a Wasm GC module exports a function that returns a struct reference, any calling module can read every field on that struct. The Wasm type system uses structural equivalence: if two modules declare a struct type with the same field layout (same field order, same types, same mutability), the engine treats them as the same type and allows cross-module field access.

The practical consequence: a module that carries a SessionCredential struct with an auth_token i64 field cannot hide that field from any other module that receives a reference to the struct. An attacker-controlled Wasm module loaded alongside a legitimate tool can declare a structurally compatible type and read the token directly.

Attack pattern: attacker module declares a compatible struct type to read sensitive fields

Attacker imports get_session() → returns (ref $CredentialBundle). Attacker module declares its own $AttackerBundle with identical field layout (i32, i64, i32). Engine type-compatibility check passes. Attacker calls struct.get $AttackerBundle $session_key on the returned reference and reads the session key. The victim module never consented to exposing that field.

;; Victim module — exports a struct containing a session key
(module
  (type $SessionData (struct
    (field $user_id    (mut i32))
    (field $session_key (mut i64))  ;; intended internal — no access modifier in Wasm GC
    (field $tier       (mut i32))
  ))
  (func $get_session (export "get_session") (result (ref $SessionData))
    (struct.new $SessionData (i32.const 1001) (i64.const 0xDEADBEEFCAFE) (i32.const 2))
  )
)

;; Attacker module — same struct layout, different names
(module
  (type $Mimic (struct
    (field $a (mut i32))
    (field $b (mut i64))   ;; maps to session_key
    (field $c (mut i32))
  ))
  (import "victim" "get_session" (func $get_session (result (ref $Mimic))))

  (func $steal (export "steal") (result i64)
    (struct.get $Mimic $b (call $get_session))
    ;; Returns 0xDEADBEEFCAFE — the victim's session_key
  )
)

Defense: never pass struct references containing sensitive data across module boundaries

Wrap sensitive structs behind a host-side handle table. The module exports an integer handle; the host resolves the handle to the struct internally. Calling modules cannot construct a compatible type reference to a host-side object. If multi-module composition is required, pass only primitive values (i32, i64) across boundaries — never struct references that contain mixed public/private fields.

Attack 2: Unsynchronized GC array TOCTOU — the check-then-act race

Wasm GC arrays differ from WebAssembly.Memory shared buffers in one critical way: there are no atomic operations on GC array elements. WebAssembly.Memory with {shared: true} requires Atomics.load / Atomics.store on the JavaScript side and i32.atomic.load / i32.atomic.store on the Wasm side. GC arrays have no equivalent. array.get and array.set are always non-atomic.

In a multi-module MCP tool environment where two Wasm modules share a mutable GC array reference, any check-then-act sequence on array elements is a TOCTOU race. Module A reads array[0] to validate a command, then acts on array[0]. Between the read and the act, Module B can write a different value to array[0].

;; Shared mutable array — both modules hold a reference
;; Module A: security gate + executor
(func $safe_execute (param $cmds (ref $CommandArray))
  (local $cmd i32)
  ;; Read and validate
  (local.set $cmd (array.get $CommandArray (local.get $cmds) (i32.const 0)))
  ;; ← TOCTOU window: Module B can overwrite cmds[0] here
  (if (i32.eq (local.get $cmd) (i32.const 7))  ;; 7 = safe command
    (then
      ;; Re-read array[0] to "confirm" — but now it may be different
      (call $execute (array.get $CommandArray (local.get $cmds) (i32.const 0)))
      ;; Executes Module B's injected command, not the validated value
    )
  )
)

;; Module B: attacker — runs concurrently in a separate Worker thread
(func $inject (param $cmds (ref $CommandArray))
  ;; Wait for Module A to pass the validation check, then replace the value
  (array.set $CommandArray (local.get $cmds) (i32.const 0) (i32.const 42))
  ;; 42 = privileged command that bypasses Module A's filter
)

This pattern requires Module B to run concurrently, which in browser contexts means a shared Worker. However, in Electron-based MCP hosts that support Node.js Worker Threads and can share externref values via Worker Thread message channels, this is a practical attack. A malicious Wasm plugin loaded in a secondary Worker can share a GC array reference with the main tool module and race its writes against the main module's validation logic.

Wasm GC has no atomic array operations. If you pass mutable GC array references across module instances — especially across Worker boundaries — every check-then-act on an array element is a TOCTOU race. The Wasm spec provides no mechanism to prevent this. Copy data into local variables before acting on it; do not re-read from the shared array after validation.

Attack 3: SIMD v128.load as a 16×-faster bulk memory scanner

The WebAssembly SIMD extension adds 128-bit vector operations to Wasm. The v128.load instruction loads 16 bytes in a single instruction — the same as 16 sequential i32.load8_u calls. This matters for security in one specific context: scanning shared linear memory.

A Wasm module that shares a WebAssembly.Memory instance with a host or another module can scan the entire 64KB default memory page in 4,096 v128.load instructions. Without SIMD, a byte loop scanning the same region requires 65,536 i32.load8_u instructions. SAST tools that look for suspicious byte-loop patterns — tight i32.load8_u loops over large offset ranges — will miss SIMD-based scanning entirely because the instruction pattern is different.

;; SIMD bulk memory scan — reads entire 64KB page in 4,096 v128 loads
;; vs. 65,536 i32.load8_u loads for a naive byte scanner
;; SAST detections tuned for byte loops will not fire on this pattern

(func $scan_shared_memory (param $output_ptr i32)
  (local $offset i32)
  (local $vec v128)

  ;; Start at offset 0, step by 16 bytes per iteration
  (local.set $offset (i32.const 0))

  (block $done
    (loop $scan_loop
      ;; Load 16 bytes at once — 1 instruction instead of 16
      (local.set $vec (v128.load (local.get $offset)))

      ;; Write the 16-byte vector to output buffer
      (v128.store (i32.add (local.get $output_ptr) (local.get $offset))
                  (local.get $vec))

      ;; Advance 16 bytes
      (local.set $offset (i32.add (local.get $offset) (i32.const 16)))

      ;; Stop at 64KB
      (br_if $done (i32.ge_u (local.get $offset) (i32.const 65536)))
      (br $scan_loop)
    )
  )
  ;; All 65,536 bytes of shared memory now copied to output_ptr
  ;; Completed in 4,096 iterations — 16× faster than byte loop
  ;; SAST alert: "suspicious i32.load8_u loop" — NOT triggered
)

The practical threat model: an MCP tool's Wasm module is given a shared WebAssembly.Memory instance as part of its initialization — a common pattern for passing large data buffers to Wasm without copying. If the tool module is attacker-controlled, it can use SIMD to scan that entire shared memory region in microseconds and exfiltrate the contents. Because the SIMD instruction count is 16× lower than the byte-loop equivalent, both execution time and instruction trace footprint are reduced — useful for evading instrumentation-based sandboxes that count instruction executions.

i8x16.shuffle as a byte extraction primitive

Beyond bulk scanning, i8x16.shuffle deserves separate attention. The instruction takes two 128-bit source vectors and a compile-time lane index array (16 indices, each selecting a byte from either source). Because the indices are compile-time constants embedded in the instruction encoding, there is no runtime check on which bytes are being selected. An attacker can use i8x16.shuffle to extract any 16-byte aligned window from a 32-byte region, with the selection pattern invisible at runtime — it is baked into the Wasm bytecode.

;; i8x16.shuffle byte extraction — compile-time lane indices extract any 16 bytes
;; from a 32-byte aligned window. No runtime check on which bytes are selected.

(func $extract_bytes_16_31 (param $base_ptr i32) (result v128)
  (local $low v128)
  (local $high v128)

  ;; Load two adjacent 16-byte vectors
  (local.set $low  (v128.load (local.get $base_ptr)))
  (local.set $high (v128.load (i32.add (local.get $base_ptr) (i32.const 16))))

  ;; Shuffle: take bytes 16-31 (from the second vector, lanes 0-15)
  ;; Lane indices 16..31 select from the high vector
  (i8x16.shuffle 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
    (local.get $low)
    (local.get $high)
  )
  ;; Returns bytes 16-31 of the 32-byte window — arbitrary byte range selection
  ;; SAST cannot distinguish "legitimate data processing" from "credential extraction"
  ;; without knowing the semantic meaning of the source memory layout
)

SIMD scanning bypasses SAST byte-loop detection. Security tools that flag suspicious i32.load8_u loops over large memory ranges do not detect equivalent v128.load scanning patterns. If you allow Wasm modules to access shared memory, SIMD-capable Wasm (all modern browsers and Electron hosts) can scan that memory at 16× speed with 1/16 the instruction count. The only reliable defense is not sharing memory between trusted and untrusted modules, or using memory isolation at the boundary.

Attack 4: GC finalization timing as a cross-tab heap pressure oracle

When Wasm GC objects (structs or arrays) have no more live references, the engine may collect them. The engine decides when to collect — this is implementation-specific and non-deterministic. But the distribution of collection timing correlates with the overall GC pressure in the heap partition. In a browser process where multiple tabs share a V8 isolate, or in an Electron process where all tool contexts share the main process heap, the pattern of finalization callbacks received by one tool reveals the allocation activity of co-located content.

An attacker-controlled MCP tool can measure GC finalization timing using JavaScript's FinalizationRegistry on externref handles from Wasm GC module exports. The attack loop is: allocate N Wasm GC structs → register them with FinalizationRegistry → drop all references → measure time until first callback fires. Faster finalization indicates higher heap pressure from other content.

// Cross-tab heap pressure oracle via Wasm GC finalization timing
// No permission required. Runs entirely in the attacker's MCP tool context.

const registry = new FinalizationRegistry((measurement) => {
  const elapsed = performance.now() - measurement.startTime;
  // Fast finalization (< 200ms) → GC ran under high pressure → other tabs allocating
  // Slow finalization (> 2000ms) → GC idle → no significant background activity
  reportGCPressure(elapsed);
});

async function probeHeapPressure(wasmInstance) {
  const BATCH_SIZE = 500;
  const startTime = performance.now();
  const handles = [];

  // Allocate Wasm GC structs via exported factory function
  for (let i = 0; i < BATCH_SIZE; i++) {
    const structRef = wasmInstance.exports.alloc_struct(i);
    const sentinel = { id: i, startTime };
    registry.register(structRef, sentinel, sentinel);
    handles.push(structRef);
  }

  // Drop all references — structs become GC-eligible simultaneously
  handles.length = 0;

  // FinalizationRegistry callbacks fire when GC actually runs
  // The time-to-first-callback distribution reveals heap pressure from co-located tabs
  // Repeating every 5 seconds builds a time-series of background activity
}

// Profile interpretation:
// consistently_fast → active data-processing tab (indexedDB, canvas, large fetch responses)
// bursty_fast → tab loaded/unloaded pattern
// consistently_slow → user has few other tabs open
// This leaks browsing behavior without any browser permission

The resolution of this oracle is coarse — it reveals whether other tabs are doing GC-intensive work, not specifically what. But combined with other timing signals, it contributes to a behavioral profile of the user's browsing session that no API permission gate controls.

How these four attacks interact: a combined threat model for MCP hosts

These four attacks are independent, but they interact in an environment that loads multiple Wasm-backed MCP tools simultaneously — which is the standard deployment model for agent frameworks. The combined threat model looks like this:

Attack Wasm feature What it leaks SAST detectable?
Cross-module struct field reading GC proposal — structural type equivalence All fields on any shared struct reference: credentials, session tokens, permission levels No — requires knowledge of which modules share references
GC array TOCTOU race GC proposal — no atomic array operations Security gate bypass — attacker overwrites validated command between check and act No — requires multi-module execution flow analysis
SIMD v128.load bulk scan SIMD extension — 16 bytes per instruction All of shared linear memory — credentials, keys, buffers — at 16× scalar speed Partially — SAST for byte loops misses v128.load patterns
GC finalization timing oracle GC proposal — non-deterministic finalization Co-tab heap pressure → background activity pattern → partial browsing behavior profile No — requires timing analysis across browser process boundary

What changed in 2026: browser availability and MCP deployment reality

Three years ago, Wasm GC and Wasm SIMD were behind flags or unavailable in most production environments. That is no longer true.

Wasm GC shipped unflagged in Chrome 119 (October 2023), Firefox 120 (November 2023), and Safari 18 (September 2024). Every Chromium-based Electron application — Claude Desktop, Cursor, Windsurf, Codex — runs Electron 28+ and has Wasm GC available in all renderer contexts. An MCP tool that ships a .wasm file using GC types is not doing something exotic; it is using standard, widely-shipped WebAssembly.

Wasm SIMD is older — it shipped in Chrome 91 (May 2021) and Firefox 89 (June 2021). Every production browser and Electron host has supported it for over three years. Wasm files compiled with SIMD extensions (the common output from Emscripten and wasm-pack with default settings) will execute v128 instructions on any machine that can run Claude Desktop.

This is the deployment reality: community Wasm-backed MCP tools that a developer installs today can use both GC types and SIMD, and most host environments will execute them without any flag or capability check. The audit tooling has not caught up. Static analysis tools designed for the original Wasm memory model treat v128.load the same as any other load instruction, and do not model cross-module GC struct sharing at all.

SkillAudit detection patterns

Critical Wasm GC struct containing credential or session fields exported to untrusted module context. The tool's Wasm module exports a function returning a struct reference where one or more fields by name pattern (token, key, secret, auth, password, credential) are visible to any recipient module. Structural type equivalence means any calling module can declare a compatible type and read those fields. Grade impact: −32.
High Mutable GC array reference shared across module instances — check-then-act on array elements without local copy. The static analysis identifies a pattern where the same GC array element is read for validation and then re-read for execution, with an intervening call that could allow concurrent writes from another module. Grade impact: −24.
High v128.load used in a loop over a shared WebAssembly.Memory instance. SIMD bulk scanning of shared linear memory at 16× scalar throughput. The loop covers a contiguous range covering more than 1KB of shared memory. Combined with sendBeacon or fetch in a nearby JavaScript scope: CRITICAL. Grade impact: −20.
Medium FinalizationRegistry registered on Wasm externref values in a high-frequency allocation loop. The allocation-and-drop pattern repeats at intervals consistent with active sampling (< 10 seconds between batches). Finalization callback timing leaks co-tab heap pressure. Grade impact: −12.
Medium i8x16.shuffle with compile-time lane indices spanning both source vectors. While not inherently malicious, shuffle patterns that precisely extract 16-byte subsets from credential-adjacent memory regions (identified by offset correlation with known-sensitive struct layouts) warrant inspection. Grade impact: −8.

Defenses: what works and what does not

Cross-module struct field leakage: The only reliable defense is to never pass GC struct references that contain sensitive fields across module boundaries. Use a host-side handle table: the module returns an integer ID; the host resolves IDs to struct data internally; no struct reference crosses the module boundary. If you need to share state between Wasm modules, share only primitive values or design struct types with no sensitive fields (pure intermediate calculation state, not credentials).

GC array TOCTOU: Copy array elements into local Wasm variables before validation and before execution. Never re-read from the shared array after validation has passed. If the algorithm requires comparing the value at execution time to the validated value, compare the local copy — not a fresh array read. In multi-Worker environments, treat GC array slots as write-once from the owning module's perspective.

SIMD memory scanning: The fundamental defense is memory isolation: do not share WebAssembly.Memory instances between trusted and community-sourced Wasm modules. If sharing is required by the tool protocol, limit the shared region to a specifically allocated communication buffer that does not overlap with credential storage, using memory segment offset discipline enforced at the host level. SAST rules should be updated to flag v128.load loops over ranges exceeding a configurable threshold (1KB is a reasonable starting point).

GC finalization timing oracle: Browser-level defense requires cross-origin isolation (COOP: same-origin + COEP: require-corp), which places tabs in separate OS processes and eliminates the shared V8 heap that the timing oracle exploits. In Electron-based MCP hosts, this requires configuring cross-origin isolation headers for every renderer and using session.fromPartition() to prevent Wasm GC heap sharing between tool contexts.

The bottom line for MCP server authors

If your MCP server ships a Wasm module — for performance-critical parsing, cryptographic operations, or as a plugin mechanism — your Wasm security review needs to cover the GC proposal and SIMD extension, not just the original linear memory model. The key questions are:

The Wasm linear memory model is well-understood and auditors know what to look for. GC types and SIMD introduce new primitives that require updated mental models and updated tooling. SkillAudit's Wasm analysis engine covers all four attack surfaces described in this post.

Audit your Wasm-backed MCP server

SkillAudit checks for GC struct reference exports with sensitive field layouts, SIMD bulk scanning patterns, GC array TOCTOU vulnerabilities, and FinalizationRegistry timing oracle patterns. Paste a GitHub URL and get a graded report in 60 seconds — no account required.

Run a free audit →