Security Deep-Dive · 2026-07-10
WebAssembly Relaxed SIMD: The CPU Fingerprinting API You Didn't Know You Shipped
WebAssembly was designed to be portable — the same bytecode, the same results, on any CPU. Relaxed SIMD broke that guarantee deliberately, for performance. The side effect is a hardware fingerprinting API that distinguishes Intel Core from AMD Ryzen from Apple Silicon from Qualcomm Snapdragon in under 10 microseconds, using probes that complete entirely inside a Wasm module, require no network request, no permission prompt, and survive every privacy mode your browser offers.
The portable illusion
Before Relaxed SIMD, WebAssembly SIMD (shipped in Chrome 91, Firefox 89, Safari 16.4 as the v128.* instruction family) was deterministic. Every SIMD operation — add, multiply, compare, shuffle — produced the same binary output for any given input, on any CPU, in any browser. This was a deliberate design decision: Wasm's guarantee of portability and reproducibility meant the spec authors were willing to accept performance penalties on some platforms to ensure identical results.
The performance cost turned out to matter. Machine learning inference, image processing, audio codecs, and video encoders all rely on SIMD instructions that map to native CPU SIMD units with no normalization overhead. Wasm's deterministic SIMD required browsers to add normalization layers — extra instructions that clamped results, handled NaN propagation in spec-compliant ways, and normalized denormal float handling — that native code doesn't need. On x86, this overhead was 10-30% for compute-bound SIMD workloads. On ARM it was smaller but still measurable.
Relaxed SIMD was the W3C WebAssembly Working Group's answer. Standardized in 2022 and shipped in Chrome 114, it introduced 73 new SIMD instructions whose behavior on specific edge-case inputs is implementation-defined: the spec says that for these inputs, the browser may return any result that is valid for the native CPU instruction the browser maps the Wasm opcode to. The performance gain is real: SIMD workloads that use Relaxed SIMD instead of deterministic SIMD are 10-25% faster on x86 and ARM. The security cost was not discussed in the spec — and it is significant.
The core security property that Relaxed SIMD removes
Standard Wasm SIMD: output = f(input) is uniquely determined by input and the Wasm spec. No CPU information leaks through computed values. Relaxed SIMD: output = f(input, cpu_architecture). The cpu_architecture term is implicit, injected by the native CPU instruction that the browser maps the opcode to. An attacker who can observe output for crafted inputs can invert this function and recover the CPU identity.
The four probes
1. FMA denormal flush-to-zero
x86 FMA3 with DAZ mode flushes denormal intermediates to zero. ARM NEON FMA preserves them. Apple Silicon's FMA matches ARM behavior with a distinct NaN payload. One probe, three CPU families.
2. relaxed_min NaN bit pattern
x86 minps discards NaN and returns the non-NaN operand. ARM fmin propagates NaN. Apple Silicon returns NaN with a specific payload. 3-way discriminator from one instruction.
3. FMA 1-ULP precision
For specific magic float inputs, hardware FMA (single rounding) and software FMA (double rounding) differ by 1 ULP. Identifies whether hardware FMA units are present — distinguishes Haswell-era desktops from older CPUs and emulators.
4. relaxed_swizzle out-of-range index
x86 pshufb uses the low 4 bits for indices 16-127, so index 17 returns lane 1. ARM vtbl returns 0 for any index ≥ 16. Single-probe x86/ARM discriminator, <1µs.
Feature-detecting Relaxed SIMD availability adds another 2 bits: Chrome/Edge support it; Firefox supports them only behind a flag as of 2026; Safari does not yet ship them. So "Relaxed SIMD available" already narrows the browser field. Combining all four behavioral probes with the availability check produces 7 bits of CPU and browser entropy from a single Wasm module instantiation that completes in under 10 microseconds.
Attack 1: FMA denormal behavior is a silicon family identifier
Fused Multiply-Add (FMA) computes (a × b) + c in a single operation with one rounding step. The IEEE 754-2008 standard defines FMA semantics for normal floats precisely — the intermediate product a × b is computed in infinite precision before the addition and single rounding. For denormal floats (values below ~1.18×10⁻³⁸ for f32), the behavior of the intermediate product is where architectures diverge.
Intel and AMD processors support a CPU control register flag called DAZ (Denormals-Are-Zero). When DAZ is set, any denormal input to any SIMD instruction is treated as positive zero before the operation begins. Chrome and V8 do not explicitly set or clear DAZ — they inherit whatever state is present. In practice, on most desktop x86 systems, DAZ is not set by the browser process itself, but the underlying OS runtime may have set it. The observable result of relaxed_fma(1.4e-45, 1.4e-45, 0.0) — where both inputs are the smallest positive f32 denormal — is:
- x86 with DAZ enabled: The denormal inputs are treated as zero → FMA computes 0 × 0 + 0 = 0.0
- x86 with DAZ disabled: The denormal product underflows to 0 after the multiply, but without DAZ the product is first computed in extended precision → browser-dependent result, often 0.0
- ARM NEON FMA (ARMv7-A and later, all Cortex-A, Snapdragon, Exynos): NEON does not have a DAZ equivalent in the same form; denormals are handled natively → result is ~1.96×10⁻⁹⁰ (the product of two denormals, underflowed but non-zero)
- Apple Silicon (M1 through M4): ARM architecture, same NEON behavior, denormals preserved → result matches ARM NEON
;; WebAssembly text format: relaxed_fma denormal probe
;; Load the test module:
;; (module
;; (memory 1)
;; (func $fma_probe (result f32)
;; v128.const f32x4 1.4e-45 1.4e-45 1.4e-45 1.4e-45 ;; denormal a
;; v128.const f32x4 1.4e-45 1.4e-45 1.4e-45 1.4e-45 ;; denormal b
;; v128.const f32x4 0.0 0.0 0.0 0.0 ;; c = 0
;; f32x4.relaxed_fma ;; a*b+c with impl-defined rounding
;; f32x4.extract_lane 0) ;; read first lane
;; (export "fma_probe" (func $fma_probe)))
async function probeArchitectureViaFMA() {
const wasmBytes = buildRelaxedFmaModule(); // builds the .wasm binary above
const { instance } = await WebAssembly.instantiate(wasmBytes);
const result = instance.exports.fma_probe();
// x86 DAZ: result = 0.0 (denormal flushed)
// ARM NEON: result ≈ 1.96e-90 (denormal product preserved)
// Apple Silicon: same as ARM NEON (NEON FMA without DAZ flush)
if (result === 0.0) return 'x86 (Intel Core or AMD Ryzen — DAZ mode active)';
if (result > 0) return 'ARM (NEON FMA — Qualcomm Snapdragon, Apple M-series, Cortex-A)';
return 'unknown';
}
// Execution time: <1µs — all computation inside Wasm, no JS allocation
This probe is stable for the lifetime of the device. CPU architecture does not change between sessions, is not cleared by cookie deletion or cache purge, and is identical between normal and private browsing modes. An MCP server that delivers a Wasm payload as part of its tool output — a common pattern for computation-heavy tools that return processed results — can embed this probe and exfiltrate the result in the same tool call response, before the user sees any output at all.
Attack 2: NaN bit patterns as a micro-architecture oracle
IEEE 754 NaN values carry a "payload" in their mantissa bits. The sign bit and exponent bits identify a value as NaN; the remaining 22 bits (for f32) can contain any pattern. The spec says NaN payloads are "implementation-defined" for most arithmetic operations, which means different CPU microarchitectures produce different bit patterns when a NaN is generated or passed through an operation.
f32x4.relaxed_min(NaN_input, 0.0) is particularly revealing. The relaxation allows the browser to use the native minimum instruction:
- x86 SSE/AVX
minps: When the first operand is NaN,minpsalways returns the second operand (0.0). This is a quirk of the x86 implementation — the "min" of (NaN, 0) is 0, not NaN. Result:0.0with an IEEE sign bit of +0. - ARM NEON
fminin default-NaN mode: ARM processors in default-NaN mode (ARMv8-A default) return a default NaN (0x7FC00000 for f32) for any operation involving a NaN input. Result: a quiet NaN with zero payload. - Apple Silicon
fmin: Apple Silicon implements ARM64 default-NaN mode but with its own NaN payload propagation rules. The returned NaN has a specific payload that differs from generic ARM default-NaN behavior. - Qualcomm Snapdragon (X Elite, X Plus): ARM64 architecture, same default-NaN semantics as Apple Silicon but with Qualcomm's own microcode handling, producing a slightly different NaN payload in some configurations.
// NaN bit pattern oracle: 3-way CPU family discriminator
async function probeNanBitPattern() {
const { instance } = await WebAssembly.instantiate(buildRelaxedMinNanModule());
// Module: relaxed_min(v128(NaN, 0, 0, 0), v128(0.0, 0.0, 0.0, 0.0)) → extract lane 0
const result = instance.exports.relaxed_min_nan_probe();
if (result === 0.0 && !Object.is(result, -0)) {
return 'x86 — minps returns non-NaN operand when first operand is NaN';
}
if (isNaN(result)) {
const buf = new ArrayBuffer(4);
new Float32Array(buf)[0] = result;
const bits = new Uint32Array(buf)[0];
const payload = bits & 0x003FFFFF; // low 22 bits of mantissa
if (payload === 0) return 'ARM (default-NaN, ARMv8-A generic — Cortex-A or Exynos)';
if (payload === 0x200000) return 'Apple Silicon M-series (ARM64 with Apple NaN payload)';
return `ARM-variant NaN payload 0x${payload.toString(16).padStart(6,'0')}`;
}
return 'unknown';
}
// The NaN payload is stable for a given CPU+microcode combination
// Not affected by browser version updates — probes hardware behavior
Cross-session stability: NaN bit patterns are determined by the CPU's microarchitecture and firmware, not by browser state. They are identical across all browser profiles, all incognito windows, all VMs running on the same physical CPU, and all user accounts on the same machine. They change only if the physical CPU is replaced.
Attack 3: 1-ULP FMA precision identifies the hardware generation
For specific carefully chosen float inputs, a hardware FMA that computes (a × b) + c in a single rounding step produces a result that differs from a software implementation that does round(round(a × b) + c) (two rounding steps) by exactly 1 ULP (unit in the last place — the least significant bit of the mantissa). CPUs with dedicated FMA hardware (Intel Haswell and later, AMD Piledriver and later, ARMv7-A and later with VFPv4) always produce the single-rounding result. Software emulation or older CPUs without hardware FMA produce the two-rounding result.
For MCP server deployments, this matters because Wasm execution environments vary: a browser on a modern workstation, a browser on an older laptop, or a headless testing environment running Wasm via Wasmtime or Wasmer may produce different results. An MCP server that probes this can determine the execution environment with meaningful precision.
// 1-ULP FMA precision probe
// These specific float32 values produce a 1-ULP difference between HW and SW FMA
// Derived from Boldo & Muñoz 2006: "A Correctly Rounded Implementation of HMAC-SHA1"
const A = 0x3DCCCCCD; // f32 representation of ~0.1
const B = 0x3DCCCCCD; // same
const C = 0x3F800000; // f32 1.0
// Hardware FMA: (0.1 × 0.1) + 1.0 with single rounding
// = 1.00999999046325684... → rounds to 0x3F80A3D7 = 1.0100000149...
// Software (mul-then-add): round(0.01) + 1.0 with two roundings
// = 1.00999999046325684... → same first rounding, second rounding differs at ULP
// = 0x3F80A3D8 = 1.0100000500...
async function probeHardwareFMAGeneration() {
const { instance } = await WebAssembly.instantiate(buildFmaPrecisionModule());
const result = instance.exports.fma_precision_probe();
const HW_RESULT = 1.0100000149011612; // 0x3F80A3D7
const SW_RESULT = 1.0100000500679016; // 0x3F80A3D8
const diff_hw = Math.abs(result - HW_RESULT);
const diff_sw = Math.abs(result - SW_RESULT);
if (diff_hw < diff_sw) return 'hardware FMA unit (Intel Haswell+, AMD Piledriver+, ARMv7-A+)';
if (diff_sw < diff_hw) return 'software FMA or pre-FMA CPU (pre-2013 x86, emulator)';
return 'ambiguous (result equals both within float tolerance)';
}
// This probe also differentiates browser/engine emulation from real hardware:
// Headless Wasm runtimes that emulate FMA in software (rare but exists in some
// embedded/WASI environments) will show the software result even on modern CPUs
Attack 4: relaxed_swizzle — one instruction, one bit, instant x86/ARM discrimination
i8x16.relaxed_swizzle(v, indices) rearranges the 16 bytes of vector v using the 16 bytes of indices as a lookup table. The browser maps this to the native shuffle instruction: pshufb on x86, vtbl (or tbl on ARM64) on ARM. The difference in out-of-range index handling is the fingerprinting surface.
For index values 16–127 (out of the 0–15 valid range for a 16-element vector): pshufb on x86 uses only the low 4 bits of each index byte when bit 7 is clear, so index 17 (binary: 00010001) returns lane 17 mod 16 = lane 1. vtbl on ARM returns 0 for any index ≥ 16 (out of table bounds). This is a perfect 1-bit discriminator: send index 17, get lane 1 on x86, get 0 on ARM.
// relaxed_swizzle out-of-range index probe: single instruction x86/ARM discriminator
async function probeSwizzle() {
const { instance } = await WebAssembly.instantiate(buildRelaxedSwizzleModule());
// Module runs: i8x16.relaxed_swizzle([0,1,2,...,15], [17,0,0,...,0])
// → extract byte 0 of result
const lane0 = instance.exports.swizzle_probe();
// x86 pshufb: index 17 = 0x11 → bit7=0, low4=0001 → lane 1 → value 1
// ARM tbl/vtbl: index 17 ≥ 16 → out of range → 0
if (lane0 === 1) return 'x86 (Intel Core or AMD Ryzen — pshufb low-4-bits behavior)';
if (lane0 === 0) return 'ARM (Apple Silicon, Snapdragon, Exynos — vtbl out-of-range=0)';
return 'unknown (unexpected result: ' + lane0 + ')';
}
// Execution time: <1µs — evaluates a single Wasm instruction
// No network request, no permission, no allocations beyond the Wasm module itself
CPU identification table
The following table shows the expected result for each probe on common consumer silicon. This is the lookup table an MCP server would use to map probe results to CPU identity:
| CPU Family | FMA denormal | relaxed_min NaN | FMA 1-ULP | swizzle [17] | Identification |
|---|---|---|---|---|---|
| Intel Core (Haswell–Meteor Lake) | 0.0 (DAZ) | 0.0 (minps) | HW result | 1 (pshufb) | x86, Intel, modern |
| AMD Ryzen (Zen, Zen 2, Zen 3, Zen 4) | 0.0 (DAZ) | 0.0 (minps) | HW result | 1 (pshufb) | x86, AMD, modern |
| Apple Silicon M1–M4 | ~1.96e-90 (NEON) | NaN (0x5FC80000 payload) | HW result | 0 (tbl OOB) | ARM64, Apple, AArch64 |
| Qualcomm Snapdragon X Elite/Plus | ~1.96e-90 (NEON) | NaN (generic default) | HW result | 0 (tbl OOB) | ARM64, Qualcomm, laptop |
| Samsung Exynos (Cortex-A based) | ~1.96e-90 (NEON) | NaN (default-NaN, 0x0) | HW result | 0 (tbl OOB) | ARM64, Samsung, mobile/laptop |
| Intel/AMD pre-Haswell (pre-2013) | 0.0 (DAZ) | 0.0 (minps) | SW result | 1 (pshufb) | x86, legacy, no HW FMA |
| Wasmtime/Wasmer emulation | varies (host CPU) | varies (host CPU) | SW result (common) | varies | Wasm runtime, not browser |
Apple Silicon and Qualcomm Snapdragon X both appear as "ARM64 denormal-preserved, NaN-propagating, hardware FMA, swizzle OOB=0" — they are distinguishable only at the NaN payload level (Attack 2 detail), which requires reading the exact bit pattern rather than just testing isNaN(). Both families are ARM-family Wasm execution environments, so the distinction matters for accurate fingerprinting but not for the coarse architecture identification. Intel and AMD are indistinguishable from this probe set alone — distinguishing them requires additional probes (such as AVX-512 feature detection or specific latency characteristics).
Why MCP servers are the delivery vehicle
An MCP server is a process that runs code on a client machine and returns results to the Claude client (Claude Code, Claude.ai, third-party MCP clients). A malicious or compromised MCP server can deliver Wasm module bytes as part of a tool result — wrapped in a JSON payload, a data URI, or a base64 blob — and request that the client execute it locally. Claude Code and Claude.ai clients that evaluate Wasm from tool output expose the user's CPU architecture before any network-visible fingerprint is created.
The exfiltration path requires only that the Wasm module return the probe result to JavaScript, and that JavaScript include it in the next MCP tool call's request payload. The entire probe-and-report cycle happens client-side, synchronously, in microseconds, with no console output and no network request that CSP can intercept. The probe produces 7 bits of hardware entropy that the MCP server logs without the user's knowledge or consent. See the Wasm Relaxed SIMD security reference for the detailed attack implementation.
Static analysis limitation: Standard MCP security scanners check for JavaScript-level fingerprinting APIs — navigator.platform, navigator.hardwareConcurrency, Intl.DateTimeFormat().resolvedOptions().timeZone, screen dimensions. None of these are present in a Relaxed SIMD fingerprinting attack. The probe is entirely inside a Wasm binary blob, which current static analysis tools do not decompile and analyze for privacy-sensitive behavior. SkillAudit scans for Relaxed SIMD opcode prefixes in Wasm payloads as a medium-severity fingerprinting indicator.
Why standard defences don't help
Private/Incognito mode: Does not change CPU architecture. The probe returns identical results in private mode.
Privacy-focused browsers (Brave, Firefox with fingerprinting resistance): Fingerprinting resistance in these browsers typically targets JavaScript APIs like navigator.platform, canvas rendering, WebGL parameters, and font enumeration. None of them randomize or normalize Wasm instruction output — doing so would break deterministic Wasm programs and violate the Wasm spec for non-Relaxed instructions. Relaxed SIMD behavior is explicitly spec-allowed to vary per implementation, so there is no hook for browsers to intercept and normalize it.
VPNs and IP rotation: Hardware fingerprints are local. A VPN changes the attacker's view of your IP address, not their view of your CPU. The Wasm probe result is the same behind any VPN.
Virtual machines: Wasm running in a VM inherits the host CPU's SIMD behavior. A Wasm module running in Chrome on a VM hosted on an Intel Xeon will show x86 FMA behavior even though the VM guest is logically isolated from the host. Only VMs that explicitly emulate a different CPU (like Apple Rosetta 2, which translates x86 apps to run on Apple Silicon) hide the underlying architecture.
Defences that actually work
Feature detection gating in MCP clients: MCP clients that process tool output before rendering it can detect Wasm payloads (MIME type, magic bytes 00 61 73 6D) and check for Relaxed SIMD opcode prefixes (the 0xFD extension byte followed by opcodes 256–328, which are the Relaxed SIMD range). Payloads containing Relaxed SIMD instructions can be flagged for user review before execution.
Disable Relaxed SIMD in Electron-based MCP clients: Electron applications control their V8 command-line flags. Adding --no-experimental-wasm-relaxed-simd to the V8 flags passed at startup disables the Relaxed SIMD instruction set entirely. Wasm modules that use Relaxed SIMD opcodes will fail to instantiate with a WebAssembly.CompileError. This removes the attack surface at the cost of Relaxed SIMD performance optimization for legitimate Wasm in the same client.
Cross-origin sandboxed Workers for tool output Wasm: Running Wasm from tool output in a Web Worker with Content-Security-Policy: wasm-unsafe-eval 'none' (blocking dynamic Wasm compilation) prevents injected Wasm modules from running. For MCP clients that need to run Wasm from trusted tool sources, an allowlist of SHA-256 hashes of expected Wasm modules can gate execution without blocking all Wasm.
Audit Wasm in MCP tool output: SkillAudit's scanner decompiles Wasm payloads delivered in MCP tool responses and flags any use of Relaxed SIMD instructions as a medium-severity fingerprinting indicator. Server-side Wasm that runs in Node.js or Wasmtime is typically not affected (no browser SIMD hardware exposure), but client-side Wasm in browser-based MCP clients is the active attack surface. See the Wasm Component Model security reference for WASI-specific attack surfaces, and WebGPU API security for GPU-level fingerprinting vectors that complement Relaxed SIMD CPU fingerprinting.
Conclusion
Relaxed SIMD is a legitimate performance feature that was designed by people who cared about Wasm performance, not about fingerprinting. The security consequence was not part of the design discussion. That is exactly the pattern that creates durable attack surfaces: a feature that is genuinely useful for its intended purpose has an unintended side channel that is difficult to close without removing the feature entirely.
For MCP server developers: do not include Wasm in tool output unless the Wasm binary serves a clear purpose that cannot be accomplished in pure JavaScript. Prefer deterministic SIMD over Relaxed SIMD — the performance difference is small for most tool workloads, and deterministic SIMD exposes no CPU information. For MCP client developers: audit Wasm payloads from tool output, gate on hash allowlists for expected Wasm, and consider disabling Relaxed SIMD at the V8 flag level if your user base prioritizes privacy over SIMD performance.
Seven bits of stable, session-persistent CPU entropy from a probe that completes in 10 microseconds is not a theoretical risk. It is a practical capability that any MCP server operator can deploy today, invisibly, without any indication in the tool output that the user would recognize as suspicious.