Security Guide

MCP server WebAssembly Relaxed SIMD security — CPU architecture fingerprint via FMA denormal behavior, NaN bit pattern oracle, relaxed_min NaN selection, relaxed_swizzle out-of-range lane discriminator

WebAssembly Relaxed SIMD (Chrome 114+, standardized as part of Wasm 2.0) introduces SIMD instructions with implementation-defined behavior on specific edge-case inputs. The intent is to allow browsers to map each instruction directly to the underlying CPU's SIMD unit without normalization overhead. The side effect is that these undefined behaviors differ systematically between CPU architectures: x86 (FMA3 instructions), ARM (NEON FMA), and Apple Silicon (NEON with Apple's implementation choices) each produce different observable results. An MCP server delivering a Wasm module can probe these differences to identify the user's CPU architecture, silicon vendor, and micro-architecture — without CPUID access, without any permission prompt, and with probes that complete in microseconds.

Why Relaxed SIMD creates a fingerprinting surface

Standard WebAssembly SIMD instructions (the v128.* family shipped in Chrome 91) are specified with deterministic behavior: the output for any input is uniquely defined by the Wasm spec. This means a Wasm module running on x86 and on ARM produces identical results for standard SIMD operations, and no CPU information leaks through the computed values.

Relaxed SIMD was introduced specifically to close the performance gap between deterministic Wasm SIMD and native code. The relaxation allows each instruction to produce any result that is valid for the underlying CPU's native SIMD instruction that the browser maps it to. On x86, f32x4.relaxed_fma maps to the vfmadd132ps FMA3 instruction. On ARM, it maps to vfma.f32 (NEON FMA). These produce identical results for normal float inputs. They diverge on denormal floats, NaN inputs, and near-zero edge cases — exactly the inputs an attacker can craft.

Attack 1: f32x4.relaxed_fma on denormal floats identifies CPU architecture

Fused multiply-add (FMA) computes a * b + c with a single rounding step — the intermediate product a * b is computed with full precision before adding c. Separate multiply followed by add uses two rounding steps. For normal float inputs, the difference is at the last bit and may be compiler-optimized away. For denormal inputs (values smaller than ~1.18e-38 for f32), x86 FMA3 and ARM NEON FMA differ in how they handle the denormal underflow during the fused operation — specifically in whether they flush-to-zero (FTZ) the denormal intermediate or propagate it.

Intel and AMD processors with FMA3 support (Haswell and later, Ryzen and later) have a DAZ (Denormals-Are-Zero) mode that browsers may or may not enable. Apple Silicon's NEON FMA always handles denormals natively. The observable result of relaxed_fma(denormal_a, denormal_b, 0.0) differs between these configurations.

;; WebAssembly Text Format — relaxed_fma CPU architecture probe

(module
  (func $probe_fma_arch (result f32)
    ;; Denormal float: smallest positive denormal for f32
    ;; IEEE 754: sign=0, exponent=0, mantissa=1 → 1.4e-45
    f32.const 1.4e-45  ;; denormal a
    f32.const 1.4e-45  ;; denormal b
    f32.const 0.0      ;; c = 0
    ;; relaxed_fma(a, b, c) = a*b+c with implementation-defined rounding
    ;; x86 FMA3 with DAZ: result = 0.0 (denormal a*b flushed to zero)
    ;; ARM NEON / Apple Silicon: result = 1.96e-90 (denormal product preserved)
    f32x4.relaxed_fma  ;; not valid outside v128 context, simplified for illustration
    ;; In practice: load into v128, apply relaxed_fma, extract lane 0
  )
  (export "probe_fma_arch" (func $probe_fma_arch))
)
// JavaScript: invoke the Wasm probe and interpret the result
async function identifyCpuArchitecture() {
  // In practice, build the full Wasm binary with v128 inputs
  const wasmBytes = buildRelaxedFmaProbeModule();
  const { instance } = await WebAssembly.instantiate(wasmBytes, {});
  const result = instance.exports.probe_fma_arch();

  if (result === 0.0) {
    return 'x86 (Intel/AMD with DAZ mode — FMA3 denormal flush-to-zero)';
  } else if (Math.abs(result - 1.96e-90) < 1e-92) {
    return 'ARM (NEON FMA — denormal product preserved, Apple Silicon or mobile ARM)';
  } else {
    return 'Unknown architecture / DAZ disabled on x86';
  }
}

// This probe completes in <1µs — entirely within the Wasm module
// No network request, no permission, no User-Agent string parsing
// Stable across browser versions because it probes CPU behavior, not browser implementation

Hardware fingerprint permanence: CPU architecture does not change between sessions, is not cleared by cookie deletion, and is not affected by private browsing mode. A 3-probe Relaxed SIMD fingerprint (FMA denormal, NaN bit pattern, swizzle behavior) produces ~4 bits of hardware-specific entropy that is stable for the lifetime of the device.

Attack 2: NaN bit pattern oracle identifies CPU micro-architecture

IEEE 754 NaN values have a 23-bit mantissa (for f32) that can carry any bit pattern — these are called NaN payloads. When a SIMD operation involves a NaN input, the output is a NaN, but the specific bit pattern of the output NaN is implementation-defined. Different CPU architectures produce different NaN payloads: x86 SIMD typically produces a quiet NaN with the Intel-specified payload (bit 22 set, lower 22 bits zero). ARM NEON may produce a NaN with a different payload depending on the operation and the input.

The f32x4.relaxed_min(NaN_input, 0.0) Relaxed SIMD operation is particularly useful: the relaxation allows the browser to use the CPU's native min instruction, which handles NaN inputs differently on x86 vs ARM. On x86 (using minps), the second operand is returned when the first is NaN — so the result is 0.0, not a NaN. On ARM NEON (using fmin), the result is 0.0 as well in ARM's default-NaN mode, but with ARM64 strict mode disabled, the NaN propagates. Apple Silicon specifically returns the NaN unchanged.

// Wasm relaxed_min NaN behavior probe

async function probeNanBehavior() {
  const wasmModule = buildRelaxedMinNanProbe();
  // Probe: relaxed_min(v128(NaN, 0, 0, 0), v128(0, 0, 0, 0)) → extract lane 0
  const { instance } = await WebAssembly.instantiate(wasmModule);
  const lane0 = instance.exports.relaxed_min_nan_probe();

  if (isNaN(lane0)) {
    // NaN propagated → ARM / Apple Silicon behavior
    // Check NaN payload to distinguish ARM variants:
    const nanBits = new DataView(new Float32Array([lane0]).buffer).getUint32(0, true);
    const payload = nanBits & 0x3FFFFF; // lower 22 bits of mantissa
    if (payload === 0x400000) return 'Apple Silicon (default-NaN mode)';
    return `ARM NEON (NaN payload: 0x${payload.toString(16)})`;
  } else if (lane0 === 0.0) {
    // NaN discarded → x86 minps behavior (returns non-NaN operand)
    return 'x86 Intel/AMD (minps NaN behavior: non-NaN wins)';
  }
}

// This distinguishes:
// x86 (Intel Core, AMD Ryzen): minps returns second operand when first is NaN
// ARM mobile (Qualcomm, Samsung Exynos): fmin returns NaN (propagating mode)
// Apple Silicon M1-M4: fmin with default-NaN → returns 0 but with specific NaN pattern
// The result is a 2-bit CPU family discriminator

Attack 3: f32x4.relaxed_fma precision distinguishes hardware FMA vs software emulation

FMA computes a * b + c in a single step with one rounding, vs two separate rounds for multiply-then-add. For specific carefully chosen float inputs, the results differ by exactly 1 ULP (unit in the last place). CPUs with hardware FMA units (all x86 since Haswell, all ARM since ARMv7-A with NEON) produce the single-rounding result. Older CPUs or emulation environments that implement FMA by splitting into multiply + add produce the two-rounding result.

This matters for MCP servers because Wasm running in Node.js (for MCP tool implementations) and Wasm running in a browser may be executed on different platforms. A Wasm module that probes this difference can determine whether it is running in a browser on a modern desktop CPU, on a mobile ARM device, or in a headless testing environment — helping an attacker determine the attack surface they're operating in.

// FMA precision probe: input chosen so hardware FMA and software FMA differ by 1 ULP

async function probeHardwareFMA() {
  // These magic values produce a 1-ULP difference between hardware and software FMA:
  const a = 0.1;   // 0.1 in f32 = 0x3DCCCCCD
  const b = 0.1;   // same
  const c = 1.0;   // 0x3F800000
  // Hardware FMA: a*b+c with single rounding = 1.0100000149011612
  // Software (mul+add): rounds twice = 1.0100000500679016
  // Difference: last bit of f32 mantissa

  const wasmResult = await runRelaxedFmaProbe(a, b, c);

  // Check which result matches
  const hwFmaExpected = 1.0100000149011612;
  const swFmaExpected = 1.0100000500679016;

  if (Math.abs(wasmResult - hwFmaExpected) < 1e-10) {
    return 'hardware FMA (Haswell+, ARMv7-A+ with NEON)';
  } else if (Math.abs(wasmResult - swFmaExpected) < 1e-10) {
    return 'software FMA emulation (older CPU or emulator)';
  }
}

// Combined with relaxed_min NaN result: 4-bit CPU profile
// Distinguishes: modern x86 desktop, ARM mobile, Apple Silicon, x86 without FMA, emulator

Attack 4: relaxed_swizzle out-of-range lane index discriminates x86 vs ARM

i8x16.relaxed_swizzle(v, indices) reorders the 16 bytes of vector v using the 16 bytes of indices as a lookup table. On x86, this maps to the pshufb instruction, which returns 0 for any index byte with bit 7 set (i.e., index ≥ 128) — but for indices 16-127, pshufb uses only the low 4 bits (index mod 16). On ARM, the equivalent vtbl instruction returns 0 for any index ≥ 16, not ≥ 128. This means relaxed_swizzle with index 17 (binary: 00010001) returns v[17 mod 16] = v[1] on x86 (pshufb uses low 4 bits: 0001) but returns 0 on ARM (vtbl: index 17 is out of range).

This is a single-probe, 1-bit CPU architecture discriminator that can be evaluated in under 1 microsecond.

// relaxed_swizzle out-of-range probe

async function probeSwizzleBehavior() {
  // Vector v = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
  // Indices = [17, ...rest] — index 17 is out of 0-15 range
  // x86 pshufb: 17 = 0b00010001 → uses low 4 bits → index 1 → returns v[1] = 1
  // ARM vtbl: 17 is ≥ 16 → out of range → returns 0

  const result = await runRelaxedSwizzleProbe(
    [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], // v
    [17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]       // indices
  );

  const lane0 = result[0]; // First lane result

  if (lane0 === 1) {
    return 'x86 (pshufb: index 17 uses low 4 bits → lane 1 = 1)';
  } else if (lane0 === 0) {
    return 'ARM (vtbl: index 17 out of range → 0)';
  }

  // This single probe distinguishes x86 from ARM with 100% accuracy
  // when Relaxed SIMD is available (Chrome 114+ / V8 with --experimental-wasm-relaxed-simd)
}

Availability gate: Relaxed SIMD requires feature detection via WebAssembly.validate() with a test module containing relaxed instructions. As of 2026, Chrome 114+ and Edge 114+ support it; Firefox supports it behind a flag; Safari does not yet ship it. An MCP server Wasm module should feature-detect before probing — the absence of Relaxed SIMD is itself a fingerprint bit (Firefox or Safari, narrowing the browser field).

Combined fingerprint — bits of CPU entropy

Probe Distinguishes Bits of entropy Probe time
relaxed_fma denormal flush-to-zero x86 DAZ mode vs ARM NEON FMA 1 bit <1µs
relaxed_min NaN bit pattern x86 minps vs ARM fmin vs Apple Silicon 2 bits (3-way) <1µs
relaxed_fma 1-ULP precision Hardware FMA vs software emulation 1 bit <1µs
relaxed_swizzle index mod behavior x86 pshufb vs ARM vtbl 1 bit <1µs
Relaxed SIMD feature presence Chrome/Edge vs Firefox vs Safari 2 bits (3-way) <10µs

Combined: 7 bits of CPU/browser fingerprint from a single Wasm module instantiation. Stable across sessions, not cleared by privacy controls, and not detectable by standard CSP or network monitoring.

Defences

Wasm module sandboxing: MCP clients that execute Wasm from tool output should run it in a separate Worker with a restrictive Content-Security-Policy that blocks wasm-unsafe-eval unless the Wasm binary is explicitly allowlisted. Blocking wasm-unsafe-eval prevents dynamic Wasm compilation from injected payloads.

Cross-origin sandboxed iframe: Tool output rendered in a sandboxed cross-origin iframe with allow-scripts but without allow-same-origin restricts the Wasm execution environment. The iframe cannot exfiltrate fingerprint results to the attacker's server if connect-src is also locked to 'none' or 'self'.

Disable Relaxed SIMD in the execution context: Chrome's V8 engine exposes a flag (--no-experimental-wasm-relaxed-simd) that disables Relaxed SIMD, falling back to standard SIMD. MCP client Electron applications that control their V8 flags can disable the feature entirely, removing the attack surface. Browser-based MCP clients cannot control V8 flags but can use the iframe sandbox approach.

Vet Wasm modules before execution: Static analysis of Wasm binary modules can detect the presence of Relaxed SIMD opcode prefixes (0xFD extension opcodes with relaxed instruction encoding). SkillAudit's scanner flags Wasm payloads in MCP tool output that include Relaxed SIMD opcodes as a potential fingerprinting vector.

Avoid shipping Wasm in MCP server tool output: If an MCP server needs to run computation in the client, prefer pure JavaScript to Wasm — it exposes fewer hardware-level fingerprinting surfaces. If Wasm is necessary, use the standard SIMD instruction set (deterministic behavior) rather than Relaxed SIMD.

Related: Wasm component model security · WebGPU API security · MCP security checklist