← Back to all posts

WebAssembly Runs Identically Everywhere, Except Where It Leaks Your CPU

WebAssembly is deterministic except for NaN bit patterns and relaxed SIMD, and both leak whether the CPU underneath is ARM or x86. How a browser claiming Apple Silicon on an x86 server gets caught by a 30-byte module, and how to emit ARM's bits across every V8 compiler tier.

Summarize this article with

WebAssembly1’s promise is determinism: the same bytes produce the same result on every machine. That is almost true, and the exceptions are where it gets interesting. The spec carves out a few spots where the result is allowed to depend on the hardware. Those exact spots reveal whether you are on ARM or x86, which is enough to unmask a browser that claims to be an Apple Silicon Mac while running on an x86 server.

WASM’s two escape hatches

The WebAssembly spec is deterministic except for two documented cases.

  1. NaN2 bit patterns. When a float op produces NaN, the spec pins the exponent and the quiet bit but leaves the sign bit and payload to the implementation. The hardware fills them in, and hardware disagrees.
  2. Relaxed SIMD3. The relaxed-SIMD proposal, shipped in Chrome, trades determinism for speed on a set of vector ops: relaxed min and max, relaxed multiply-add, relaxed truncation, relaxed swizzle. Their behavior on the edges (NaN, overflow, signed zero) is defined as whatever the CPU does.

Both are deliberate. Both are a fingerprint.

Reading the CPU through a NaN

Divide zero by zero in WASM, or subtract infinity from infinity, and read the bits of the result.

  • x86 default quiet NaN4: 0xFFF8000000000000 (sign bit set). 32-bit: 0xFFC00000.
  • ARM default quiet NaN: 0x7FF8000000000000 (sign bit clear). 32-bit: 0x7FC00000.

One bit. A browser presenting as macOS on Apple Silicon, with a matching User-Agent, client hints, and GPU string, that returns the x86 NaN has just told you it runs on x86. An anti-bot ships a 30-byte WASM module, runs one division, and reads the sign.

It is not even WASM-specific. V8 exposes the exact same difference in plain JavaScript. Produce a NaN at runtime (so it is not constant-folded) and read its bytes through a typed array5:

let z = 0;                                  // runtime, not a literal
const f = new Float64Array([z / z]);        // 0/0 -> NaN
const sign = new Uint32Array(f.buffer)[1] >>> 31;
// sign === 1 on x86, sign === 0 on ARM

Number is an IEEE-754 double, and a Float64Array aliased as Uint32Array hands back the raw bytes, sign bit included. No WASM required. So the fix cannot live in the WASM compiler alone. It has to cover V8’s ordinary JavaScript float pipeline too, which is what makes the tiering below hard.

The relaxed-SIMD tells

f32x4.relaxed_min: on x86, minps returns the second operand when the inputs are NaN or equal. On ARM, fmin propagates NaN and orders signed zeros so that -0 < +0. Feed it a NaN, or a (-0, +0) pair, and the lanes differ.

i32x4.relaxed_trunc_f64x2: converting an out-of-range or NaN float to an integer. ARM fcvtzs saturates (NaN becomes 0, overflow clamps to INT_MIN or INT_MAX). x86 cvttpd2dq returns INT_MIN for every invalid input. One probe of trunc(NaN) and trunc(1e300) separates the two.

These are not bugs. The “relaxed” in relaxed SIMD means the spec chose speed over portability, and the cost is that the CPU shows through.

The fix is to emit ARM’s bits on x86

Disabling WASM is itself a tell. Adding noise is worse: a NaN whose sign flips between runs is not a real machine, and a relaxed-min that is sometimes right and sometimes random matches no CPU. The target is exact: on an ARM persona, the x86 build has to produce the bits an Apple Silicon chip produces, gated on the spoofed CPU so an x86 persona pays nothing.

Start with the NaN sign. Clear it, but only on the lanes that are actually NaN:

// x86 quiet-NaN 0xFFF8... -> ARM 0x7FF8..., NaN lanes only.
void canon_simd_nan_f64x2(XMMRegister v, XMMRegister scratch) {
  if (!g_arm_persona) return;
  Movapd(scratch, v);
  Cmpunordpd(scratch, scratch, scratch);   // mask = the NaN lanes
  Psllq(scratch, scratch, 63);             // -> sign-bit mask
  Andnpd(scratch, scratch, v);             // clear sign on NaN lanes only
  Movapd(v, scratch);
}

cmpunordpd builds a mask of the NaN lanes, psllq 63 turns it into a sign-bit mask, and andnpd clears the sign only where the lane is NaN. Every other lane passes through untouched.

For relaxed min and max, emit the NaN-aware version instead of the bare instruction:

case kMinF32x4:
  if (g_arm_persona) {
    // ARM fmin propagates NaN and orders -0 < +0; x86 minps does neither.
    emit_nan_aware_min_f32x4(out, in0, in1, scratch);
    canon_simd_nan_f32x4(out, scratch);
  } else {
    emit_minps(out, in0, in1);   // native x86 path, untouched
  }

Saturating truncation is the same idea: on an ARM persona, emit a conversion that saturates NaN to 0 and clamps overflow, instead of x86’s blanket INT_MIN.

The tier problem

V8 is not one compiler. It is Liftoff6 (the baseline WASM compiler), TurboFan (the optimizer), and snapshot-built bytecode handlers baked in at build time. A NaN can come out of any of them, so the spoof has to cover all of them.

That breaks a simple compile-time if. The snapshot handlers were compiled when the ARM flag was false, so a build-time gate never fires in them. The scalar float64 canonicalization7 has to be gated at runtime, through a flag the generated code reads by address:

ExternalReference ExternalReference::address_of_arm_persona() {
  return ExternalReference(reinterpret_cast<Address>(&g_arm_persona));
}

The generated code loads that address, tests the flag, and skips the whole canonicalization on an x86 persona (one not-taken branch). That same runtime check runs after every scalar float64 op, add, sub, mul, div, so the WASM path and the plain-JS 0/0 typed-array probe both come out with ARM’s sign. SIMD8 is simpler: SIMD128 only appears in WASM, which is compiled at runtime after the flag is set, so a compile-time gate is correct there.

One case slips past runtime gating entirely. A constant folded at JIT time, like 0.0/0.0, embeds the x86 NaN bits directly into the machine code before any runtime check runs. That one is caught by rewriting the constant as it is emitted:

inline uint64_t CanonNaN64(uint64_t bits) {
  if (g_arm_persona && bits == 0xFFF8000000000000ull) {
    return 0x7FF8000000000000ull;
  }
  return bits;   // array-hole sentinel, undefined-NaN, custom payloads untouched
}

Notice the exact match. V8’s array-hole sentinel and its undefined-NaN have their own bit patterns, and rewriting them would corrupt the engine’s internal invariants. Only the one specific x86 quiet-NaN maps to the one specific ARM quiet-NaN. Nothing else moves.

Why it matters

CPU architecture is one of the hardest things to fake, because it is not a string you set. It is the behavior of the silicon, showing through every place a spec allows nondeterminism. WASM concentrates those places into a short list: NaN signs and relaxed SIMD. That makes them cheap to probe and, for a spoof that has not handled them, reliable to catch. A profile that claims Apple Silicon has to back it up in the one layer that talks straight to the hardware.

Getting it right takes covering every compiler tier V8 has, gating at runtime where the snapshot reaches, canonicalizing at constant-emission time where the JIT folds, and touching only the exact bits that differ while leaving the engine’s own sentinels alone.

Scrapium is Scrapfly’s scraping browser, built to stay indistinguishable from real traffic. Our engineering blog goes deep on the rest of the stack.


  1. WebAssembly (WASM): a portable bytecode format browsers execute at near-native speed. Deterministic except for two documented cases: NaN bits and relaxed SIMD. Reference: webassembly.org↩︎

  2. NaN (not a number): the IEEE 754 result of undefined operations like 0/0 or inf - inf. The spec fixes the exponent and quiet bit but leaves the sign and payload to the hardware. Reference: Wikipedia↩︎

  3. relaxed SIMD: a shipped WASM proposal that trades determinism for speed on some vector ops, defining edge behavior (NaN, overflow, signed zero) as whatever the CPU does. Reference: WebAssembly/relaxed-simd↩︎

  4. quiet NaN: a NaN that propagates through arithmetic without signaling an exception. x86 canonicalizes it with the sign bit set (0xFFF8...), ARM with it clear (0x7FF8...). Reference: Wikipedia↩︎

  5. typed array (Float64Array / Uint32Array): JavaScript views over the same raw bytes. Aliasing a Float64Array as Uint32Array reads a double’s sign bit directly, no WASM needed. Reference: MDN↩︎

  6. Liftoff and TurboFan: V8’s baseline (fast-to-compile) and optimizing WASM compilers. A NaN can come out of either, plus snapshot-built handlers, so the spoof has to cover all tiers. Reference: v8.dev↩︎

  7. canonicalization: normalizing a NaN to a standard bit pattern. Here it means mapping the x86 quiet-NaN to ARM’s, on NaN lanes only, without touching the engine’s own sentinels. Reference: Wikipedia↩︎

  8. SIMD (single instruction, multiple data): one instruction operating on several lanes at once. WASM’s 128-bit vector type is SIMD128. Reference: Wikipedia↩︎