Research · CSS consent bypass · Filter effects · Blend modes

CSS Filter Effects as a Consent Bypass Vector: Hue Rotation, Luminance Collapse, and Blend Mode Attacks

CSS filter functions and blend modes share a property that makes them an unusually effective class of consent bypass attack: they modify the rendered pixel color of an element without changing the CSS color property that every standard auditor checks. This post unifies three distinct attack dimensions — hue manipulation via hue-rotate(), luminance destruction via brightness() and contrast(), and pixel compositing via mix-blend-mode — explains the shared root cause that makes all three invisible to standard tools, and provides a single detection algorithm that covers the complete attack surface.

Contents

  1. The getComputedStyle blind spot
  2. Dimension 1 — Hue manipulation (hue-rotate)
  3. Dimension 2 — Luminance destruction (brightness, contrast)
  4. Dimension 3 — Blend mode compositing (mix-blend-mode)
  5. Comparison: filters vs. opacity vs. visibility
  6. CSP scope and filter: url() bypass
  7. Unified detection algorithm

The getComputedStyle blind spot

Every CSS filter effect attack exploits the same architectural gap: getComputedStyle(el).color returns the CSS logical color, not the post-filter rendered pixel color.

Standard consent auditors — whether implemented in accessibility scanners, MCP registry validators, or custom scripts — check consent legibility by reading the color and backgroundColor computed properties and computing a contrast ratio. This approach works when the visual color matches the CSS property. It fails completely when a filter or blend mode has been applied.

Consider this minimal attack:

/* What getComputedStyle reports: */
getComputedStyle(el).color           → "rgb(26, 26, 26)"  /* valid dark text */
getComputedStyle(el).backgroundColor → "rgb(255, 255, 255)" /* white background */
/* Computed contrast ratio: 18.1:1 — passes WCAG AAA */

/* What is actually rendered: */
/* filter: hue-rotate(180deg) on black text on white background:
   Black (#1a1a1a) on white: HSL(0, 0%, 10%)
   After hue-rotate(180deg): HSL(180, 0%, 10%) — same value (achromatic, no hue)
   But on colored text: hue-rotate(180deg) on HSL(0, 70%, 30%) shifts to HSL(180, 70%, 30%)
   Result: teal text on white — lower contrast than the original red-on-white */

The root cause is that CSS filters are compositing operations applied after the element's CSS properties determine the source color. They operate in the graphics pipeline, not in the CSS property model. getComputedStyle() reads from the CSS property model and has no visibility into the graphics pipeline transformation.

This is not a browser bug. It is how the CSS specification is designed: filter functions are applied as post-processing on the element's rendering, and getComputedStyle() reports the CSS-level properties, not the rendering result. The gap between these two representations is the attack surface.

Dimension 1: Hue manipulation via hue-rotate()

1

hue-rotate() — shift consent text color to near-background hue

Rotates the hue of every pixel in the element's rendering by N degrees. Can transform a valid high-contrast color into a near-background match while leaving the CSS color property unchanged.

The HSL color model represents colors as Hue (0°–360°), Saturation (0%–100%), and Lightness (0%–100%). CSS hue-rotate(Ndeg) adds N degrees to the hue component of every pixel — it does not change saturation or lightness.

If a consent element has color: hsl(0, 70%, 35%) (a dark red — valid contrast on white), applying filter: hue-rotate(180deg) produces hsl(180, 70%, 35%) — a teal. On a white background, this teal may have the same or lower contrast than the original red. On a page with a blue-gray color scheme, a hue-rotate(180deg) can shift dark blue text to a color that matches the background — near-zero contrast.

/* Malicious CSS — SA-CSS-HR-001 */
/* Consent text color on page with bg #e8f0fe (blue-gray): */
.mcp-consent {
  color: hsl(240, 60%, 30%); /* deep blue — 6.2:1 contrast on white */
  filter: hue-rotate(180deg); /* → hsl(60, 60%, 30%) — yellow-olive */
}
/* On the page background #e8f0fe (blue-gray):
   hsl(60, 60%, 30%) contrast against #e8f0fe:
   yellow-olive on pale blue → approximately 1.9:1 — fails AA */

/* The attacker picks the hue-rotate value that maps the logical color
   to the one with lowest contrast against the specific background */

For MCP servers targeting Claude's default interface colors, the attacker can pre-compute the exact rotation angle that minimizes contrast. The logical CSS color remains the original valid value; only reading getComputedStyle(el).filter and applying the rotation formula reveals the attack. See: CSS filter hue-rotate consent attack patterns.

Dimension 2: Luminance destruction via brightness() and contrast()

2

brightness() / contrast() — blow out or collapse consent text luminance

brightness() multiplies the luminance of every pixel. contrast() scales the distance of each channel from 128. Both can render dark text as white-on-white or gray-on-gray while reporting the original color from getComputedStyle.

Two luminance-axis attacks target different failure modes:

Blow-out attack: filter: brightness(10) multiplies every channel by 10. A dark text color rgb(26, 26, 26) becomes rgb(255, 255, 255) (clamped to 255) — white on white. Completely invisible. getComputedStyle(el).color still reports rgb(26, 26, 26).

Collapse attack: filter: contrast(0) maps every channel to 128 — all pixels become the same medium gray (rgb(128, 128, 128)). On a light gray background, medium gray text has a contrast ratio of approximately 1:1 — effectively invisible without appearing "off." More subtle: filter: contrast(0.1) compresses the contrast range to 10% without full collapse, producing very low contrast that is harder to detect than a flat gray.

/* Combined filter chain — more evasion-resistant */
.mcp-consent-text {
  color: #1a1a1a; /* getComputedStyle reports: rgb(26,26,26) — valid */
  filter: brightness(0.02) contrast(0);
  /* brightness(0.02): rgb(26,26,26) → rgb(0.5, 0.5, 0.5) ≈ near-black
     contrast(0): → rgb(128, 128, 128) = medium gray
     On page bg #f3f4f6 (light gray):
     rgb(128,128,128) contrast against #f3f4f6: ~2.0:1 — fails AA */
}

/* Dynamic chain: JS computes the exact filter chain from the page background */
const bg = getComputedStyle(document.body).backgroundColor;
const [r, g, b] = bg.match(/\d+/g).map(Number);
const targetContrast = 1.2; /* target: just below detection threshold */
/* Solve for brightness/contrast values that hit targetContrast */

The chained attack is particularly difficult to detect because both brightness() and contrast() are individually plausible (slight brightness reduction or slight contrast boost are common design choices), but their combined effect is destructive. See: CSS filter brightness and contrast consent attack patterns.

Dimension 3: Pixel compositing via mix-blend-mode

3

mix-blend-mode — composite rendered pixels with background layer

Blend modes apply a mathematical formula between the element's pixels and the background layer. difference mode inverts near-black text to near-white on white backgrounds. screen and multiply modes enable overlay attacks. Unlike filter, blend mode operates relative to the background — not the element in isolation.

mix-blend-mode is architecturally different from filter: instead of transforming a single element's pixels, it composites the element against its background layer using one of 16 blend mode formulas. This context-dependence creates attacks that are invisible in one testing environment (light mode) but effective in another (dark mode).

The critical formulas:

Blend modeFormula (per channel, 0–1 range)Effect on dark text on white
difference|A − B||1 − 0.1| = 0.9 → near-white on white → invisible
exclusionA + B − 2ABSimilar to difference — softer near-white result
screen1 − (1−A)(1−B)White overlay screens to white over any dark text
multiplyA × BWhite overlay × dark bg = dark bg (visible only in dark mode)

The multiply mode creates a particularly insidious theme-dependent attack: a white overlay element with mix-blend-mode: multiply is completely invisible on a white background (multiply of 1 × any = any → unchanged). But on the dark-mode install dialog, multiply(1, 0.1) = 0.1 — the white element takes the exact color of the dark background, becoming a dark cover layer over the consent text. The attack is invisible during light-mode testing; it activates only in the deployment context. See: CSS mix-blend-mode consent attack patterns.

Unlike filter effects, mix-blend-mode is not a property on the consent element itself — the blend mode might be on a sibling cover element or on the consent element's ancestor. The ancestor stacking context must be audited, not just the consent element's direct properties.

Why filters evade standard tools: comparison matrix

Standard consent auditors check a fixed set of properties against WCAG criteria. The table below shows which attacks each check method can and cannot detect:

Attack type getComputedStyle color check WCAG 1.4.3 contrast audit getBoundingClientRect() size check display/visibility/opacity check Detectable how
filter: hue-rotate()Misses — reports pre-filter colorMisses — uses computed colorUnaffectedUnaffectedCheck getComputedStyle(el).filter + apply formula
filter: brightness()Misses — reports pre-filter colorMisses — uses computed colorUnaffectedUnaffectedCheck filter value; brightness >5 or <0.05 on consent
filter: contrast()Misses — reports pre-filter colorMisses — uses computed colorUnaffectedUnaffectedCheck filter value; contrast <0.1 on consent
mix-blend-modeMisses — reports pre-blend colorMisses — uses computed colorUnaffectedUnaffectedCheck mixBlendMode on element and ancestors + apply blend formula
opacity: 0UnaffectedMisses — doesn't check opacityUnaffectedCatches if checking opacitygetComputedStyle(el).opacity === "0"
visibility: hiddenUnaffectedMissesUnaffected (has dimensions)Catches — visibility checkgetComputedStyle(el).visibility === "hidden"
display: noneN/A (no box)N/AReturns zero dimensionsCatches — display checkel.offsetParent === null

The table reveals the pattern: filter and blend mode attacks evade every standard check. Opacity and visibility attacks evade contrast checks but are caught by property checks. Display:none is caught by dimension checks. Filter attacks require new, explicitly implemented checks.

CSP scope and the filter: url() bypass

Content Security Policy can restrict some attack vectors but has no directive that covers CSS filter effects. filter: hue-rotate(), brightness(), contrast(), and mix-blend-mode are pure CSS properties — they require no external resource loading and are not governed by any CSP directive including style-src.

There is one filter attack variant that does involve resource loading: filter: url(#svgFilter) references a custom SVG filter defined inline in the document. An inline SVG <filter> element can implement arbitrary pixel transformations — including custom matrix transforms that map text color to near-background values:

/* SVG filter definition — inline in the HTML document */
<svg style="position:absolute;width:0;height:0">
  <filter id="consent-suppress">
    <feColorMatrix type="matrix"
      values="0 0 0 0 1
              0 0 0 0 1
              0 0 0 0 1
              0 0 0 0 1" />
    <!-- This matrix maps every pixel to white (1,1,1,1) — text disappears -->
  </filter>
</svg>

/* CSS referencing the inline SVG filter */
.mcp-consent-text {
  filter: url(#consent-suppress);
  /* Result: consent text rendered as white pixels — invisible on white background */
}

/* The CSS color property remains unchanged.
   The SVG filter definition is inline — no external resource fetch.
   CSP does not restrict this pattern. */

The url(#id) form references an inline document element — there is no network request and therefore no CSP connect-src or img-src restriction applies. The only detection path is to check the filter CSS property for url() references and inspect the referenced SVG filter element.

Unified detection algorithm

All three attack dimensions — hue, luminance, and blend mode — share the same detection architecture: read the relevant property, walk the ancestor chain, and apply the appropriate formula to compute the actual rendered color rather than relying on getComputedStyle(el).color.

/* Unified CSS filter + blend mode consent audit */
function auditFilterConsent() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  const FILTER_DANGEROUS = /hue-rotate|brightness|contrast|saturate|blur|url\(/;

  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 400) || '')) continue;

    /* Walk element and ancestors for filter + blend mode properties */
    let node = el;
    while (node && node !== document.body) {
      const s = window.getComputedStyle(node);

      /* ---- FILTER CHECK ---- */
      const filter = s.filter || s.webkitFilter || '';
      if (filter && filter !== 'none' && FILTER_DANGEROUS.test(filter)) {
        /* Parse and evaluate each filter function */
        const fns = parseFilterFunctions(filter);
        for (const fn of fns) {
          if (fn.name === 'hue-rotate') {
            const deg = fn.args[0];
            /* Any non-zero hue rotation on consent text is suspicious;
               >90deg is almost certainly an attack */
            if (Math.abs(deg) > 30) {
              findings.push({ id: 'SA-CSS-HR-001', severity: 'high',
                message: `Consent element/ancestor has filter: hue-rotate(${deg}deg). ` +
                  `This shifts the rendered hue of consent text by ${deg}° without ` +
                  `changing the CSS color property — getComputedStyle().color is unreliable.` });
            }
          } else if (fn.name === 'brightness') {
            const val = fn.args[0];
            if (val > 5 || val < 0.05) {
              findings.push({ id: 'SA-CSS-BR-001', severity: 'high',
                message: `Consent element/ancestor has filter: brightness(${val}). ` +
                  `Values >5 blow out dark text to white; values <0.05 collapse text to near-black on black.` });
            }
          } else if (fn.name === 'contrast') {
            const val = fn.args[0];
            if (val < 0.1) {
              findings.push({ id: 'SA-CSS-CT-001', severity: 'high',
                message: `Consent element/ancestor has filter: contrast(${val}). ` +
                  `Values near 0 map all pixels to medium gray — text disappears on light-gray background.` });
            }
          } else if (fn.name === 'url') {
            findings.push({ id: 'SA-CSS-SVG-001', severity: 'critical',
              message: `Consent element/ancestor has filter: url("${fn.args[0]}"). ` +
                `SVG filter effects can perform arbitrary pixel transforms including full whitewash. ` +
                `Inspect the referenced filter element for color matrix values.` });
          }
        }
      }

      /* ---- BLEND MODE CHECK ---- */
      const mbm = s.mixBlendMode;
      if (mbm && mbm !== 'normal' && mbm !== 'unset' && mbm !== 'initial') {
        findings.push({ id: 'SA-CSS-MBM-001', severity: 'high',
          message: `Consent element/ancestor has mix-blend-mode: "${mbm}". ` +
            `Blend modes change rendered pixel color based on the background layer — ` +
            `getComputedStyle().color is the pre-blend value, not the rendered result.` });
      }

      node = node.parentElement;
    }
  }
  return findings;
}

/* Minimal filter function parser */
function parseFilterFunctions(filterStr) {
  const results = [];
  const re = /(\w[\w-]*)\(([^)]*)\)/g;
  let m;
  while ((m = re.exec(filterStr)) !== null) {
    const name = m[1];
    const rawArgs = m[2].trim();
    /* Parse numeric arg — handle deg, %, unitless */
    const num = parseFloat(rawArgs.replace(/[^0-9.\-]/g, ''));
    /* Normalize: percent → 0-1 range for brightness/contrast/saturate */
    const normalized = rawArgs.endsWith('%') ? num / 100 : num;
    results.push({ name, args: [normalized] });
  }
  return results;
}

The unified algorithm handles all three attack dimensions in a single pass. The key insight is walking the ancestor chain — blend mode and filter attacks frequently target an ancestor of the consent element, not the element itself. An element-only check misses these patterns entirely.

Four things to add to any MCP consent auditor:
1. Check getComputedStyle(el).filter and webkitFilter on the consent element AND every ancestor
2. Check getComputedStyle(el).mixBlendMode on the same set
3. For url() filter references, inspect the SVG filter element's feColorMatrix values
4. Flag any dynamic style mutations on consent elements during install-button interaction windows

Further reading

Each attack dimension covered in this post has a dedicated reference page with full attack patterns, severity classifications, and detector code:

SkillAudit's consent audit checks filter, webkitFilter, and mixBlendMode on every consent element and ancestor, parses filter function arguments to detect dangerous values, and flags SVG filter url() references for inspection. It catches all three attack dimensions — hue, luminance, and compositing — that are invisible to standard WCAG contrast checkers. Paste your MCP server URL at skillaudit.dev to run a full filter-effect audit.