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.
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:
- Renders at full width at page load (
90deg→width: 100px) - Collapses to zero at the install mousedown (
0deg→width: 0px) - Snaps back to full width after install completes (
90degrestored)
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:
| Expression | Result when var = X | Zero when | Obfuscation notes |
|---|---|---|---|
sin(var(--a) * 1deg) | sin(a°) | a = 0, 180 | Direct — easy to recognize |
cos(90deg - var(--a) * 1deg) | sin(a°) | a = 0, 180 | Identity: 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 = ±1 | Inverse trig composition; collapses at extremes |
abs(sin(var(--a) * 1deg)) | |sin(a°)| | a = 0, 180 | abs() 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.
- Unregistered custom property: CSS cannot interpolate the value — any change is discrete. No
transitionis specified on the width property itself. Auditors checkinggetComputedStyle(el).transitionseenone. - Registered custom property (
@property --phase { syntax: '<number>'; }): CSS can animate the angle. Atransition: --phase 0.2s linearon the element creates a smooth width transition. The transition applies to--phase, not towidth— so auditors checkingtransitionon the width property see nothing, but auditors checking all transition values would see--phase. - @keyframes on registered property (SA-CSS-TRIG-002): the
animationproperty is visible in computed styles, but it specifies the--phase-radproperty animation, not aheightanimation. Auditors that parse animations to check which CSS property is being animated will report--phase-radas the animated property, notheight.
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
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@keyframes used as angle argument in cos() for a consent element dimension — height/width reaches zero at animation end coinciding with install button activationtranslate 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 itatan2(var(--y), var(--x)) composition with cos() for a consent element dimension — mathematically equivalent to trig-based collapse but obfuscated as a geometric calculationSummary and browser support
| Attack | Severity | Min browser | Static detection | Dynamic detection |
|---|---|---|---|---|
| SA-CSS-TRIG-001: sin() width collapse at mousedown | Critical | Chrome 111, FF 108, Safari 15.4 | Partial (CSS expression parse) | Yes (Phase 2 simulation) |
| SA-CSS-TRIG-002: cos() height collapse timed animation | High | Chrome 111 + @property | Partial (animation parse) | Yes (Phase 3 timed re-measure) |
| SA-CSS-TRIG-003: sin() translate off-screen | High | Chrome 111, FF 108, Safari 15.4 | Partial (BoundingRect off-screen) | Yes (Phase 2 simulation) |
| SA-CSS-TRIG-004: atan2() obfuscated collapse | Medium | Chrome 111, FF 108, Safari 15.4 | No (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
- CSS @property Registered Custom Properties and MCP Consent Attacks
- MCP server CSS trig functions consent security — sin/cos/atan2 dimension collapse attacks
- MCP server CSS custom property animation consent security — unregistered steps() collapse
- CSS animation-range consent bypass — scroll-driven animation consent timing attacks
- CSS timing attack synthesis — mousedown collapse, timed reveal, and interaction window attacks