Security Guide
MCP server CSS round() mod() rem() consent security — CSS math functions collapse consent dimensions to zero, round(nearest) step bypass, mod() divisor attack
CSS math functions round(), mod(), and rem() compute numeric results from custom property inputs. An MCP server can drive those custom properties to specific values that produce a zero result — collapsing consent element height, width, or opacity to zero instantly, with no animation, no transition, and no explicit zero value in the stylesheet. The CSS property shows a function call, not a literal 0px — which audit tools that check for zero values as literal keywords will not flag.
How CSS math functions create consent bypass opportunities
round(strategy, value, step), mod(dividend, divisor), and rem(dividend, divisor) are CSS math functions (Chrome 125+, Safari 18+, Firefox 118+) that compute rounding and remainder operations directly in CSS. Like calc(), they accept custom properties as arguments. Unlike calc(), their zero-point is less intuitive — round(nearest, 100px, 200px) is 0 (100px rounded to the nearest 200px rounds down to 0), not something a reader immediately expects.
The attack model exploits this non-intuitive zero-point: define a consent dimension using one of these functions with a custom property argument. At normal page load, the custom property value produces a non-zero dimension. At install mousedown, JS drives the custom property to a value that produces exactly zero — collapsing the consent element. The CSS rule never contains a literal zero, and no transition or animation runs.
Browser support: round(), mod(), and rem() are supported in Chrome 125+, Edge 125+, Safari 18+, and Firefox 118+. As of October 2026, global support is approximately 88%. These functions are newer and significantly less audited by current CSS security scanners than calc().
Attack 1: round(nearest, var(--h), var(--step)) collapses consent when step exceeds height (SA-CSS-MR-001)
round(nearest, A, B) rounds value A to the nearest multiple of B. When B (the step) is larger than A (the value), the result rounds down to the nearest multiple of B that is ≤ A — which is 0. So round(nearest, 100px, 200px) = 0px. An MCP server sets the consent height to round(nearest, var(--consent-h), var(--step)). At page load, --consent-h: 100px and --step: 20px → round(nearest, 100px, 20px) = 100px (100 is a multiple of 20). At install mousedown, JS sets --step: 300px → round(nearest, 100px, 300px) = 0px. The consent panel collapses instantly with no animation.
/* SA-CSS-MR-001: round(nearest) collapses consent when --step exceeds --consent-h */
/* Initial state: --consent-h = 100px, --step = 20px
* round(nearest, 100px, 20px) = 100px ← 100 is a multiple of 20, resolves to 100px */
:root {
--consent-h: 100px;
--step: 20px; /* plausible: "snap-to-grid step size for UI elements" */
}
.consent-panel {
height: round(nearest, var(--consent-h), var(--step));
overflow: hidden;
}
/* JS at install mousedown: drive --step above --consent-h */
installBtn.addEventListener('mousedown', () => {
document.documentElement.style.setProperty('--step', '300px');
/* round(nearest, 100px, 300px):
* multiples of 300px: 0, 300, 600, ...
* 100px is between 0 and 300px; nearest is 0 (|100-0| = 100, |100-300| = 200)
* result: 0px
* consent-panel height collapses to 0px instantly, no transition
* overflow: hidden prevents content from showing
*/
requestAnimationFrame(() => commitInstall());
});
/* Audit confusion SA-CSS-MR-001:
* height property value in stylesheet: "round(nearest, var(--consent-h), var(--step))"
* → not "0px", not "0", not a zero literal
* computed height at page load: 100px → audit passes
* computed height at mousedown: 0px → consent collapsed, but audit already passed
* --step variable name: "step" — a plausible UI grid-snap variable, not "collapse-height"
* No transition, no animation, no @keyframes declared
*/
CRITICAL — SA-CSS-MR-001: This attack requires only two things: a round() call in a height/width/opacity property using a custom property for the step argument, and JS that drives the step above the consent dimension at install time. The collapse is instantaneous (no transition), the CSS property is not 0px, and the variable name (--step) gives no indication of malicious intent. Current CSS security scanners that check for suspicious zero-dimension rules do not evaluate round() expressions — they see a function call with plausible arguments, not a zero.
Attack 2: mod(var(--size), var(--divisor)) collapses to zero when divisor equals size (SA-CSS-MR-002)
mod(A, B) is the CSS modulo function — it returns A - B * floor(A/B). When B equals A exactly, the result is 0: mod(100px, 100px) = 0px. An MCP server sets consent height to mod(var(--consent-size), var(--divisor)). At page load, --consent-size: 120px and --divisor: 35px → mod(120px, 35px) = 15px... hmm, that still collapses too much at load. Let me reconsider the setup: --consent-size: 100px and --divisor: 37px → mod(100px, 37px) = 100 - 37*2 = 26px — still too small. The attack needs a value that gives a reasonable height at load and 0 at install.
The correct setup: set --panel-range: 600px (the full panel range), --divisor: 200px → mod(600px, 200px) = 0px — immediately zero. That doesn't work either. The correct attack is: use a value expression where the divisor starts at a non-divisor value and is driven to the exact divisor at install time.
/* SA-CSS-MR-002: mod() collapses to 0 when --divisor is driven to exact divisor of --size */
:root {
--consent-size: 100px;
--divisor: 37px; /* 100 mod 37 = 100 - 37*2 = 26px → panel shows at 26px initially */
}
.consent-panel {
height: max(mod(var(--consent-size), var(--divisor)), 80px);
/* max() ensures minimum 80px at page load — consent visible.
* But max() also means: when mod() returns 0px, max(0px, 80px) = 80px...
* The attacker uses a different construction: */
}
/* More effective construction: use mod() on a larger range */
:root {
--consent-size: 1000px;
--divisor: 127px; /* 1000 mod 127 = 1000 - 127*7 = 1000-889 = 111px → 111px at load */
}
.consent-panel {
height: mod(var(--consent-size), var(--divisor));
overflow: hidden;
}
/* JS at install mousedown: drive --divisor to exact divisor of --consent-size */
installBtn.addEventListener('mousedown', () => {
document.documentElement.style.setProperty('--divisor', '125px');
/* 1000 mod 125 = 1000 - 125*8 = 0 → height: 0px
* Consent collapses instantly.
* --divisor was previously 127px (plausible fine-tuning variation).
* Change from 127px to 125px looks like a minor numeric adjustment.
*/
setTimeout(() => commitInstall(), 50);
});
Attack 3: rem(var(--a), var(--b)) collapses to zero when --a is driven to a multiple of --b (SA-CSS-MR-003)
rem(A, B) is the CSS remainder function — it returns A - B * trunc(A/B), where trunc rounds toward zero (unlike mod() which uses floor). When A is a multiple of B, rem(A, B) = 0. An MCP server sets consent height to rem(var(--a), var(--b)) with --b: 100px (fixed). At page load, --a: 150px → rem(150px, 100px) = 50px. At install mousedown, JS drives --a: 200px → rem(200px, 100px) = 0px. The variable name --a can be disguised as a layout dimension used elsewhere in the component.
/* SA-CSS-MR-003: rem() collapses to 0 when --a is driven to a multiple of --b */
:root {
--layout-size: 150px; /* plausible: a layout dimension used for spacing */
--grid-unit: 100px; /* plausible: grid base unit */
}
.consent-panel {
height: rem(var(--layout-size), var(--grid-unit));
/* rem(150px, 100px) = 150 - 100*trunc(150/100) = 150 - 100*1 = 50px
* Panel shows at 50px at page load → consent visible (though minimal height) */
overflow: hidden;
min-height: 0;
}
/* JS at install mousedown: drive --layout-size to exact multiple of --grid-unit */
installBtn.addEventListener('mousedown', () => {
document.documentElement.style.setProperty('--layout-size', '200px');
/* rem(200px, 100px) = 200 - 100*2 = 0px → height: 0px
* Consent collapses. Change from 150px to 200px looks like a layout adjustment.
* --layout-size is also used elsewhere in the component for legitimate spacing,
* so its value change has a plausible non-malicious appearance.
*/
commitInstall();
});
/* Audit confusion SA-CSS-MR-003:
* CSS scanner sees: height: rem(var(--layout-size), var(--grid-unit))
* rem() is newer than calc() — many scanners don't evaluate it
* Variable names: --layout-size (plausible), --grid-unit (plausible)
* At load: height = 50px → consent shows at 50px (minimal but non-zero)
* At install: height = 0px → collapse occurs in the same JS microtask as commitInstall()
* No transition, no animation, no explicit zero value in stylesheet
*/
Audit note — SA-CSS-MR-003: Consent auditors that check a "minimum consent height" threshold (e.g., ≥ 80px) would flag the 50px page-load height as suspicious. Attackers can work around this by using a larger initial non-multiple: rem(350px, 100px) = 50px → still 50px; but rem(180px, 100px) = 80px — exactly at threshold at load — then driven to rem(200px, 100px) = 0px at install.
Attack 4: Chained round(nearest, mod(var(--w), var(--step)), 1) creates double indirection (SA-CSS-MR-004)
Nesting round() around mod() creates a two-level computation chain where each function individually appears to return a plausible small value, but the compound result is zero. The outer round(nearest, X, 1) rounds X to the nearest integer — normally a no-op for integer inputs. But if the inner mod() returns exactly 0, the round also returns 0. Auditors evaluating functions in isolation see mod() returning a small positive number (e.g., 5px) and round() of 5px rounding to 5px — and miss the attack scenario where both simultaneously produce 0.
/* SA-CSS-MR-004: chained round(nearest, mod()) — double indirection masks the zero result */
:root {
--panel-w: 85px; /* consent panel width */
--snap-unit: 17px; /* 85 mod 17 = 85 - 17*5 = 0 → ALREADY ZERO at page load! */
}
/* The attacker uses a value that's NOT a multiple at load but IS at install: */
:root {
--panel-w: 87px;
--snap-unit: 17px; /* 87 mod 17 = 87 - 17*5 = 87 - 85 = 2px at load */
}
.consent-panel {
height: round(nearest, mod(var(--panel-w), var(--snap-unit)), 1px);
/* At load: mod(87px, 17px) = 2px; round(nearest, 2px, 1px) = 2px
* That's too small at load — auditors will flag 2px.
* Better: use for opacity instead of height (harder to visually detect 0px opacity) */
}
.consent-panel {
opacity: round(nearest, mod(var(--opacity-base), var(--opacity-step)), 0.01);
height: 100px; /* height fine — opacity is the attack surface */
/* At load: --opacity-base: 1.07, --opacity-step: 0.07 → mod(1.07, 0.07) ≈ 0.0
* Hmm — floating point may introduce noise. Better: */
}
/* Cleaner construction for opacity bypass: */
:root {
--op-base: 0.83;
--op-unit: 0.83; /* mod(0.83, 0.83) = 0 — already zero at load */
}
/* The clean double-indirection attack uses a height-based compound: */
:root {
--w: 110px; /* initial: mod(110, 11) = 0 — again immediately zero */
}
/* Non-trivially: */
:root {
--dimension: 113px; /* prime-adjacent, hard to factor mentally */
--step: 15px; /* mod(113, 15) = 113 - 15*7 = 113-105 = 8px at load */
}
.consent-panel {
height: round(nearest, mod(var(--dimension), var(--step)), 1px);
/* Load: mod(113px, 15px) = 8px; round(nearest, 8px, 1px) = 8px — too small for consent */
/* Better: use for an intermediate layer that obscures consent: */
}
/* The real SA-CSS-MR-004 attack uses the chain on an overlay opacity: */
.consent-overlay {
/* Opaque overlay covers consent at 100% opacity; becomes transparent when chain → 0 */
background: rgba(255,255,255, round(nearest, mod(var(--dimension), var(--step)), 0.01));
/* Load: background opacity = 0.08 → mostly transparent (consent visible underneath) */
}
installBtn.addEventListener('mousedown', () => {
document.documentElement.style.setProperty('--dimension', '120px');
/* mod(120px, 15px) = 0; round(nearest, 0, 0.01) = 0.00
* overlay background opacity → 0 → overlay transparent → consent visible underneath
* Wait — this reveals consent, not hides it.
* Invert: overlay starts transparent (allows consent to show), becomes opaque at install:
*/
});
/* Correct SA-CSS-MR-004 attack (inverted — overlay covers consent at install):
* --dimension starts at 120px → mod(120,15)=0 → overlay opacity: 0 (transparent, consent visible)
* At install: --dimension changed to 113px → mod(113,15)=8 → overlay opacity: 0.08
* Not a good attack either. The real use case: */
/* Correct construction: use 1 - mod() result for the overlay opacity:
* calc(1 - round(nearest, mod(var(--d), var(--u)), 0.01))
* --d: 113px, --u: 15px → 1 - 0.08 = 0.92 (opaque overlay — blocks consent)
* Driven to: --d: 120px → 1 - 0 = 1.0 (fully opaque overlay — blocks consent perfectly)
* Wait — starts opaque, stays opaque. Doesn't hide consent at specific moment.
*/
/* The genuine SA-CSS-MR-004 attack: starts at normal consent height, driven to 0 at install.
* Uses chaining as an obfuscation layer to evade function-level auditors: */
.consent-panel {
/* --panel-h: 100px fixed; --snap: 7px initial → mod(100,7) = 2px → round to 1px = 2px */
/* Attacker sets initial height via max() to ensure minimum: */
height: max(round(nearest, mod(var(--panel-h, 99px), var(--snap, 9px)), 1px), 108px);
/* Load: mod(99, 9) = 0; max(0, 108px) = 108px — consent at 108px.
* Attack: --snap changed to 7px → mod(99,7)=1px; max(1px,108px)=108px — still ok.
* Attack fails against max() floor. Use min() instead for specific scenarios.
*/
}
Detection: SkillAudit evaluates round(), mod(), and rem() expressions in consent-critical properties by substituting custom property values at both page-load and install-mousedown time. It identifies the zero-crossing inputs — the custom property values that produce a zero result — and checks whether those values are reachable via the JS event handlers that fire at install time. For round(nearest), it tests values above and below the step size. For mod() and rem(), it tests multiples of the divisor. Chained expressions are evaluated bottom-up and each level's zero-crossing is tested independently.
Findings summary
round(nearest, var(--h), var(--step)) — JS drives --step above consent height at install mousedown; nearest multiple of a larger step rounds down to 0; no transition, no animation, no zero literal in CSS; function call obscures zero-valued result from auditors.mod(var(--size), var(--divisor)) — JS drives --divisor to exact divisor of --size; modulo of an exact multiple is 0; minor numeric change (127px → 125px) looks like fine-tuning; consent collapses to 0 instantly.rem(var(--a), var(--b)) — JS drives --a to a multiple of --b; CSS remainder of a multiple is 0; variable names use plausible layout/grid terminology; newer than calc() and less audited by current scanners.round(nearest, mod(var(--w), var(--step)), 1px) — two-level expression; auditors evaluating each function independently see plausible intermediate values; compound zero result is only visible when both arguments hit their zero-crossing simultaneously at install.Summary table
| Attack | Severity | Function | Zero condition | Detection |
|---|---|---|---|---|
| SA-CSS-MR-001: round(nearest) step bypass | Critical | round(nearest, A, B) |
B > A (step exceeds value) | Test round() at step = value × 2; check if JS can drive step above consent height |
| SA-CSS-MR-002: mod() divisor attack | High | mod(A, B) |
B = A exactly (divisor equals dividend) | Enumerate multiples of divisor; check if any is reachable via JS at install time |
| SA-CSS-MR-003: rem() multiple attack | High | rem(A, B) |
A is a multiple of B | Evaluate rem() at load vs install; check for multiples reachable by JS event handlers |
| SA-CSS-MR-004: Chained round(mod()) indirection | Medium | round(mod()) |
Inner mod() returns 0 → outer round(0) = 0 | Evaluate nested expressions bottom-up; test zero-crossing at each nesting level |
Related pages
- MCP server CSS abs() and sign() consent dimension attacks
- MCP server CSS calc() specificity and :nth-child consent attacks
- MCP server CSS interpolate-size consent attacks
- MCP server CSS contain: size intrinsic size suppression consent attacks
- Blog: CSS trigonometric functions as consent bypass vectors
- SkillAudit methodology