自 Chromium 148 版本起,Math.tanh 已可被用于指纹识别,从而关联底层的操作系统。
Since Chromium 148, Math.tanh is now fingerprintable to link underlying OS

原始链接: https://scrapfly.dev/posts/browser-math-os-fingerprint/

浏览器指纹识别已不再局限于 Canvas 和字体,而是演变为通过检测数学计算中的细微差异来进行识别。由于 JavaScript 的 `Math` 库和 CSS 三角函数依赖于底层操作系统的数学库(libm),它们在 Linux、macOS 和 Windows 上往往会产生略有不同的结果。 例如,`Math.tanh(0.8)` 返回的位级近似值会根据系统使用的是 glibc (Linux)、libsystem_m (macOS) 还是 UCRT (Windows) 而有所不同。尽管 V8 引擎过去曾内置自己的数学例程,但近期版本已改为调用宿主系统的库,从而为特定函数创建了独特的操作系统“签名”。 为了保持与真实浏览器无法区分,Scrapfly 等反指纹工具必须实现与目标操作系统完全一致的逐位输出。这需要: 1. **精确复刻**:对特定供应商的系数和缩减常数进行逆向工程。 2. **确定性执行**:禁用编译器驱动的融合乘加(FMA)收缩,以确保不同 CPU 架构间的一致性。 3. **库映射**:在无法复刻时,直接调用宿主的二进制数学库(例如映射 `ucrtbase.dll`)。 若无法匹配这些“最后一位”的签名,将会产生追踪器用于识别自动化机器人的不一致性。实现完美的数学对等性,如今已成为隐身工程中关键且复杂的一环。

一份最新的报告显示,自 Chromium 148 版本以来,`Math.tanh()` 函数在不同操作系统上产生的结果存在细微差异。这种差异成为了一种“指纹识别”向量,使网站无论用户的 User-Agent 标头如何,都能识别其操作系统乃至浏览器版本。 Hacker News 上的讨论主要集中在以下三个方面: * **指纹识别的伦理:** 批评者认为,该文章(旨在推广一种网络爬虫服务)属于“大模型生成的垃圾内容”,通过支持工业规模的数据抓取并绕过反爬虫措施,加剧了互联网的“垃圾化”。 * **技术现实:** 许多参与者指出,这种特定的数学特性只是浏览器被指纹识别的无数种方式之一。TCP 协议栈行为、渲染差异以及各种硬件专用 API,使得在不破坏网站功能的情况下,几乎不可能完美隐藏操作系统或浏览器身份。 * **隐私的未来:** 社区讨论了指纹识别是否应该被定为非法。一些人认为这是一种等同于非法窃听的监视工具;另一些人则认为,虽然该机制被滥用,但它对于欺诈检测和网站安全来说是必要的。有人建议,浏览器应主动在这些输出中加入“噪声”或进行标准化,以保护用户匿名性。
相关文章

原文

Fingerprinting is usually about canvas, WebGL, fonts, audio. There is a quieter signal, and it lives in the last bits of a number.

Run this in any console:

Math.tanh(0.8)
// 0.6640367702678491   genuine Linux Chrome    (glibc)
// 0.664036770267849    genuine macOS Chrome     (libsystem_m)
// 0.6640367702678489   genuine Windows Chrome   (UCRT)

That is not a constant. It is an approximation, and the exact bits depend on the OS that computed it. A genuine Mac runs Math.tanh through Apple’s math library. Linux runs it through glibc. The two disagree on about a quarter of all inputs, usually by one unit in the last place (1 ULP). Windows, through the Universal C Runtime, disagrees with both on a few percent, and on the input above all three land on a different bit.

The same call, run on genuine Chrome 150 across three real machines:

CallLinux (glibc)macOS (libsystem_m)Windows (UCRT)Split
Math.tanh(0.5)0.462117157260009740.462117157260009740.46211715726000974all three agree
Math.tanh(0.7)0.60436777711716360.60436777711716350.6043677771171635Linux alone, 1 ULP
Math.tanh(0.8)0.66403677026784910.6640367702678490.6640367702678489all three differ, 2 ULP spread
Math.tanh(0.9)0.71629787019902450.71629787019902450.7162978701990244Windows alone, 1 ULP

Measured over the DevTools protocol on Chrome 150: Linux (glibc), macOS 26 on Apple Silicon (libsystem_m), Windows 11 (ucrtbase.dll). tanh(0.5) is one of the roughly three-in-four inputs where everyone agrees, which is exactly why it makes a useless probe. tanh(0.8) is one that separates all three at once.

One tanh call on the right input is a per-OS signature. Claim macOS, return Linux math bits, and you have contradicted your own User-Agent.

This tell is recent. Until Chrome 148, V8 computed tanh itself with a bundled fdlibm port, so it returned the same bits on every OS and leaked nothing. V8 commit c1486295ae5 replaced it with std::tanh, which reads the host libm. It first shipped in V8 14.8.57, which is Chrome 148. Chrome 147 and earlier do not leak here. Chrome 148, 149, and 150 do.

Scrapfly ships a browser that has to match a real one across hundreds of signals. Math is one of the harder ones. Here is why, and how we close it.

Why one function returns different bits

IEEE 754 defines how a double is stored. It does not require sin, cos, tanh, or exp to be correctly rounded. Correct rounding is expensive, so every vendor ships a libm that trades a fraction of a ULP for speed, with its own minimax coefficients, lookup tables, and reduction constants.

Three implementations, three sets of bits:

  • Linux: glibc
  • macOS: Apple libsystem_m
  • Windows: UCRT (ucrtbase.dll)

They agree almost everywhere and split just often enough to classify the OS. A detector needs no math. It needs a table: genuine macOS Chrome returns this pattern for cos(1), genuine Linux Chrome returns that one. One probe, one comparison.

Four traps

“Just reimplement the Mac functions” breaks on contact. Four reasons.

1. Only some math leaks. V8 ships its own math and links it statically: Math.exp, Math.pow, Math.atan, and most of the rest come from bundled llvm-libc, and Math.sin / Math.cos from a bundled glibc-derived dbl-64 routine. All of them are identical on every OS, so spoofing them creates an inconsistency. The exception is Math.tanh: since Chrome 148 V8 computes it with the platform std::tanh instead of the bundled routine it used before, so it now reads the host libm. It is the only Math.* that leaks the OS, and that asymmetry is itself checkable.

2. JavaScript math and CSS math are different code paths. CSS sin(), cos(), and atan2() do not share code with Math.sin. The layout engine reduces the angle in degrees, then calls platform std::sin on the reduced value. That gives a different result than a direct radian sin(), and it hits the host libm, so all seven CSS trig functions leak. We reproduced the degree reduction and the radians-to-degrees step bit-for-bit, not just the leaf function.

3. macOS has two math libraries that disagree. Apple Silicon carries scalar libsystem_m and the Accelerate framework’s vector routines (vvsin, vvtanh). They are different code. Across a million inputs they diverge on 10 to 89 percent, depending on the function. Take cos(0): scalar returns exactly 1.0, Accelerate returns 0.9999999999999999. So “reproduce Apple’s math” is undefined until you know which library the browser calls, at which site. We resolved it by driving real Chrome on a real Mac over the debugging protocol and reading the exact double. Answer: scalar libsystem_m backs Math.tanh, CSS trig, and the audio compressor’s per-sample transcendentals. Accelerate backs Chrome’s Web Audio DSP on Mac, the FFT, the vector math, and the biquad filters (fft_frame_mac.cc, vector_math_mac.h, biquad.cc, all BUILDFLAG(IS_MAC)). Pick the wrong library for a given call site and you land 1 ULP off on most inputs, worse than not spoofing.

4. Architecture leaks. ARM and x86 differ on fused-multiply-add and on NaN sign propagation. A reproduction that is correct on paper drifts if the compiler fuses a multiply-add on one target and not the other.

The map: what leaks where

Put the routing on one page. Bold is the host libm (glibc, Apple libsystem_m, or UCRT), the code that leaks the OS. Everything else is identical on every machine and safe to leave alone.

OperationV8 Math.* (JS)CSS calc()Web Audio
sin cos tanV8 bundledhost libmAccelerate (osc FFT), sin scalar in the compressor
asin acos atan atan2V8 bundledhost libmnot used
tanhhost libmnonenot used
expV8 bundledhost libmscalar in the compressor
log log2 log10 powV8 bundledhost libmscalar log10f / powf in the compressor
vector add/mul/scale, FFTn/an/aAccelerate (vDSP) on Mac
sqrt abs + - * /hardwarehardwarehardware

V8 bundled = statically linked and identical on every OS: llvm-libc for most functions, a glibc-derived dbl-64 routine for sin/cos. host libm = the platform library that leaks the OS (libsystem_m on Mac, glibc on Linux, UCRT on Windows). Accelerate = Apple’s vDSP, which Chrome uses for the Mac Web Audio DSP.

Three things stand out. First, V8 routes almost everything through its own bundled math, so JavaScript Math is a tell in exactly one place: Math.tanh. Second, CSS is a tell everywhere, because Blink calls the host libm directly for every trig function. Third, Web Audio on Mac runs on Accelerate for the FFT and the vector stages, while the DynamicsCompressor’s per-sample transcendentals stay scalar libsystem_m. Three different libraries in one audio graph.

WASM is not in the table because it has no transcendental opcodes. sin and friends come from whatever libm the module bundled, and its arithmetic (f64.sqrt, f64.mul) is hardware, so WASM math is identical on every OS. Its only fingerprint axis is the ARM-versus-x86 split in NaN canonicalization and a few SIMD roundings.

The tells cluster in three surfaces: Math.tanh, every CSS trig function, and Web Audio (the Accelerate FFT, which carries the CPU architecture, plus the compressor’s scalar libsystem_m, which carries the OS). That is the whole target.

How to close it

No noise. Perturbing the output fails twice. A reference comparison sees a value that matches no real OS, and per-call randomness breaks determinism, which is its own tell. The target is not “different from Linux.” It is “identical to the OS you claim.”

Reproduce the algorithm exactly. Recover the target’s minimax coefficients, exponent tables, and reduction constants from its libm, and transcribe them to portable C. Match every bit, including the inputs where the target rounds the wrong way. You are not building a good tanh. You are building theirs. Here is Apple’s sin polynomial, coefficients pulled straight out of libsystem_m:

// Every fused multiply-add Apple emits is written as an explicit fma(). The
// bit pattern of each coefficient is copied verbatim; a decimal transcription
// would round differently.
static const double P[6] = {
     0x1.5d8fd1fd19ccdp-33, -0x1.ae5e5a9291f5dp-26,  0x1.71de3567d48a1p-19,
    -0x1.a01a019bfdf03p-13,  0x1.111111110f7d0p-7,  -0x1.5555555555548p-3,
};
static double sin_poly(double x2) {
  double p = fma(x2, P[0], P[1]);
  p = fma(x2, p, P[2]);
  p = fma(x2, p, P[3]);
  p = fma(x2, p, P[4]);
  p = fma(x2, p, P[5]);
  return x2 * p;                 // caller finishes: sin(x) = fma(x, x2*p, x)
}

Make it deterministic. That explicit fma() matters. Compile with FMA contraction off (-ffp-contract=off) so the compiler never invents or drops a fusion of its own. Now the fused ops are exactly the ones Apple fuses, and the result is identical on FMA and non-FMA CPUs, and identical between the ARM machine you imitate and the x86 fleet you run on. Hardware FMA and correctly-rounded software FMA return the same bits.

When reproduction is not worth it, lift the original. Windows UCRT is x86-64, the same ISA as a Linux server, and position-independent. Map the genuine ucrtbase.dll into memory at runtime and call its exports directly. The bits are genuine because the code is genuine, no reverse-engineering required.

Calling into Windows code from a Linux binary hits the ABI boundary. UCRT is compiled for the Windows x64 convention: the callee owns 32 bytes of shadow space above the return address, and the callee-saved register set differs from System V. Declare the function pointers ms_abi or clang’s frame layout gets corrupted by the callee’s shadow-space writes, and the indirect call jumps into garbage.

// Windows x64 ABI, not System V. Without ms_abi the call crashes.
typedef double(__attribute__((ms_abi)) * D1)(double);          // tanh, sin, ...
typedef double(__attribute__((ms_abi)) * D2)(double, double);  // atan2

// The mapped DLL code is not a CFI-registered indirect-call target, so
// -fsanitize=cfi-icall (on in production) #UD-traps every call -> SIGILL at
// startup. Opt the wrappers that call through the pointers out of that check.
[[clang::no_sanitize("cfi-icall")]]
double ucrt_tanh(double x) {
  return ucrt.loaded ? ucrt.tanh(x) : std::tanh(x);
}

One more detail decides correctness. Every UCRT math function starts with mov eax, [rip+disp32], reading a CPU-dispatch flag that selects the scalar or the FMA/AVX2 code path. A fresh mapping leaves it at zero, so you get the slow path, whose bits differ from what a modern Windows box produces. Extract the flag’s address from the tanh prologue and force it to the FMA path before the first call, and the lifted library matches a real Windows machine bit-for-bit.

Patch the chokepoint, gate it. Hook the single function that owns the value, where the engine calls libm. Gate on the claimed OS: Linux keeps glibc, Mac gets the reproduction.

Watch the clock. A perfect reproduction that runs slow is still a tell. Our first build lowered every fma() to a software call, because the default x86 baseline predates hardware FMA. That ran 2.5 to 6 times slower than native. A loop timing Math.tanh against Math.sin would show a ratio no real browser has. Turning on hardware FMA cut each fused op to one instruction: about 6 times faster, faster than glibc, and bit-identical.

Validation

None of this ships without proof. Our harness runs 871,000 inputs per release across every branch and domain: dense grids, interval boundaries, subnormals, signed zeros, infinities, NaNs. Two ground truths back it:

  1. A genuine-device oracle: a real Mac computing both scalar and Accelerate results for every input, so we know exactly where the two disagree.
  2. A genuine-browser anchor: real Chrome on a real Mac over the debugging protocol, computing Math.tanh and every CSS trig function at full precision. This is the surface a fingerprinter reads.

We ship at bit-for-bit parity with genuine Mac Chrome on Math.tanh and on CSS sin, cos, tan, asin, acos, atan, and atan2, with the reproduction verified identical to the machine code in the shipped binary. Domain edges get checked too: asin(2) on a real Mac resolves to 0 (out of domain is NaN, and CSS clamps NaN to zero), not the 90 degrees a naive reproduction returns.

Why it matters

Math is deterministic, cheap to probe, hard to fake, and almost never on a spoofing stack’s radar. That makes it a strong signal for a defender and a liability for a scraper. Getting it right takes reverse-engineering vendor libm internals, mapping how three engines route math per call site, matching algorithms to the last bit, holding determinism across architectures, and proving it against real hardware.

That is the work behind Scrapfly’s browser. Send a request through our API and ask to present as macOS, and the identity holds down to the rounding of a cosine.

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

联系我们 contact @ memedata.com