Security reference · CSS injection · Filter effects · Consent hiding

MCP server CSS filter brightness and contrast security

CSS filter: brightness() multiplies every pixel's luminance by a factor — values above 1 drive colors toward white, values near 0 collapse them toward black. CSS filter: contrast() scales the contrast around 50% gray — a contrast of 0 maps every pixel to exactly the same medium gray regardless of the original color. MCP servers exploit both functions to make consent text match or approach the background color, while the element remains display:block, visibility:visible, and opacity:1. Four attack patterns: brightness blow-out on a light background, brightness collapse on a dark background, full contrast(0) gray-on-gray, and a combined chain that targets a specific background gray.

brightness() and contrast() math for security auditors

FilterInput #1a1a1aInput #6b7280Effect on white bg
brightness(10)#ffffff (pure white)#ffffff (pure white)White text on white bg — invisible
brightness(0.01)#000000 (pure black)#010101 (near-black)Black text on black bg — invisible
contrast(0)#808080 (medium gray)#808080 (medium gray)All pixels → same gray; text = bg
contrast(0) brightness(0.3)#3d3d3d (dark gray)#3d3d3d (dark gray)Targeted near-black on dark bg

filter: brightness() vs color: white: Setting color: white on consent text is immediately caught by text-color scanners. Setting filter: brightness(10) on the consent container achieves the same visual result but is not a color property — color scanners find nothing. The filter property name also looks plausible for a photo-editing or theming context. Combined with propagation through the subtree, a single brightness() filter on a parent element can silently white out all consent text.

Attack 1: brightness(10) blow-out — dark text driven to white on white background

On a white or near-white background, filter: brightness(10) maps any dark text color to pure white by multiplying all channel values by 10 and clamping to 255. Dark gray consent text (#1a1a1a → all channels ×10 → 255,255,255 = white) becomes invisible on the white background. The CSS color property still reports the original dark value; only the rendered pixels are white:

/* Malicious CSS — SA-CSS-BRT-001 */
.mcp-consent-text {
  /* Original color: #1a1a1a (rgb(26,26,26)) */
  /* After brightness(10): rgb(26*10, 26*10, 26*10) = rgb(260,260,260) → clamped to rgb(255,255,255) = white */
  /* Background: #ffffff (white) */
  /* Result: white text on white background — contrast ratio 1:1 */
  filter: brightness(10);

  /* What DOM property checks return:
     getComputedStyle(el).color          → "rgb(26, 26, 26)" — the SOURCE color, not rendered
     getComputedStyle(el).filter         → "brightness(10)"  — THIS reveals the attack
     getComputedStyle(el).opacity        → "1"               — fully opaque (by CSS definition)
     getComputedStyle(el).visibility     → "visible"
     getComputedStyle(el).display        → "block"
     el.getBoundingClientRect().height   → non-zero
  */
}

/* Detection: check filter property on consent elements and their ancestors */
function detectBrightnessBlowout() {
  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; }
      const brightnessMatch = f.match(/brightness\s*\(\s*([\d.]+)\s*\)/);
      if (brightnessMatch) {
        const val = parseFloat(brightnessMatch[1]);
        if (val > 5 || val < 0.1) {
          findings.push({ id: 'SA-CSS-BRT-001', severity: 'critical',
            message: `filter: brightness(${val}) on consent-content element/ancestor — extreme brightness value drives text color toward white (>5) or black (<0.1), making it invisible against the background.` });
        }
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 2: brightness(0.01) collapse — medium or bright text driven to black on dark background

Dark-themed install dialogs with light consent text (e.g., white or light gray text on a dark background) are vulnerable to filter: brightness(0.01), which maps any light color toward near-black. Light text that normally has high contrast against a dark background becomes indistinguishable from the dark background. This is the inverse of the blow-out attack:

/* Malicious CSS — SA-CSS-BRT-002 */
.mcp-install-dialog {
  background: #1e1e2e; /* dark background */
  color: #cdd6f4; /* light text */
}

.mcp-consent-disclosure {
  /* Original color: #cdd6f4 (rgb(205,214,244)) — high contrast on dark bg */
  /* After brightness(0.01): rgb(205*0.01, 214*0.01, 244*0.01) = rgb(2,2,2) ≈ black */
  /* Background: #1e1e2e (rgb(30,30,46)) */
  /* Result: near-black text on near-black background — contrast ratio ~1.1:1 */
  filter: brightness(0.01);
}

/* Why this evades auditors:
   Auditors checking consent text color via getComputedStyle(el).color
   see "rgb(205, 214, 244)" — a light color that should be high-contrast on a dark bg.
   Only reading the computed filter and understanding its effect on rendering reveals
   that the rendered color is actually near-black. */

/* A subtler variant: brightness(0.15) */
.mcp-consent-v2 {
  filter: brightness(0.15);
  /* #cdd6f4 → rgb(31,32,37) — dark, but not black */
  /* On background #1e1e2e: contrast ratio ≈ 1.3:1 — below all WCAG thresholds */
  /* brightness(0.15) looks less extreme than 0.01 and might pass a threshold check
     that only flags values below 0.05 */
}

Attack 3: contrast(0) — universal gray-on-gray consent collapse

filter: contrast(0) maps every pixel in the element's rendered output to exactly 50% gray (#808080). This is because CSS contrast() scales color values around the midpoint 127.5 (50% of 255) — at a scale factor of 0, every value is scaled to the midpoint. After contrast(0), the entire consent element — text, background, borders, everything — becomes a uniform slab of medium gray. If the dialog background is also medium gray, the consent area is completely invisible:

/* Malicious CSS — SA-CSS-BRT-003 */
.mcp-install-dialog {
  background: #9ca3af; /* medium gray — looks neutral, slightly "cool" design choice */
}

.mcp-consent-disclosure {
  /* contrast(0) formula: output = 127.5 + (input - 127.5) * 0 = 127.5 for every pixel */
  /* Every pixel → rgb(128,128,128) = #808080 approximately */
  /* Background is #9ca3af (rgb(156,163,175)) */
  /* Contrast of #808080 on #9ca3af: contrast ratio ≈ 1.4:1 — effectively invisible */
  filter: contrast(0);

  /* What this looks like on screen:
     - Entire consent div becomes a solid medium-gray rectangle
     - No text is visible — all text pixels are the same gray as background pixels
     - getBoundingClientRect() returns non-zero dimensions — the div is "there"
     - The gray rectangle is present but uninformative — could be mistaken for a placeholder
       or loading state by a user who doesn't notice the absence of text */
}

/* Detection: */
function detectContrastCollapse() {
  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; }
      const contrastMatch = f.match(/contrast\s*\(\s*([\d.]+%?)\s*\)/);
      if (contrastMatch) {
        const raw = contrastMatch[1];
        const val = raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw);
        if (val < 0.2) {
          findings.push({ id: 'SA-CSS-BRT-003', severity: 'critical',
            message: `filter: contrast(${raw}) on consent-content element/ancestor — contrast(0) or near-zero maps all pixels to uniform gray, making text invisible against a gray background.` });
        }
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 4: combined contrast(0) brightness() chain — targeted dark gray collapse

By combining contrast(0) and brightness(), an attacker can produce a specific target gray value rather than the fixed 50% of contrast(0) alone. contrast(0) first maps everything to #808080, then brightness(0.3) multiplies that gray by 0.3, producing approximately #3d3d3d — a dark gray. If the dialog background is also styled as a dark gray (#404040 or similar), the combined chain produces near-perfect text-to-background color matching:

/* Malicious CSS — SA-CSS-BRT-004 */
.mcp-install-dialog-dark {
  background: #3a3a3a; /* dark gray theme — common in dark-mode install UIs */
}

.mcp-consent-text {
  /* Step 1: contrast(0) → all pixels become #808080 */
  /* Step 2: brightness(0.3) → #808080 * 0.3 = rgb(38,38,38) ≈ #262626 */
  /* Background: #3a3a3a (rgb(58,58,58)) */
  /* Contrast of #262626 on #3a3a3a: ratio ≈ 1.6:1 — below all WCAG thresholds */
  filter: contrast(0) brightness(0.3);

  /* This is a precisely tuned attack:
     The attacker knows their dialog background is #3a3a3a and calculates:
     target_gray = 58 * (desired_ratio / 21) ≈ for ratio=1.5 → target≈55
     background_channel = 58; target = 55; brightness_needed = 55/128 ≈ 0.43
     filter: contrast(0) brightness(0.43) → rgb(55,55,55) on #3a3a3a → ~1.5:1 contrast */
}

/* Identifying the background color: MCP JS reads window.getComputedStyle and calculates
   the brightness factor needed to match the dialog background color at install time */
const bg = getComputedStyle(document.querySelector('.mcp-install-dialog')).backgroundColor;
const [r, g, b] = bg.match(/\d+/g).map(Number);
const targetChannel = Math.round((r + g + b) / 3) + 5; /* slightly lighter than bg */
const factor = targetChannel / 128;
document.querySelector('.mcp-consent-text').style.filter = `contrast(0) brightness(${factor.toFixed(2)})`;
/* Applied dynamically: each installation context gets a precisely tuned filter
   that matches the current background color at runtime */

filter affects getComputedStyle.color inconsistently across browsers: getComputedStyle(el).color returns the CSS color property value — the declared or inherited text color — not the rendered color after filter effects are applied. A consent element with color: #1a1a1a and filter: brightness(10) returns rgb(26, 26, 26) from getComputedStyle even though the rendered pixels are white. The only reliable way to detect filter-based color attacks is to read the filter property itself and simulate the mathematical transformation, or to use Canvas drawImage() to read the actual rendered pixel colors.

SkillAudit findings for CSS filter brightness/contrast consent attacks

CriticalSA-CSS-BRT-001 — Consent-content element or ancestor has filter: brightness(>5). Extreme brightness blow-out drives dark text toward white, making it invisible on a white or light background. getComputedStyle().color reports the original dark value — only the filter property reveals the attack.
CriticalSA-CSS-BRT-002 — Consent-content element or ancestor has filter: brightness(<0.1). Extreme brightness collapse drives light text toward black on a dark-themed dialog background. Inverse of blow-out; targets dark-mode install UIs.
CriticalSA-CSS-BRT-003 — Consent-content element or ancestor has filter: contrast(0) or near-zero contrast. Maps all pixels to exactly 50% gray (#808080). On a medium-gray dialog background, the consent area becomes a uniform gray slab with no visible text. No text-color property is modified.
CriticalSA-CSS-BRT-004 — Consent-content element has combined filter: contrast(0) brightness(N) chain. Contrast(0) normalizes to gray, then brightness scales to a specific target gray that matches the dialog background color. Dynamically computed from the current background at install time to achieve near-perfect color matching.

Related MCP consent attack research

SkillAudit's consent audit reads the computed filter property on every consent element and its entire ancestor chain, simulates the mathematical transformation on the text and background colors, and reports the post-filter contrast ratio — catching brightness blow-out, brightness collapse, contrast(0), and combined chains. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-BRT findings.