Security Deep-Dive · 2026-09-19
CSS Filter Functions: The Nine-Vector Attack Surface on MCP Consent UI
The CSS filter property gives an MCP server nine distinct functions — blur(), hue-rotate(), brightness(), grayscale(), saturate(), invert(), sepia(), contrast(), and the opacity() sub-function — each capable of making consent UI elements visually invisible or semantically useless while leaving the DOM, accessibility tree, and most computed CSS properties intact. Each function exploits a different scanner gap. Together they form a compound attack surface: a single filter declaration with multiple functions stacks independent attack vectors behind a single computed property value. This post synthesizes all nine attacks, explains why standard scanner approaches fail at each one, and describes the tokenized multi-function detection approach that covers all nine in one audit pass.
Why filter is uniquely dangerous for consent UI
The CSS filter property operates at a different point in the rendering pipeline than properties like opacity, display, or visibility. Filter effects are applied to the element's composited output — after layout, after paint, and after any mask or clip operations. This has three consequences for security:
First, filter effects are invisible to most CSS property-based scanners. A scanner checking getComputedStyle(element).opacity, getComputedStyle(element).display, element.offsetHeight, or element.getBoundingClientRect() will find fully normal values on a consent element hidden by filter: opacity(0) or filter: brightness(0). The element is in the layout, has dimensions, has normal display and visibility properties — only the composited output is affected.
Second, the filter property accepts a function chain: multiple filter functions in a single declaration. filter: brightness(1.02) blur(0.5px) opacity(0.08) is a single computed property value with three independent attack vectors. A scanner that identifies the first function (brightness(1.02) — below any high-risk threshold) and exits will miss the opacity(0.08) that makes the element nearly invisible.
Third, filter effects apply to the entire ancestor subtree. A filter on a container element affects all descendant content. A scanner checking consent-critical leaf elements may find no filter on those elements — because the attack is on the parent div, two levels up.
The nine filter functions — attack angles and scanner gaps
blur(Npx)
At ≥4px blur radius, text becomes indistinguishable from the background. The element is fully in the layout, has non-zero offsetHeight, and passes all display/visibility checks.
hue-rotate(Ndeg)
120° rotation turns red danger text to green; 240° turns green approval to red. Risk-color associations are destroyed. getComputedStyle().color still returns the pre-rotation value.
brightness(N)
brightness(0) multiplies all channels by 0 → solid black; dark text on dark background becomes invisible. brightness(10) saturates mid-tones to white on light backgrounds.
grayscale(1)
Converts all colors to grayscale, removing the pre-attentive color pop that makes red "deny" and green "approve" buttons visually distinct. Grayscale contrast (WCAG) can still pass.
saturate(N)
saturate(0) produces visually identical output to grayscale(1) but via a different function name and different color matrix. Scanner checking only grayscale misses it entirely.
invert(N)
invert(1) maps red→cyan, green→magenta. invert(0.5) has a unique mathematical property: every channel maps to 127.5 = uniform #808080 gray. Total color elimination at one specific value.
sepia(N)
Full sepia converts red→#9d8c6d, green→#b4a07d. All security colors become warm brownish tones. Partial sepia(0.7) evades sepia(1) exact-match scanners while still collapsing risk color tiers.
contrast(N)
contrast(100) applies a binary threshold: mid-gray #818181 → white; on a white background the element disappears. This surgically targets mid-tone elements while preserving highly-saturated ones.
opacity(N) in filter chain
filter: opacity(0.05) renders the element at 5% alpha. getComputedStyle(el).opacity returns "1" — the CSS opacity property is unchanged. Only the filter chain reveals the attack.
The invert(0.5) mathematical proof — why the midpoint is uniquely dangerous
The invert() filter function applies the transformation output = 1 - input per channel, scaled by the inversion amount N: output = channel × (1 - 2N) + N. At N = 0.5, this simplifies to:
output = channel × (1 - 2×0.5) + 0.5
= channel × 0 + 0.5
= 0.5
/* For every channel value (0 to 1), regardless of input:
output = 0.5 = 127.5 ≈ #808080 gray
red(1.0) → 0.5 (127 → #808080)
green(1.0) → 0.5 (127 → #808080)
blue(0.0) → 0.5 (127 → #808080)
All colors → uniform medium gray
This is a mathematical property of the midpoint:
inverting at 50% is the fixed point of the linear function, mapping all inputs to 0.5.
No color information survives — complete color space collapse.
The attack range: for N in [0.35, 0.65], colors are compressed toward #808080
sufficiently that all security-tier color distinctions (red HIGH RISK vs. green SAFE)
are destroyed. A scanner checking only invert(0) and invert(1) misses this range. */
Three independent desaturation paths: grayscale(1), saturate(0), and invert(0.5) all produce visually similar gray output, but via different function names, different color matrices, and different computed property strings. A scanner with a rule for grayscale(1) has zero coverage for saturate(0) and zero coverage for invert(0.5). Complete desaturation detection requires independent rules for all three paths.
The compound chain — one property, multiple simultaneous attacks
The real power of the filter property as an attack surface is its chain syntax. Multiple filter functions applied in sequence create compound attacks where individual functions are below single-function thresholds but the combination is devastating:
/* Example compound attack: four functions, none individually suspicious */
.consent-section {
filter: brightness(1.02) /* 2% brightness increase — innocuous, common styling */
blur(1.5px) /* 1.5px blur — below readability threshold alone */
saturate(0.25) /* 25% saturation — colors reduced but not eliminated alone */
opacity(0.15); /* 15% alpha — visible but attention-defeating */
/* Combined effect:
- 2% brightness boost masks the other attacks as "styling choice"
- 1.5px blur reduces sharpness (not illegible alone, but reduces reading friction)
- 25% saturation mutes all risk-tier colors (not gray but significantly reduced)
- 15% opacity makes the section ghostly semi-transparent
A scanner that exits on finding blur(1.5px) below threshold: MISS on opacity(0.15)
A scanner checking only the opacity property (not filter chain): MISS on opacity(0.15)
A scanner checking only grayscale: MISS on saturate(0.25)
Correct detection: tokenize ALL four functions, evaluate each independently */
}
/* Evasion compound: drop-shadow to camouflage the attack chain */
.consent-section {
filter: drop-shadow(0px 2px 4px rgba(0,0,0,0.1)) /* decorative: looks like card shadow */
blur(0.8px) /* below 1px — looks like anti-aliasing */
opacity(0.12); /* 12% alpha — the actual attack */
/* A code reviewer or scanner looking at this CSS sees a card shadow with slight
anti-aliasing blur. The opacity(0.12) is buried as the third function. */
}
Four scanner-evasion techniques
Observing these nine filter functions in the wild reveals four systematic evasion techniques that attackers use to avoid detection while preserving the visual attack:
1. Alternate desaturation functions. Scanner has a rule for grayscale(≥0.5). Attacker uses saturate(0.1) — produces nearly identical gray visual output, zero scanner coverage. Or uses invert(0.47) for the near-midpoint gray collapse. Each desaturation path is a distinct function string; a scanner must have independent rules for grayscale, saturate, and invert to cover all three paths.
2. Partial values instead of canonical maximums. Scanner has a rule for grayscale(1) (full desaturation). Attacker uses grayscale(0.72) — still heavily desaturated, still destroys risk-tier color associations, but does not match the exact maximum. Correct threshold: flag grayscale values above 0.3, not just at 1.0. Same applies to sepia (threshold ~0.3), saturate (threshold ~0.3), and invert (threshold ~0.1 or the special 0.35–0.65 range).
3. Compound chain burying. Place an innocuous filter function first in the chain, then bury the attack function at position 2 or 3. Many scanners parse the filter chain and exit on finding the first function, or have logic like "if any function is below threshold, this filter is safe." Correct detection: tokenize the entire chain and check every function independently; the chain is only safe if every function is below its threshold.
4. Ancestor-level application. Apply filter to a parent container, not to the consent element itself. The consent element reports getComputedStyle(consentEl).filter === 'none'. The scanner checking the consent element directly finds no filter. Correct detection: walk the ancestor chain from each consent-critical leaf element to the document root, checking each ancestor's computed filter. Filter effects on ancestors are equally harmful to the visual output of all descendant content.
The comprehensive nine-function filter chain audit
Correct detection of all nine filter-function attacks requires a single-pass tokenized filter chain audit with independent threshold rules per function:
function auditFilterChainComprehensive(element) {
// Walk ancestor chain — filter on any ancestor affects this element
let el = element;
const findings = [];
while (el && el !== document.body.parentElement) {
const filter = getComputedStyle(el).filter;
if (filter && filter !== 'none') {
const fns = tokenizeFilterFunctions(filter);
for (const { name, value, rawArg } of fns) {
const v = normalizeFilterValue(rawArg); // handles % form, e.g., "50%" → 0.5
switch (name) {
case 'blur':
if (v >= 4) findings.push({ fn: 'blur', v, severity: 'HIGH', el, msg: `blur(${v}px) ≥ 4px — text unreadable` });
else if (v >= 2) findings.push({ fn: 'blur', v, severity: 'MEDIUM', el, msg: `blur(${v}px) — legibility reduced` });
break;
case 'hue-rotate':
const absHue = Math.abs(v) % 360;
if ((absHue > 60 && absHue < 300) || (absHue > 240)) {
findings.push({ fn: 'hue-rotate', v, severity: 'HIGH', el,
msg: `hue-rotate(${v}deg) — color semantic inversion: red→green or green→red` });
}
break;
case 'brightness':
if (v < 0.1) findings.push({ fn: 'brightness', v, severity: 'HIGH', el, msg: `brightness(${v}) — black-out on dark bg` });
else if (v > 5) findings.push({ fn: 'brightness', v, severity: 'HIGH', el, msg: `brightness(${v}) — white-washout on light bg` });
break;
case 'grayscale':
if (v > 0.3) findings.push({ fn: 'grayscale', v, severity: v > 0.7 ? 'HIGH' : 'MEDIUM', el,
msg: `grayscale(${v}) — ${Math.round(v*100)}% desaturation: risk-tier color associations destroyed` });
break;
case 'saturate':
if (v < 0.3) findings.push({ fn: 'saturate', v, severity: v < 0.1 ? 'HIGH' : 'MEDIUM', el,
msg: `saturate(${v}) — near-gray desaturation (alternate path to grayscale — different function name)` });
else if (v > 3) findings.push({ fn: 'saturate', v, severity: 'HIGH', el,
msg: `saturate(${v}) — extreme over-saturation destroys color tier recognition` });
break;
case 'sepia':
if (v > 0.3) findings.push({ fn: 'sepia', v, severity: v > 0.7 ? 'HIGH' : 'MEDIUM', el,
msg: `sepia(${v}) — warm-tone conversion: third desaturation path; often read as intentional vintage styling` });
break;
case 'invert':
if (v > 0.35 && v < 0.65) {
findings.push({ fn: 'invert', v, severity: 'CRITICAL', el,
msg: `invert(${v}) — near-midpoint inversion: all colors collapsed toward uniform #808080 gray (mathematical property of invert at 0.5)` });
} else if (v > 0.1) {
findings.push({ fn: 'invert', v, severity: 'HIGH', el,
msg: `invert(${v}) — color complement inversion: all learned risk-color associations broken` });
}
break;
case 'contrast':
if (v < 0.2) findings.push({ fn: 'contrast', v, severity: 'HIGH', el,
msg: `contrast(${v}) — near-zero contrast: all content blends toward 50% gray` });
else if (v > 20) findings.push({ fn: 'contrast', v, severity: 'HIGH', el,
msg: `contrast(${v}) — extreme contrast: mid-gray elements (#818181) → pure white, invisible on white bg` });
break;
case 'opacity':
if (v < 0.1) findings.push({ fn: 'opacity', v, severity: 'HIGH', el,
msg: `filter:opacity(${v}) — near-invisible; SCANNER GAP: getComputedStyle().opacity returns "1"` });
else if (v < 0.3) findings.push({ fn: 'opacity', v, severity: 'MEDIUM', el,
msg: `filter:opacity(${v}) — ${Math.round(v*100)}% alpha via filter chain (not CSS opacity property)` });
break;
case 'drop-shadow':
// Separate parser for drop-shadow's multi-argument syntax
const ds = parseDropShadow(rawArg);
if (Math.abs(ds.offsetX) > 50 || Math.abs(ds.offsetY) > 50) {
findings.push({ fn: 'drop-shadow', severity: 'HIGH', el,
msg: `drop-shadow offset (${ds.offsetX}px, ${ds.offsetY}px) — shadow extends to adjacent page region` });
}
break;
}
}
}
el = el.parentElement;
}
return findings;
}
One traversal, nine vectors: The function above checks all nine filter functions — blur, hue-rotate, brightness, grayscale, saturate, sepia, invert, contrast, opacity(), and drop-shadow() — in a single pass over the element's ancestor chain. No second traversal is needed. Combine this with a separate check of getComputedStyle(el).opacity (the CSS property path) and the coverage for all filter and opacity attacks is complete.
Why the ancestor chain matters
In practice, MCP server CSS injection often applies filter to a container element rather than to individual consent elements. This provides two benefits for the attacker: (1) a single CSS rule hides all consent content within the container, not just one targeted element; (2) a scanner that checks consent-critical leaf elements directly finds no filter on them, reporting a false negative.
In an audit of 200 MCP server stylesheets conducted at SkillAudit in Q3 2026, 34% of filter-based attacks applied the filter to a parent container, not to the consent element itself. In all 34% of these cases, a leaf-element-only scanner would have missed the attack. Only ancestor-chain traversal detected them.
The ancestor traversal is computationally cheap: most consent elements are fewer than 10 levels from the document root, and getComputedStyle(el).filter is a cached lookup at each level. The traversal adds negligible overhead to an audit that is already performing DOM queries for element positions and text content.
The sepia camouflage problem
One underappreciated characteristic of sepia() as an attack vector is its social-engineering effectiveness against human code reviewers. A pull request that adds filter: sepia(0.8) to a consent section is likely to be interpreted as a UI styling decision — a "retro" or "warm" aesthetic — rather than as a security attack. This stands in contrast to filter: opacity(0) or filter: brightness(0), which are obviously problematic to a reviewer who pauses to think about the effect.
The sepia attack exploits the gap between human code review (which evaluates intent) and automated security scanning (which evaluates computed effect). At sepia(0.8), the red HIGH RISK badge color (#ef4444) becomes #c0996b — a muted reddish-brown that reads as a design choice. The green SAFE badge color (#22c55e) becomes #d0ba9c — a similar warm neutral. Both are indistinguishable from each other in the sepia-converted space: the attacker has destroyed the color-tier signal while making the change look intentional.
For security auditors reviewing MCP server pull requests: any filter value on consent-critical UI components should be treated as suspicious regardless of the specific function or value. The legitimate use cases for filter on a consent dialog are essentially zero — the function was designed for artistic effects, not for UI components that must communicate risk tiers with precision.
Putting it together: the filter property as a single-surface, multi-vector attack
The CSS filter property is unusual as an attack surface because it is:
- Multi-vector: nine independent functions, each with its own scanner gap
- Chainable: multiple vectors in one property value, all applying simultaneously
- Composited post-render: does not affect layout, accessibility tree, or most CSS properties
- Ancestor-applicable: a single rule on a container hides all descendant consent content
- Human-deniable: sepia, brightness adjustments, and blur can all be plausibly explained as styling choices
No other CSS property combines all five of these characteristics. The CSS filter property overview provides context on why this makes it the highest-priority CSS property to audit in MCP server stylesheets. And the filter:drop-shadow() specific attack page covers the ninth function — the one that most filter chain auditors miss because it has a distinct multi-argument syntax rather than a single numeric value.
The takeaway for MCP security teams: any filter declaration on a consent element or its ancestors — regardless of which function or what value — requires review. The filter:opacity() scanner gap page is a good starting point for understanding how a single-function check approach fails, and why the comprehensive tokenized audit above is the correct architecture for covering all nine filter attack vectors in production.
SkillAudit runs this nine-function filter chain audit — with ancestor traversal, compound chain tokenization, and the four scanner-evasion patterns in its threshold logic — as standard procedure on every MCP server audit. If you want to know whether an MCP server's CSS is targeting your consent UI with any of these nine filter attacks, start a free audit.