RC RANDOM CHAOS

tanh now fingerprints your OS

Since Chrome 148, Math.tanh returns different bits on Linux, macOS and Windows, giving anti-bot systems a per-OS fingerprint that is cheap to probe.

· 6 min read
tanh now fingerprints your OS

Run Math.tanh(0.8) in a Chrome console and the answer depends on the operating system underneath. Linux returns 0.6640367702678491, macOS returns 0.664036770267849, and Windows returns 0.6640367702678489. The engine and the input are identical; only the last bits differ.

That value is an approximation, and its exact bits come from whichever C math library the OS ships. Linux computes tanh through glibc, macOS through Apple’s libsystem_m, Windows through the Universal C Runtime in ucrtbase.dll. glibc and Apple’s library disagree on about a quarter of all inputs, usually by one unit in the last place. Windows disagrees with both on a few percent of inputs. On 0.8 all three land on a different bit, which makes that one call a per-OS signature. A browser that claims macOS in its User-Agent and returns the Linux tanh bits has contradicted itself.

The input has to be chosen carefully. tanh(0.5) is one of the roughly three-in-four inputs where all three libraries agree, so it tells a detector nothing. tanh(0.8) separates all three at once. Scrapfly measured this on genuine Chrome 150 over the DevTools protocol across a real Linux box, macOS 26 on Apple Silicon, and Windows 11.

The leak is new. Through Chrome 147, V8 computed tanh with its own bundled fdlibm port, so the function returned identical bits on every OS and leaked nothing. V8 commit c1486295ae5 swapped that for std::tanh, which reads the host libm. It shipped in V8 14.8.57, which is Chrome 148. Chrome 148, 149 and 150 leak; anything earlier does not.

IEEE 754 fixes how a double is stored. It does not require sin, cos, tanh or exp to be correctly rounded, because correct rounding is expensive. Every vendor ships a libm that trades a fraction of a ULP for speed, with its own minimax coefficients, lookup tables and reduction constants. The three libraries agree almost everywhere and split just often enough to classify the host. A detector needs no math of its own, only a lookup table: one pattern of bits for genuine macOS Chrome, another for Linux, and a single comparison between them.

Why spoofing it is harder than it looks

Faking the value is harder than reimplementing one function, for four reasons.

Only one JavaScript Math function leaks. V8 links its own math statically: exp, pow, atan and most of the rest come from a bundled llvm-libc, and sin and cos from a bundled glibc-derived dbl-64 routine. All of them return identical bits on every OS, so changing any of them creates an inconsistency rather than hiding one. tanh is the exception since Chrome 148, and that asymmetry, one Math function that tracks the OS while the rest do not, is itself something a detector can check.

CSS trig is a separate code path. CSS sin(), cos() and atan2() do not share code with Math.sin. Blink reduces the angle in degrees and then calls the platform std::sin on the reduced value, which gives a different result from a direct radian sin and hits the host libm directly. All seven CSS trig functions leak the OS, everywhere.

macOS carries two math libraries that disagree with each other. Apple Silicon has scalar libsystem_m and the Accelerate framework’s vector routines. Across a million inputs they diverge on 10 to 89 percent of them depending on the function. cos(0) is exactly 1.0 from the scalar library and 0.9999999999999999 from Accelerate. So reproducing Apple’s math is undefined until you know which library backs each call site. Scrapfly resolved it by driving real Chrome on a real Mac and reading the doubles: scalar libsystem_m backs Math.tanh, CSS trig and the Web Audio compressor’s per-sample transcendentals, while Accelerate backs the Mac Web Audio DSP, the FFT, the vector math and the biquad filters. One audio graph on a Mac touches three libraries. Pick the wrong one for a given call and you land 1 ULP off on most inputs, which is worse than leaving it alone.

Architecture leaks too. ARM and x86 differ on fused multiply-add and on NaN sign propagation, so a reproduction that is correct on paper drifts when the compiler fuses a multiply-add on one target but not the other.

Where the tells cluster

JavaScript Math leaks in exactly one place, tanh. CSS trig leaks everywhere, because Blink calls the host libm for every function. Web Audio on a Mac leaks on two axes at once, with the Accelerate FFT carrying the CPU architecture and the compressor’s scalar libsystem_m carrying the OS. WASM does not leak the OS at all: it has no transcendental opcodes, so its sin comes from whatever libm the module bundled and its arithmetic is hardware. Its only fingerprint axis is the ARM-versus-x86 split in NaN canonicalization and a few SIMD roundings.

Closing it

If you are the one being fingerprinted, the obvious move is ruled out. Adding noise to the output fails twice: a reference comparison sees a value that matches no real OS, and per-call randomness breaks the determinism real math has, which is its own tell. The target is a value bit-identical to the OS you claim, and noise cannot produce that.

That leaves reproducing the library. Recover the target’s minimax coefficients, exponent tables and reduction constants from its libm and transcribe them to portable C, matching every bit including the inputs where the target rounds the wrong way. Coefficients have to be copied as hex bit patterns, since a decimal transcription rounds differently. Determinism across CPUs comes from compiling with FMA contraction off (-ffp-contract=off), so the only fused multiply-adds are the ones the original emits; hardware FMA and correctly-rounded software FMA then return the same bits on ARM and x86 alike.

For Windows there is a shortcut. UCRT is x86-64 and position-independent, the same ISA as a Linux server, so you can map genuine ucrtbase.dll into the process and call its exports directly. The bits are genuine because the code is. Two details decide whether that works. The function pointers have to be declared ms_abi, because UCRT expects the Windows x64 calling convention, with 32 bytes of shadow space and a different callee-saved set than System V, and without it the callee’s shadow-space writes corrupt the frame. And every UCRT math function opens by reading a CPU-dispatch flag that picks the scalar or the FMA/AVX2 path; a fresh mapping leaves it zero, giving the slow path whose bits differ from a modern Windows box, so you force it to the FMA path before the first call.

Then hook the one function where the engine calls libm and gate it on the claimed OS: Linux keeps glibc, a Mac identity gets the reproduction. Watch the clock while you do it. A correct reproduction that runs slow is still a tell. Scrapfly’s first build lowered every fma() to a software call and ran 2.5 to 6 times slower than native, which a loop timing tanh against sin would expose as a ratio no real browser shows. Turning on hardware FMA made each fused op a single instruction, about six times faster and bit-identical.

Scrapfly says it validates all of this against 871,000 inputs per release across dense grids, interval boundaries, subnormals, signed zeros, infinities and NaNs, checked against two ground truths: a real Mac computing both scalar and Accelerate results, and real Chrome on a real Mac read over the debugging protocol. It claims bit-for-bit parity with genuine Mac Chrome on Math.tanh and on CSS sin, cos, tan, asin, acos, atan and atan2, including domain edges like asin(2), which a real Mac resolves to 0 because CSS clamps the out-of-domain NaN to zero rather than returning 90 degrees.

For a defender the economics favour the probe. A single tanh call plus a lookup table flags any browser whose math does not match its claimed OS, and for the three current Chrome versions that leak it costs nothing to run.

Share

Keep Reading

Latest on the Wire

Full wire →

New signal daily · RSS

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.