MCP server CSS round() mod() rem() math function security: zero font-size via round(0.4px,1px), opacity:0 via mod(1,1), zero-width via rem(-100%,100%), zero margin via round(down,0.9px,1px) attacks
Published 2026-07-23 — SkillAudit Research
CSS Values Level 4 introduced a family of rounding and modular arithmetic math functions: round(strategy, A, B), mod(A, B), and rem(A, B). These functions operate on CSS numeric values and produce results based on modular division — similar to the % operator in JavaScript but with CSS-specific dimension handling. Browser support shipped in Chrome 116, Firefox 118, and Safari 15.4.
For MCP server consent disclosure attacks, these functions introduce a new obfuscation layer. The computed value is always a plain number (zero, for the attacks below), but the CSS source expression does not contain a literal zero. Static scanners that look for font-size: 0, opacity: 0, width: 0, or margin: 0px do not match expressions like round(0.4px, 1px), mod(1, 1), rem(-100%, 100%), or round(down, 0.9px, 1px).
Browser support and fallback behavior: round(), mod(), and rem() are supported in Chrome 116+, Firefox 118+, and Safari 15.4+. On unsupported browsers, these declarations are invalid and the property uses its inherited or initial value. This creates a dual-target scenario: the attack hides the disclosure on modern browsers that support these functions, while on older browsers the fallback value may expose it — but if the MCP server targets only modern browsers (e.g., @supports (round(1px,1px): 0px)), the attack applies precisely where users are most likely to be running.
Attack 1: font-size: round(0.4px, 1px) — zero font size via rounding to nearest step
The round(A, B) function rounds the value A to the nearest multiple of B. When round(0.4px, 1px) is evaluated, the nearest multiple of 1px to 0.4px is 0px (since 0.4 is closer to 0 than to 1). The result is a font-size of exactly 0px — text is rendered at zero size and is invisible:
/* Attack 1: zero font-size via round() rounding down to nearest 1px step */
/* Direct (trivially detected): */
.consent-disclosure { font-size: 0px; }
/* Obfuscated via round(): */
.consent-disclosure {
font-size: round(0.4px, 1px);
/* round(0.4px, 1px):
Nearest multiple of 1px to 0.4px = 0px (since 0.4 < 0.5, rounds to lower multiple).
Result: font-size: 0px — text is invisible.
CSS source string: 'round(0.4px, 1px)' — does not contain '0px' or '0'. */
}
/* Variant: use round(nearest, ...) explicitly (nearest is the default strategy): */
.consent-disclosure {
font-size: round(nearest, 0.3px, 1px);
/* round(nearest, 0.3px, 1px):
0.3px is 0.3 of the way from 0px to 1px — below 0.5, rounds to 0px.
Result: font-size: 0px. */
}
/* Using CSS variables to obscure the values: */
.consent-disclosure {
--mcp-size: 0.4px;
--mcp-step: 1px;
font-size: round(var(--mcp-size), var(--mcp-step));
/* Same result: 0px. Source: 'round(var(--mcp-size), var(--mcp-step))' — no literal 0. */
}
// Detection: check computed font-size regardless of expression form
function detectRoundZeroFontSize(el) {
const cs = window.getComputedStyle(el);
const fontSize = parseFloat(cs.fontSize);
if (fontSize < 10 && el.textContent.trim().length > 0) {
console.error('SECURITY: consent disclosure font-size below legibility threshold (possible round() attack)', {
element: el,
computedFontSize: fontSize,
inlineStyle: el.style.cssText
});
return true;
}
return false;
}
Attack 2: opacity: mod(1, 1) — zero opacity via modular arithmetic
The mod(A, B) function returns the remainder of A divided by B, with the same sign as B (following the floored division convention). When A is any exact multiple of B, the remainder is 0. mod(1, 1) = 0 because 1 divides exactly into 1 with no remainder. Any integer modulo itself is zero — mod(2, 2), mod(5, 5), mod(100, 100) all evaluate to 0:
/* Attack 2: opacity: mod(1, 1) — zero opacity via any-integer mod itself */
/* Direct: */
.consent-disclosure { opacity: 0; }
/* Obfuscated via mod(): */
.consent-disclosure {
opacity: mod(1, 1);
/* mod(1, 1):
1 ÷ 1 = 1 remainder 0.
Result: opacity: 0 — fully transparent.
Source: 'opacity: mod(1, 1)' — no literal '0'. */
}
/* Variants — all evaluate to mod(N, N) = 0: */
.consent-disclosure { opacity: mod(2, 2); } /* 2 mod 2 = 0 */
.consent-disclosure { opacity: mod(5, 5); } /* 5 mod 5 = 0 */
.consent-disclosure { opacity: mod(0.5, 0.5); } /* 0.5 mod 0.5 = 0 */
/* Using a CSS variable for the divisor: */
.consent-disclosure {
--mcp-n: 3;
opacity: mod(var(--mcp-n), var(--mcp-n));
/* mod(3, 3) = 0. Source: 'mod(var(--mcp-n), var(--mcp-n))' — no literal opacity value. */
}
/* Difference between mod() and rem(): */
/* mod(A, B): result has same sign as B (floored division) */
/* rem(A, B): result has same sign as A (truncated division) */
/* For positive equal values: both return 0. */
// Detection: check computed opacity
function detectModZeroOpacity(el) {
const cs = window.getComputedStyle(el);
const opacity = parseFloat(cs.opacity);
if (opacity === 0 && el.textContent.trim().length > 0) {
console.error('SECURITY: consent disclosure opacity:0 (possible mod() attack)', {
element: el,
computedOpacity: opacity,
inlineStyle: el.style.cssText
});
return true;
}
return false;
}
Attack 3: width: rem(-100%, 100%) — zero width via CSS rem() remainder
The rem(A, B) function returns the remainder of A divided by B using truncated (C-style) division — the result has the same sign as A. For rem(-100%, 100%): -100% ÷ 100% = -1 exactly (no fractional part), so the remainder is 0% with the sign of A = −100% → the result is 0% (or −0% in IEEE 754, which CSS normalizes to 0%). Combined with overflow: hidden, a zero-width disclosure clips all text:
/* Attack 3: width: rem(-100%, 100%) — zero width via CSS rem() */
/* Direct: */
.consent-disclosure { width: 0; overflow: hidden; }
/* Obfuscated via rem(): */
.consent-disclosure {
width: rem(-100%, 100%);
overflow: hidden;
/* rem(-100%, 100%):
-100% / 100% = -1 exactly (remainder 0%).
rem() uses truncated division: result sign = sign of A = negative.
remainder is 0% (zero × sign of A, but 0 has no sign in practice).
Result: width: 0% — element occupies no horizontal space.
With overflow:hidden, all text is clipped.
Source: 'rem(-100%, 100%)' — no literal '0'. */
}
/* Variants: */
.consent-disclosure {
width: rem(200%, 200%); /* 200/200 = 1 remainder 0 → 0% */
}
.consent-disclosure {
width: rem(50px, 50px); /* 50/50 = 1 remainder 0 → 0px */
}
.consent-disclosure {
width: rem(var(--parent-w), var(--parent-w)); /* any self-divided value = 0 */
}
// Detection: check computed width
function detectRemZeroWidth(el) {
const cs = window.getComputedStyle(el);
const width = parseFloat(cs.width);
const overflow = cs.overflow;
const rect = el.getBoundingClientRect();
if ((width < 4 || rect.width < 4) && overflow === 'hidden' && el.textContent.trim().length > 0) {
console.error('SECURITY: consent disclosure near-zero width with overflow:hidden (possible rem() attack)', {
element: el,
computedWidth: width,
boundingRectWidth: rect.width,
overflow,
inlineStyle: el.style.cssText
});
return true;
}
return false;
}
Attack 4: margin-top: calc(round(down, 0.9px, 1px)) — zero margin via round(down) strategy
The round(strategy, A, B) form accepts an explicit rounding strategy: nearest (default), up, down, or to-zero. The down strategy rounds toward negative infinity — for positive values below the step size, round(down, 0.9px, 1px) rounds 0.9px down to the nearest 1px multiple below 0.9px, which is 0px:
/* Attack 4: margin-top collapse via round(down, 0.9px, 1px) */
/* Context: consent disclosure relies on margin-top for visual separation from a button.
The user must see the disclosure as separate from the accept button.
Collapsing margin-top to 0 makes them appear merged. */
/* Direct: */
.consent-disclosure { margin-top: 0px; }
/* Obfuscated via round(down): */
.consent-disclosure {
margin-top: calc(round(down, 0.9px, 1px));
/* round(down, 0.9px, 1px):
Round 0.9px down to the nearest multiple of 1px toward negative infinity.
Multiples of 1px: ..., -1px, 0px, 1px, ...
The largest multiple of 1px that is ≤ 0.9px is 0px.
Result: 0px.
The calc() wrapper is technically redundant here but further obscures the expression. */
}
/* More aggressive: round(down) on a sub-pixel combined with negative offset */
.consent-disclosure {
margin-top: calc(round(down, 0.49px, 1px) - 20px);
/* round(down, 0.49px, 1px) = 0px
0px - 20px = -20px
margin-top: -20px — pulls content 20px up, overlapping the button above.
Source has no literal '-20px' on the margin-top line. */
}
/* round(to-zero) is equivalent to truncation — also produces 0 for values between 0 and 1: */
.consent-disclosure {
margin-top: round(to-zero, 0.7px, 1px);
/* round toward zero: 0.7px is between 0px and 1px, truncate toward zero = 0px. */
}
/* Combined: font-size via round() and margin via round(down) */
.consent-disclosure {
font-size: round(0.4px, 1px); /* = 0px */
margin-top: round(down, 0.9px, 1px); /* = 0px */
/* Both properties collapse via rounding. Source contains no literal zeros. */
}
// Detection: check computed margins regardless of expression
function detectRoundZeroMargin(el) {
const cs = window.getComputedStyle(el);
const marginTop = parseFloat(cs.marginTop);
if (marginTop < 4 && el.textContent.trim().length > 0) {
console.error('SECURITY: consent disclosure has near-zero margin-top (possible round(down) attack)', {
element: el,
computedMarginTop: marginTop,
inlineStyle: el.style.cssText
});
return true;
}
return false;
}
/* Key detection insight: all four attacks are invisible to source-level scanning.
getComputedStyle() always returns the resolved value:
- round(0.4px, 1px) → '0px'
- mod(1, 1) → '0'
- rem(-100%, 100%) → '0%'
- round(down, 0.9px, 1px) → '0px'
Check resolved values, not CSS source expressions. */
Why round()/mod()/rem() bypass most static scanners: These functions are relatively new (Chrome 116+, 2023) and most CSS linters, security scanners, and static analysis tools do not evaluate CSS Level 4 math expressions. A scanner that parses CSS tokens and checks property values against a deny-list will see mod(1, 1) and flag it as a function call — not as zero. Only a scanner that actually evaluates CSS math expressions (treating the result as a numeric value and comparing against thresholds) will catch these. Runtime inspection via getComputedStyle is the reliable detection mechanism.
Attack summary
| Attack | CSS expression | Computed value | Property | Severity |
|---|---|---|---|---|
| Zero font-size via round() | round(0.4px, 1px) |
0px | font-size | High |
| Zero opacity via mod() | mod(1, 1) |
0 | opacity | High |
| Zero width via rem() | rem(-100%, 100%) |
0% | width + overflow:hidden | High |
| Zero margin via round(down) | round(down, 0.9px, 1px) |
0px | margin-top | Medium |
Consolidated finding blocks
font-size: round(0.4px, 1px) or round(nearest, 0.3px, 1px) — any value below 0.5px in a 1px step rounds to 0px. Text is invisible. Source string does not contain '0px'. Detection: parseFloat(getComputedStyle(el).fontSize) < 10.
opacity: mod(N, N) for any positive N. Any integer modulo itself is zero. Fully transparent element. Detection: parseFloat(getComputedStyle(el).opacity) === 0 on an element with non-empty text content.
width: rem(-100%, 100%) with overflow: hidden. rem(-100%,100%) = 0%. Element clips all text. Detection: getBoundingClientRect().width < 4 on element with non-empty text content.
margin-top: round(down, 0.9px, 1px) = 0px, collapsing the visual separator between consent disclosure and accept button. Detection: parseFloat(getComputedStyle(el).marginTop) < 4 on consent elements that should have visual separation.