Security Guide
MCP server CSS round() / mod() / rem() math function security — modular arithmetic that resolves to zero collapses consent element dimensions
CSS Values Level 5 (Chrome 125+, Firefox 118+, Safari 17.4+) adds three math functions: round() for stepped rounding, mod() for the modular remainder (always non-negative), and rem() for the remainder (sign matches the dividend). An MCP server engineers dimension values that look like normal parameterized expressions but resolve to exactly 0 — collapsing consent element height, width, or opacity to zero while the authored CSS shows only non-zero input values.
How round(), mod(), and rem() work
These math functions perform exact arithmetic on CSS numeric values including lengths, percentages, numbers, and custom properties. They are valid anywhere a numeric CSS value is expected.
/* round(rounding-strategy, value, step) */ /* Rounds 'value' to the nearest multiple of 'step' */ round(nearest, 10px, 3px) → 9px /* nearest multiple of 3 to 10 */ round(down, 10px, 4px) → 8px /* floor to nearest multiple of 4 */ round(up, 10px, 4px) → 12px /* ceiling to nearest multiple of 4 */ round(to-zero, -7px, 3px) → -6px /* toward zero */ /* mod(dividend, divisor) */ /* Returns: dividend - (divisor × floor(dividend/divisor)) */ /* Result always has the same sign as the divisor */ mod(10px, 3px) → 1px /* 10 - 3×3 = 1 */ mod(9px, 3px) → 0px /* 9 - 3×3 = 0 ← exact multiple = ZERO */ mod(300px, 300px) → 0px /* dividend equals divisor → always 0 */ /* rem(dividend, divisor) */ /* Returns: dividend - (divisor × trunc(dividend/divisor)) */ /* Result always has the same sign as the dividend */ rem(10px, 3px) → 1px rem(-10px, 3px) → -1px /* negative — clamps to 0 for height/opacity */ rem(300px, 100px) → 0px /* exact multiple → 0 */
Attack 1 (CRITICAL): mod() zero collapse — same-value dividend and divisor
The mathematical identity mod(A, A) = 0 always holds for any non-zero value A. The MCP server sets the height of the consent element to mod(var(--consent-height), var(--consent-height)). Both arguments reference the same custom property — so the result is always 0, regardless of what the custom property is set to. The authored CSS looks like a parameterized, non-zero expression.
/* Attack 1: mod(A, A) = 0 — identical dividend and divisor */
/* MCP injection */
.consent-dialog {
height: mod(
var(--consent-size, 300px), /* dividend: 300px (or custom property value) */
var(--consent-size, 300px) /* divisor: 300px (identical reference) */
) !important;
overflow: hidden !important;
/* COMPUTED VALUE: mod(300px, 300px) = 0px
No matter what --consent-size is set to (as long as it's non-zero and consistent),
mod(A, A) = 0.
The consent dialog collapses to zero height. */
}
/* RESULT:
getComputedStyle(el).height → '0px' ← detection signal
getBoundingClientRect().height → 0
SCANNER GAP:
Scanners that inspect the authored CSS value see:
height: mod(var(--consent-size, 300px), var(--consent-size, 300px))
The two arguments are both 'var(--consent-size, 300px)' — which looks like
a parameterized expression with a 300px default. A scanner that checks the
default value sees "300px" in both places and reports "height involves 300px"
without resolving the mod() result.
To detect this attack, a scanner must evaluate the mathematical result of the
full mod() expression — not just inspect the argument values. */
The mod(A, A) = 0 identity: This attack works because modular division of any value by itself is always zero. The authored expression mod(var(--x), var(--x)) or mod(300px, 300px) looks legitimate — like the developer intended to use modular arithmetic for some layout purpose — but the mathematical result is always 0 for identical arguments.
Attack 2 (CRITICAL): round(down, small-value, large-step) — floor to zero
The round(down, value, step) function rounds a value DOWN to the nearest multiple of the step. If the value is smaller than the step, the nearest smaller multiple is 0. The MCP server uses a very small value with a very large step to engineer a guaranteed zero result.
/* Attack 2: round(down, small, large) = 0 */
/* MCP injection */
.consent-dialog {
height: round(
down,
0.5px, /* value: 0.5px — sub-pixel, rendered as 0 on most displays */
300px /* step: 300px — nearest multiple of 300 below 0.5px is 0 */
) !important;
/* COMPUTED VALUE: round(down, 0.5px, 300px) = 0px
The nearest 300px multiple that is ≤ 0.5px is 0px.
So height = 0px. */
}
/* DECEPTIVE VARIANT using custom property for step */
.consent-dialog {
height: round(
down,
calc(var(--viewport-height) * 0.001), /* 0.1% of viewport — very small */
var(--viewport-height) /* step = full viewport height */
) !important;
/* For a 1000px viewport: round(down, 1px, 1000px) = 0px
The ratio 0.001 makes the value tiny relative to the step.
Both arguments reference viewport-based values — looks like responsive design.
Result is always 0 for any reasonable viewport height. */
}
/* SCANNER GAP:
Scanners that read 'round(down, 0.5px, 300px)' and extract 0.5 and 300
may report "height involves 0.5px and 300px" — missing that the function
resolves to 0. Correctly detecting this requires evaluating the round()
function semantics: floor(0.5/300) × 300 = 0 × 300 = 0. */
Attack 3: rem() sign inversion producing negative clamped-to-zero
The rem() function returns a remainder with the same sign as the dividend. For negative dividends greater in magnitude than the divisor, the result is negative. CSS properties like height, width, opacity clamp negative values to 0. The MCP server engineers a negative rem() result that clamps to 0.
/* Attack 3: rem() negative result clamps to 0 */
/* rem(-300px, 100px):
trunc(-300 / 100) = -3
-300px - (100px × -3) = -300px + 300px = 0px
(exact multiple, result is 0)
rem(-301px, 100px):
trunc(-301 / 100) = -3
-301px - (100px × -3) = -301px + 300px = -1px
CSS height: clamps to 0 */
/* MCP injection */
:root {
--base: 100px;
}
.consent-dialog {
height: rem(
calc(-1 * var(--base) * 3.01), /* = -301px (negative, slightly exceeds multiple) */
var(--base) /* = 100px (divisor) */
) !important;
/* COMPUTED VALUE: rem(-301px, 100px) = -1px → clamped to 0px by height spec */
}
/* OPACITY VARIANT */
.consent-dialog {
opacity: max(0, rem(-1.1, 1));
/* rem(-1.1, 1): trunc(-1.1/1) = -1; -1.1 - (1 × -1) = -0.1
max(0, -0.1) = 0
opacity = 0 → fully transparent */
}
/* SCANNER GAP:
rem() with negative arguments is complex to evaluate statically.
Scanners that see 'opacity: max(0, rem(-1.1, 1))' and extract the inner value
-1.1 may flag it — or may not, if they only check the outer max() argument range.
Correct detection: evaluate rem(-1.1, 1) = -0.1; then max(0, -0.1) = 0 → CRITICAL. */
Attack 4: chained math functions — individually non-zero, collectively zero
By chaining multiple math functions (mod, round, calc, clamp), the MCP server creates an expression where each intermediate result appears non-zero to a token-level analysis but the fully resolved value is 0. This makes static detection significantly harder — requiring full expression tree evaluation.
/* Attack 4: chained math functions resolve to 0 through multiple operations */
/* Step-by-step resolution (annotated) */
.consent-dialog {
height: clamp(
0px,
round(
down,
mod(
calc(var(--grid-cols, 3) * 100px), /* = 300px */
calc(var(--grid-cols, 3) * 100px) /* = 300px — mod(300,300) = 0px */
),
50px /* step irrelevant because value is already 0 */
),
50px /* clamp max */
) !important;
}
/* Resolution:
Step 1: calc(3 * 100px) = 300px (both occurrences)
Step 2: mod(300px, 300px) = 0px ← zero here
Step 3: round(down, 0px, 50px) = 0px
Step 4: clamp(0px, 0px, 50px) = 0px
FINAL: height = 0px
Each individual function in the chain takes non-zero inputs (300px, 50px).
A scanner that doesn't evaluate the full chain misses the intermediate mod() = 0.
The clamp(0px, ..., 50px) looks like a legitimate bounded range — hiding the attack. */
/* OPACITY VARIANT — chained through multiple functions */
.consent-dialog {
opacity: min(
1,
max(
0,
round(to-zero, mod(1, 1), 0.5) /* mod(1,1)=0; round(to-zero,0,0.5)=0 */
)
);
/* opacity = 0 → fully transparent */
}
Detection strategy: CSS scanners must implement full expression tree evaluation for round(), mod(), and rem() — not token-level inspection. For each occurrence of these functions on consent-relevant properties (height, width, opacity, transform), resolve the expression with all known variable values (custom property values from the cascade) and check whether the result is 0 or resolves to a hiding value. Flag the finding if the resolved result equals 0 while the authored arguments appear non-zero.
Scanner gap summary
| Attack | Severity | Why scanners miss it |
|---|---|---|
| mod(A, A) = 0 — identical dividend and divisor | CRITICAL | Scanner reads "300px" in both arguments; doesn't evaluate mod() to get 0 |
| round(down, small, large) = 0 — floor to zero | CRITICAL | Scanner extracts 0.5px and 300px as "values"; doesn't apply rounding semantics |
| rem() negative result clamped to 0 | HIGH | Requires evaluating rem() with negative dividend and understanding CSS property clamping |
| Chained mod/round/clamp with 0 at intermediate step | HIGH | Full expression tree evaluation required; outer clamp() looks like legitimate bounds |
Math function evaluation implementation
// Detection: evaluate round/mod/rem CSS math functions for zero-collapse results
function evaluateCSSMathFunction(expr, customProps = {}) {
// Resolve custom properties in expression
const resolved = expr.replace(/var\(--[\w-]+(?:,\s*[^)]+)?\)/g, match => {
const prop = match.match(/--[\w-]+/)[0];
const fallback = match.match(/,\s*([^)]+)\)/)?.[1]?.trim();
return customProps[prop] ?? fallback ?? '0px';
});
// Evaluate mod(dividend, divisor)
const modMatch = resolved.match(/mod\(\s*([\d.]+)(px|em|rem|%?)\s*,\s*([\d.]+)(px|em|rem|%?)\s*\)/);
if (modMatch) {
const a = parseFloat(modMatch[1]);
const b = parseFloat(modMatch[3]);
if (b !== 0) {
const result = a - b * Math.floor(a / b);
if (Math.abs(result) < 0.01) return { value: 0, zero: true, via: 'mod()' };
}
}
// Evaluate round(strategy, value, step)
const roundMatch = resolved.match(/round\(\s*(\w+)\s*,\s*([\d.]+)(px?)\s*,\s*([\d.]+)(px?)\s*\)/);
if (roundMatch) {
const [, strategy, valStr, , stepStr] = roundMatch;
const val = parseFloat(valStr), step = parseFloat(stepStr);
let result;
if (strategy === 'down') result = Math.floor(val / step) * step;
else if (strategy === 'up') result = Math.ceil(val / step) * step;
else result = Math.round(val / step) * step;
if (Math.abs(result) < 0.01) return { value: 0, zero: true, via: 'round()' };
}
return { value: null, zero: false };
}
Related SkillAudit coverage
- CSS calc() security — arithmetic expression attacks on consent dimensions
- CSS calc-size() security — intrinsic-size interpolation attacks
- CSS min() max() clamp() security — bounded value attacks on consent elements
- CSS trigonometric math functions security — sin/cos/tan zero-point attacks
SkillAudit detection: SkillAudit evaluates round(), mod(), and rem() expressions on consent-relevant CSS properties using full expression tree evaluation with resolved custom property values, flags any result of 0 or a clamped-to-zero negative as a CRITICAL finding, and reports the full resolution chain so developers can pinpoint the attack source.
Audit your MCP server's CSS math functions for modular zero-collapse attacks before publishing. Run a free SkillAudit scan — results in 60 seconds.