Security reference · CSS injection · Filter effects · Consent hiding
MCP server CSS filter hue-rotate security
CSS filter: hue-rotate() rotates every color in an element's rendered subtree through the hue wheel by the specified angle. MCP servers exploit this to shift consent text from a readable dark color to a near-background hue — the element stays display:block, visibility:visible, opacity:1, but its text color is rotated to something indistinguishable from the background. Standard visibility checkers see no anomaly; only a computed-color contrast check reveals the attack. Four patterns: direct hue rotation on the consent text container, ancestor-level propagation through the entire install dialog, a combined hue-rotate() saturate(0) chain that collapses all color information into gray-on-gray invisibility, and a JS-triggered rotation applied only at install-click time to evade load-time audits.
hue-rotate() color mapping for security auditors
| Source color | hue-rotate(180deg) result | On white background | Contrast ratio |
|---|---|---|---|
#1a1a1a (near-black, hue 0°) | Blue-green ~#1a1a1a shifted, dark teal | Low but still somewhat readable | ~10:1 (readable) |
#374151 (dark gray-blue, common UI) | ~#513737 brownish on white | Slightly reduced contrast | ~6:1 |
#6b7280 (medium gray consent text) | ~#807468 warm gray — low contrast on white | Near-background on off-white | ~2.5:1 |
Any color + saturate(0) | Pure gray at original luminance | Medium gray on white or gray bg | Varies by luminance |
Why hue-rotate() evades string scanners: Auditors looking for color: transparent, opacity: 0, visibility: hidden, or display: none find nothing. The consent element is fully visible by every conventional metric — only its computed text color, measured against the actual background color, reveals the contrast problem. The filter property also propagates to all descendants, so a single filter: hue-rotate(180deg) on a parent dialog element hides everything inside it simultaneously.
Attack 1: direct hue-rotate on consent text container — color shifted to near-background
The most direct form applies filter: hue-rotate() directly to the consent element or its immediate wrapper. Because consent text in install dialogs is typically styled with a medium-gray or muted color for visual hierarchy (not pure black), a 90°–180° rotation can produce a color with insufficient contrast against the background. The element passes every standard visibility check:
/* Malicious CSS — SA-CSS-HUE-001 */
.mcp-consent-disclosure {
/* filter: hue-rotate(180deg) rotates all colors in this element 180° around the hue wheel */
/* Example: consent text color #6b7280 (gray, hue ~210°) → rotated to ~hue 30° (warm gray/tan) */
/* On a white #ffffff background the contrast drops from ~4.5:1 to ~2.5:1 */
/* WCAG AA requires 4.5:1 for normal text — this falls below the threshold */
filter: hue-rotate(180deg);
/* All standard visibility checks pass:
display → block ✓
visibility → visible ✓
opacity → 1 ✓
height → auto ✓
width → auto ✓
overflow → visible ✓
getBoundingClientRect → non-zero ✓
*/
}
/* The attack is invisible to any auditor that does not check computed contrast ratios */
/* element.style.filter or getComputedStyle(el).filter reveals "hue-rotate(180deg)" */
/* Detection: */
function detectHueRotateHiding() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
let node = el;
while (node && node !== document.body) {
const filterVal = getComputedStyle(node).filter;
if (filterVal && filterVal !== 'none' && /hue-rotate\s*\(/.test(filterVal)) {
findings.push({ id: 'SA-CSS-HUE-001', severity: 'high',
message: `Consent-content element or ancestor has filter: ${filterVal}. hue-rotate() shifts text color hue — may reduce contrast below WCAG AA threshold. Verify computed contrast ratio against background.` });
}
node = node.parentElement;
}
}
return findings;
}
Attack 2: ancestor-level propagation — entire install dialog hue-rotated
CSS filters propagate through the entire subtree of the element they are applied to. Placing filter: hue-rotate() on the install dialog root element rather than on the consent element directly means every text and color within the dialog — including the install button label, form inputs, and consent text — is hue-shifted simultaneously. This looks less suspicious in a stylesheet review because no single selector targets .consent:
/* Malicious CSS — SA-CSS-HUE-002 */
.mcp-install-dialog {
/* Applied at the dialog root — propagates to ALL descendants */
/* Install button text (dark on colored button): still readable because the button
background color is also hue-rotated, maintaining its internal contrast */
/* Consent text (dark on white background area): the white area is also
hue-rotated toward a tinted white/off-white, while the consent text
rotates toward a similar hue — low contrast differential */
filter: hue-rotate(90deg);
/* 90° rotation: yellows become cyans, blues become magentas,
grays shift slightly — can be tuned so consent text hue matches background hue */
}
/* Why does this look legitimate?
Developers sometimes apply filter effects to entire components for theming.
filter: hue-rotate(90deg) on a dialog might look like a "dark mode" or
"theme inversion" feature rather than a consent-hiding attack. */
/* The subtle version adjusts the angle to specifically target the consent text
color while leaving the button color "accidentally" preserved: */
.mcp-install-dialog-v2 {
filter: hue-rotate(120deg);
/* Empirically tuned: consent text color (#64748b) rotated 120° → ~#748b64 (olive-ish)
on a slightly tinted background — falls to ~2.0:1 contrast ratio.
Install button: blue #3b82f6 → rotated to #82f63b (yellow-green) on white → 3.5:1
Button is still usable; consent is not. */
}
Attack 3: hue-rotate() combined with saturate(0) — gray-on-gray collapse
The CSS filter property accepts multiple functions in a chain. Combining hue-rotate() with saturate(0) first removes all color saturation (making every color a gray at its original luminance level), then applies hue rotation (which has no further effect on grays, as gray has no hue). The result: every color in the consent element becomes a neutral gray — if the background is also gray or off-white, the contrast between the now-gray consent text and the gray background falls to near 1:1:
/* Malicious CSS — SA-CSS-HUE-003 */
.mcp-consent-wrapper {
/* Step 1: saturate(0) removes all color saturation — every pixel becomes gray */
/* Step 2: hue-rotate(180deg) has no effect on gray (gray has no hue to rotate) */
/* Combined effect: consent text color #374151 (dark blue-gray) → gray #424242 */
/* On a dialog background of #f9fafb (near-white) → contrast ratio ≈ 5:1 (still OK) */
/* On a dialog background of #6b7280 (medium gray) → contrast ratio ≈ 1.5:1 (FAIL) */
filter: hue-rotate(180deg) saturate(0);
/* Attacker also sets the dialog container to a medium gray background: */
}
.mcp-install-dialog {
background: #9ca3af; /* medium gray — install button with white text: 3.2:1 (passes AA Large) */
/* consent text after saturate(0): ~#424242 on #9ca3af → ~2.8:1 (FAIL AA) */
}
/* Stylesheet string scan for "saturate(0)" might catch this,
but the property is on the wrapper, not the consent element directly,
and the dialog background change is on a different selector entirely.
Combined effect only visible through computed contrast ratio measurement. */
/* Detection: check the full filter chain, not just individual functions */
function detectFilterChain() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
let node = el;
while (node && node !== document.body) {
const f = getComputedStyle(node).filter;
if (!f || f === 'none') { node = node.parentElement; continue; }
/* Flag any filter that contains both hue-rotate and saturate(0) */
if (/hue-rotate/.test(f) && /saturate\s*\(\s*0/.test(f)) {
findings.push({ id: 'SA-CSS-HUE-003', severity: 'critical',
message: `Consent-content element/ancestor has combined filter "${f}" — saturate(0) + hue-rotate collapses all consent colors to gray. Check consent text contrast against the actual rendered background color.` });
}
node = node.parentElement;
}
}
return findings;
}
Attack 4: JS-triggered hue-rotate at install-click time — evades static audit
No filter is applied at page load. MCP JavaScript adds filter: hue-rotate(180deg) to the consent element inline style when the user begins the install interaction (on mousedown or as the install form becomes active). The audit runs at page load and sees clean, readable consent; the hue rotation activates at the moment the user clicks install:
/* No CSS hiding at load time */
.mcp-consent-disclosure { color: #374151; } /* Normal readable text */
/* JS — SA-CSS-HUE-004 */
document.querySelector('.mcp-install-btn').addEventListener('mousedown', () => {
/* Applied at mousedown — fires before the 'click' event */
/* Consent is hue-rotated and potentially unreadable by the time
the 'click' handler fires to start the install flow */
document.querySelector('.mcp-consent-disclosure').style.filter = 'hue-rotate(180deg)';
});
/* Why mousedown, not click?
mousedown fires before click in the event sequence:
mousedown → mouseup → click
Applying the filter at mousedown means it is active when the click handler reads
the consent state. Any UI that re-checks consent visibility in the 'click' handler
sees a hue-rotated element — but computed style checks would still catch it. */
/* Animation variant — filter transition triggered by class addition: */
.mcp-consent-disclosure { filter: none; transition: filter 0.5s; }
.mcp-consent-disclosure.installing { filter: hue-rotate(180deg); }
document.querySelector('.install-form').addEventListener('submit', () => {
document.querySelector('.mcp-consent-disclosure').classList.add('installing');
/* filter animates from none to hue-rotate(180deg) over 0.5s */
/* If the install flow completes in under 0.5s, consent was rotated mid-installation */
});
/* Detection: MutationObserver on the style attribute and classList */
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'attributes') continue;
if (m.attributeName !== 'style' && m.attributeName !== 'class') continue;
const el = m.target;
const f = getComputedStyle(el).filter;
if (f && f !== 'none' && /hue-rotate/.test(f)) {
console.warn('SA-CSS-HUE-004: filter hue-rotate applied to consent element at interaction time', el);
}
}
});
document.querySelectorAll('[class*="consent"]').forEach(el =>
observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] })
);
filter propagates through stacking contexts: CSS filter creates a new stacking context on the element it is applied to, and the filter effect is applied to the entire rendered output of the stacking context — including all child elements, pseudo-elements, and backgrounds. Applying filter: hue-rotate() to a parent element cannot be overridden by a child element's own filter: none — the child's rendered output is composited before the parent's filter is applied. The only escape is CSS isolation: isolate combined with mix-blend-mode gymnastics, which attackers do not provide for consent elements. Auditors must walk the entire ancestor chain checking for filters.
SkillAudit findings for CSS filter hue-rotate consent attacks
filter: hue-rotate() applied directly. Text color is shifted through the hue wheel — may fall below WCAG AA contrast ratio against the page background. All standard visibility checks pass; computed-color contrast measurement required to detect.filter: hue-rotate() applied at the dialog or container level. CSS filter propagates through the entire subtree simultaneously. No consent-specific selector in the rule — appears as a generic dialog style. Must walk the full ancestor chain to detect.filter: hue-rotate() saturate(0) chain. saturate(0) collapses all colors to gray; on a gray or off-white background the text contrast falls to near 1:1. Neither function individually signals a hiding attack — only the combined effect and measured contrast reveal it.filter: hue-rotate() applied to consent element via inline style mutation at install-click time. Consent is fully readable at page load and during audit; hue rotation activates at mousedown or form interaction. Requires MutationObserver on consent element's style and class attributes to detect.Related MCP consent attack research
- CSS filter brightness and contrast attacks — blow-out and collapse consent text to background luminance
- CSS mix-blend-mode attacks — blend consent text color into the background layer
- CSS HWB color space attacks — consent text color encoded in hwb() function to evade string scanners
- CSS opacity attacks — direct opacity:0 hiding and JS-deferred variants
- CSS mask-image as a consent bypass vector — transparent gradient, zero-size tile, and data URL attacks
SkillAudit's consent audit checks the full CSS filter chain on every element in the consent ancestor hierarchy, then measures the computed text color against the computed background color to calculate the actual WCAG contrast ratio at runtime — catching hue-rotate(), saturate(), and all other filter-based color attacks. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-HUE findings.