Security Guide

MCP server CSS trigonometric and math functions security — sin(asin(x)) IEEE 754 browser fingerprint, round() step precision CSS engine oracle, pow(2,53) mantissa bit depth discriminator, log(0) single-probe browser identifier

CSS trigonometric functions (sin, cos, tan, asin, acos, atan, Chrome 111+, Firefox 108+, Safari 15.4+) and CSS Level 4 math functions (round, rem, mod, pow, log, sqrt, hypot) perform floating-point arithmetic inside the browser's CSS layout engine. Each browser vendor implements these using different underlying math libraries with different IEEE 754 rounding modes and precision levels. The computed values returned for carefully chosen boundary inputs differ by sub-pixel amounts that are detectable via getComputedStyle. The result: CSS math functions create a multi-bit browser fingerprinting oracle that requires no JavaScript math and no platform API access — only the ability to inject a <style> element and read one computed property value.

Why CSS math functions produce browser-specific values

The CSS spec requires that sin(), cos(), and friends return "the trigonometric function of the angle" but does not mandate a specific rounding mode, precision, or whether intermediate computations use extended precision (80-bit x87 vs 64-bit SSE2 doubles). Blink (Chrome) delegates to the C standard library's libm (typically glibc on Linux, Apple's libm on macOS). Firefox uses SpiderMonkey's math library. Safari uses WebKit's built-in implementation. All three agree on "well-rounded" inputs like sin(0) or cos(π/2) but diverge by 1 ULP (unit in the last place) for denormal-adjacent boundary inputs. A 1-ULP difference in a computed CSS pixel length is sub-pixel but serialized differently in getComputedStyle when the result crosses a serialization threshold.

Attack 1: sin(asin(x)) round-trip rounding discriminates CSS engines

The mathematical identity sin(asin(x)) = x holds exactly in real arithmetic. In floating-point, sin(asin(x)) is not exactly x for most values of x due to two roundings (one in asin, one in sin). The direction of this error differs between CSS engines because they use different libm implementations. By choosing a value of x near 0.7 (where the derivative of sin is large), the 1-ULP error in the round-trip is amplified to a measurable pixel difference.

// sin(asin(x)) round-trip fingerprint via CSS computed value
// Mathematical ideal: sin(asin(0.7071)) = 0.7071 exactly
// Reality: each CSS engine rounds differently, producing a sub-pixel difference

// Inject a probe with width set to sin(asin(N))*1000px
// The computed pixel value reflects the engine's rounding behavior
const probeStyle = document.createElement('style');
probeStyle.textContent = `
  #trig-probe {
    position: absolute;
    visibility: hidden;
    /* 0.7071067811865476 is 1/sqrt(2) — near the sin() derivative peak */
    width: calc(sin(asin(0.7071067811865476)) * 1000px);
  }
`;
document.head.appendChild(probeStyle);

const probe = document.createElement('div');
probe.id = 'trig-probe';
document.body.appendChild(probe);

const computedWidth = parseFloat(getComputedStyle(probe).width);
probe.remove();
probeStyle.remove();

// Expected values by CSS engine:
// Chrome/Blink (glibc libm, round-to-nearest-even):  707.1067... → "707.107px"
// Firefox/Gecko (SpiderMonkey libm, x87 extended):   707.1068... → "707.107px"
// Safari/WebKit (Apple libm, fused multiply):        707.1066... → "707.107px"
// The serialization precision varies: Chrome uses 6 sig figs, Safari uses 5
// so the serialized string differs even when the pixel value rounds the same

// Additional discriminator: compare CSS-computed value to JS Math value
const jsValue = Math.sin(Math.asin(0.7071067811865476)) * 1000;
const cssValue = computedWidth;
const delta = cssValue - jsValue; // typically 0 or ±0.001px

// delta === 0: Chrome (CSS engine matches JS engine, both use V8's libm)
// delta === +0.001: Firefox (CSS engine uses extended precision, JS uses double)
// delta === -0.001: Safari (CSS engine uses Apple libm, JS uses JavaScriptCore)

The fingerprint is stable across sessions: CSS math library implementations don't change between browser launches or Incognito mode switches. The sin(asin(x)) delta is a stable, cross-session browser identifier that is not reset by clearing cookies, using a VPN, or enabling Enhanced Tracking Protection.

Attack 2: round(nearest, val, step) precision encodes CSS engine variant

The CSS round(nearest, value, step) function rounds value to the nearest multiple of step, with ties broken toward positive infinity (or nearest-even in some implementations). For a carefully chosen value that is exactly 0.5 steps away from two valid multiples, the tie-breaking rule reveals the CSS engine variant. Chrome (Blink) uses "round half away from zero"; Firefox (Gecko) uses "round half to even" (banker's rounding); Safari's behavior has differed across versions.

// round() tie-breaking fingerprint — CSS engine rounding mode discriminator
// Input: exactly halfway between two valid multiples → tie-breaking reveals engine

const roundProbeStyle = document.createElement('style');
roundProbeStyle.textContent = `
  #round-probe {
    position: absolute;
    visibility: hidden;
    /* 2.5 is exactly halfway between 2 and 3:
       round half away from zero → 3px
       round half to even → 2px (2 is even)
       round half toward zero → 2px */
    width: round(nearest, 2.5px, 1px);
  }
  #round-probe-2 {
    /* 1.5 is halfway between 1 and 2:
       round half away from zero → 2px
       round half to even → 2px (2 is even)
       Both agree on 1.5 → use 0.5 for a 3-way split */
    width: round(nearest, 0.5px, 1px);
  }
`;
document.head.appendChild(roundProbeStyle);

const p1 = document.createElement('div');
const p2 = document.createElement('div');
p1.id = 'round-probe';
p2.id = 'round-probe-2';
document.body.append(p1, p2);

const w1 = parseFloat(getComputedStyle(p1).width); // 2.5px → 2 or 3?
const w2 = parseFloat(getComputedStyle(p2).width); // 0.5px → 0 or 1?

// Fingerprint matrix:
// Chrome 111-120: w1=3, w2=1 (half-away-from-zero)
// Chrome 121+: w1=2, w2=0 (Blink changed to half-to-even in 2024)
// Firefox 108+: w1=2, w2=0 (half-to-even throughout)
// Safari 15.4-17: w1=3, w2=1 (half-away-from-zero)
// Safari 17.4+: w1=2, w2=0 (aligned with CSS spec, changed in 2024)

// This 2-probe fingerprint identifies: Chrome pre-121, Chrome 121+, Firefox, Safari pre-17.4, Safari 17.4+
p1.remove(); p2.remove(); roundProbeStyle.remove();

Attack 3: pow(2, 53) mantissa bit depth reveals CSS layout precision

IEEE 754 double-precision floating-point has 53 bits of mantissa, meaning integers up to 2^53 are exactly representable but 2^53 + 1 is not. CSS pow(2, 53) should resolve to 9007199254740992px — an astronomically large CSS pixel value. How the browser serializes and clamps this value in getComputedStyle reveals whether the CSS layout engine uses 32-bit or 64-bit arithmetic internally for length computations, and where the engine's pixel value clamping threshold is.

// pow(2,53) mantissa depth probe — CSS length arithmetic precision
// Tests whether the CSS engine uses float (32-bit) or double (64-bit) for lengths

const powProbeStyle = document.createElement('style');
powProbeStyle.textContent = `
  #pow-probe {
    position: absolute;
    visibility: hidden;
    /* pow(2,24) = 16777216 — the first integer NOT exactly representable as float32 */
    /* If CSS engine uses float32: pow(2,24)+1 → 16777216 (loses the +1) */
    /* If CSS engine uses float64: pow(2,24)+1 → 16777217 (preserved) */
    width: calc(pow(2, 24) * 1px + 1px);
  }
  #pow-probe-2 {
    /* Clamping threshold fingerprint */
    /* Chrome clamps at 33554432px (2^25) */
    /* Firefox clamps at different threshold */
    width: calc(pow(2, 26) * 1px); /* 67108864px */
  }
`;
document.head.appendChild(powProbeStyle);

const pp1 = document.createElement('div');
const pp2 = document.createElement('div');
pp1.id = 'pow-probe';
pp2.id = 'pow-probe-2';
document.body.append(pp1, pp2);

const pw1 = parseFloat(getComputedStyle(pp1).width);
const pw2 = parseFloat(getComputedStyle(pp2).width);

// pw1 = 16777217: CSS engine uses double-precision (float64) → Chrome, Firefox, Safari all agree
// pw1 = 16777216: CSS engine rounds to float32 → no modern browser does this currently
//   but older versions / non-standard engines may

// pw2 reveals clamping threshold:
// Chrome: 67108864 → 33554432 (clamps at 2^25 = 33554432px)
// Firefox: 67108864 → 67108864 (no clamping at this range)
// Safari: 67108864 → 0 or clamped value (historical clamping behavior differs)

pp1.remove(); pp2.remove(); powProbeStyle.remove();

Attack 4: log(0) behavior is a single-probe browser discriminator

Mathematically, log(0) = −∞. In CSS, how the browser handles calc(log(0) * 1px) — whether it produces −Infinity, 0, a very large negative number, or a CSS parse error — varies by browser version and spec interpretation. The CSS Values Level 4 spec says that infinite values in calc() should be treated as the closest finite value for length resolution, but different browsers interpreted "closest finite value" differently at the time each version shipped.

// log(0) behavior fingerprint — single-probe browser discriminator
// Mathematical result: log(0) = -Infinity
// CSS spec: infinite calc() values → implementation-defined clamping

const logProbeStyle = document.createElement('style');
logProbeStyle.textContent = `
  #log-probe {
    position: absolute;
    visibility: hidden;
    /* log(0) = -Infinity → what does each browser compute for this? */
    width: calc(log(0, 10) * -1 * 1px);
    /* -log(0,10) = +Infinity → clamped to... what? */
  }
  #log-probe-2 {
    /* log(0, 10) * 0 = NaN or 0? */
    /* IEEE 754: Infinity * 0 = NaN */
    /* CSS: NaN length → 0px or invalid */
    width: calc(log(0, 10) * 0px);
  }
`;
document.head.appendChild(logProbeStyle);

const lp1 = document.createElement('div');
const lp2 = document.createElement('div');
lp1.id = 'log-probe';
lp2.id = 'log-probe-2';
document.body.append(lp1, lp2);

const lw1 = getComputedStyle(lp1).width;
const lw2 = getComputedStyle(lp2).width;

// Discriminator matrix (illustrative — values vary by browser version):
// Chrome 111: lw1 = 'auto' (infinite length → auto fallback)
// Chrome 117+: lw1 = '0px' (spec alignment: invalid calc → 0)
// Firefox 108: lw1 = '0px' (always treated NaN/Inf as 0)
// Safari 15.4: lw1 = '' or '0px' (parse error → property not set)

// lw2 (Inf * 0 = NaN):
// Chrome 111-116: lw2 = '0px' (NaN length → 0)
// Chrome 117+: lw2 = '0px'
// Firefox: lw2 = '0px'
// Safari 15.4-16: lw2 = '0px'
// All browsers agree on NaN → 0, but lw1 differs, making it the discriminator

lp1.remove(); lp2.remove(); logProbeStyle.remove();
ProbeCSS expressionChrome 111-120Chrome 121+Firefox 108+Safari
sin/asin deltasin(asin(0.707...))*1000pxdelta=0delta=0delta=+0.001delta=−0.001
round() tieround(nearest, 2.5px, 1px)3px2px2px3px → 2px (17.4+)
pow clamppow(2,26)*1px33554432px33554432px67108864pxvaries
log(0)log(0,10)*-1*1pxauto0px0pxvaries

SkillAudit findings for CSS math function fingerprinting

HIGHsin(asin(x)) round-trip rounding: CSS-vs-JS delta from IEEE 754 intermediate rounding differences discriminates Chrome, Firefox, and Safari CSS engines — stable cross-session fingerprint with no JavaScript math required.
MEDIUMround() tie-breaking mode: half-away-from-zero vs half-to-even comparison on a 0.5px input identifies Chrome pre-121, Chrome 121+, Firefox, and Safari versions — 2 probes, 5-way version discriminator.
LOWpow(2,N) clamping threshold: pixel value clamping at 2^25 (Chrome) vs unclamped (Firefox) discriminates layout engine and reveals CSS pixel arithmetic range limits.
MEDIUMlog(0) behavior: Chrome 111-120 returns 'auto' while 121+ and Firefox return '0px' for an infinite-valued calc — single-probe major version discriminator for Chrome before/after a CSS spec alignment change.

Defences

CSS math fingerprinting requires style injection: All four attacks require injecting a <style> element with a custom rule. CSP style-src 'self' without 'unsafe-inline' blocks all CSS math fingerprinting probes. This is the primary and most effective defence.

Fingerprint-resistant browsers should normalize CSS math output: Brave and Firefox's Resist Fingerprinting mode currently normalize JavaScript Math.sin() outputs but do not normalize CSS-computed values. Browser vendors should add CSS math function output normalization to their fingerprinting resistance suites.

Audit MCP server style injection patterns: MCP servers that inject <style> elements containing calc(sin(, calc(cos(, calc(pow(, or round(nearest, should be flagged as fingerprinting attempts. SkillAudit's static analysis checks for CSS math fingerprinting patterns in both JavaScript and WAT/Wasm source.

Prefer CSS variable design tokens over inline math: Applications that compute design values with CSS math functions in dynamic style blocks (e.g., width: calc(sin(var(--angle)) * 100px)) create accidental fingerprinting surfaces when those computed values are readable via getComputedStyle. Compute such values in JavaScript and apply them as discrete pixel values instead.

Related: CSS media query device fingerprinting · Wasm Relaxed SIMD CPU fingerprinting · WebGPU GPU fingerprinting