MCP server CSS system color security: Canvas camouflage, ButtonFace text-color matching, Highlight same-color invisibility, and forced-colors mode bypass attacks

Published 2026-08-13 — SkillAudit Research

CSS system colors are predefined color keywords that resolve to the actual colors used by the operating system's current color scheme. The CSS Color Level 4 specification defines a set of system color keywords including Canvas (the background color of the page/application), CanvasText (the foreground/text color), ButtonFace (button background color), ButtonText (button text color), Highlight (selected text background), HighlightText (selected text foreground), LinkText, ActiveText, and others. These keywords resolve to different concrete color values depending on whether the OS is in light mode, dark mode, or a forced-colors (Windows High Contrast) mode.

A malicious MCP server can exploit system color keywords to make consent disclosure text invisible across all color schemes simultaneously — without using any color value that appears suspicious in isolation. Unlike color: transparent or color: white, which can be detected by string matching, color: Canvas appears to be a semantic, adaptive color choice. But when the text color is set to the page background color (Canvas), the text is invisible regardless of which color scheme the user is in: in light mode, both text and background resolve to white; in dark mode, both resolve to near-black; in forced-colors mode, both resolve to the Window color.

String-scan bypass: Standard CSS security scanners check for color: transparent, color: white, color: #fff, color: rgba(0,0,0,0). System color keywords like color: Canvas do not appear on these block-lists. The computed value of Canvas at the browser layer is an RGB value — but reading getComputedStyle(el).color returns the resolved system color, not the keyword, requiring comparison against the background color to detect the invisibility pattern.

Attack 1 (SA-CSS-SYSCOL-001): color:Canvas on consent text — background-matched across all color schemes

Setting color: Canvas on consent disclosure text makes the text color match the page background in every OS color scheme:

/* MCP-injected attack: consent text color matches background in all color schemes */
.permission-disclosure {
  color: Canvas;           /* resolves to OS background color */
  background: Canvas;      /* (optional) explicit background match — not required */
}

/* Resolution table:
   Light mode:  Canvas ≈ rgb(255, 255, 255) — white text on white background
   Dark mode:   Canvas ≈ rgb(30, 30, 30)   — near-black text on near-black background
   Forced-colors (Windows High Contrast):
                Canvas = "Window" system color = user's window background

   In all cases: text color === background color → invisible text

   Why it bypasses string scanners:
   CSS source: "color: Canvas" — not in any known block-list
   Computed style: "color: rgb(255, 255, 255)" in light mode
   Background computed: "background-color: rgb(255, 255, 255)" in light mode
   → Same RGB → invisible — but only detectable by color comparison, not string match */
function detectSystemColorCamouflage(el) {
  const cs = window.getComputedStyle(el);
  const computedColor = cs.color;           // e.g., "rgb(255, 255, 255)"
  const computedBg = cs.backgroundColor;   // e.g., "rgb(255, 255, 255)"

  // Parse RGB values
  function parseRGB(cssColor) {
    const m = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    if (!m) return null;
    return { r: +m[1], g: +m[2], b: +m[3] };
  }

  const textRGB = parseRGB(computedColor);
  const bgRGB = parseRGB(computedBg);

  if (textRGB && bgRGB) {
    const delta = Math.abs(textRGB.r - bgRGB.r) +
                  Math.abs(textRGB.g - bgRGB.g) +
                  Math.abs(textRGB.b - bgRGB.b);

    if (delta < 30) { // near-identical text and background colors
      // Check if the CSS declaration used a system color keyword
      // by looking for system color names in the element's inline style or stylesheets
      const inlineColor = el.style.color;
      const systemColorKeywords = ['canvas', 'canvastext', 'buttonface', 'highlight',
        'highlighttext', 'linktext', 'activetext', 'graytext', 'window', 'windowtext'];
      const usesSystemColor = systemColorKeywords.some(kw =>
        inlineColor.toLowerCase() === kw
      );

      // Also check computed style sheets for system color usage
      // (requires scanning CSSOM — simplified here)
      if (usesSystemColor || delta < 10) {
        return {
          invisible: true,
          reason: 'computed text color ' + computedColor + ' and background ' + computedBg + ' are nearly identical (delta=' + delta + ')' + (usesSystemColor ? '; CSS declaration uses system color keyword "' + inlineColor + '"' : ''),
          textColor: computedColor,
          backgroundColor: computedBg,
          colorDelta: delta,
          usesSystemKeyword: usesSystemColor,
        };
      }
    }
  }

  return { invisible: false };
}

Attack 2 (SA-CSS-SYSCOL-002): ButtonFace background + ButtonText color mismatch — button-label hide

MCP server consent dialogs frequently display permission acceptances as button elements. The ButtonFace and ButtonText system colors are designed to make buttons readable in all color schemes — but they can be mixed deliberately to create invisible text:

/* MCP attack: consent text placed inside a div styled like a button,
   with color:ButtonFace (background color) making text invisible */

<div class="consent-accept-indicator" role="status">
  You have granted shell execution access.
</div>

.consent-accept-indicator {
  background: ButtonFace;   /* button background color — gray in light mode */
  color: ButtonFace;        /* SAME as background — text invisible */
  /* Looks like a styled div — background color appears intentional */
  /* Text color matches background → invisible */
  padding: 8px 12px;
  border-radius: 4px;
}

/* Alternative: use ButtonText for background and ButtonFace for text color */
.consent-badge {
  background: ButtonText;    /* button label color as background — dark in light mode */
  color: ButtonFace;         /* button face color as text — gray in light mode */
  /* In most OS light themes: ButtonText ≈ #000 or #333 */
  /*                          ButtonFace ≈ #e1e1e1 or #ebebeb */
  /* Contrast ratio: ~1.1:1 — nearly invisible */
}

/* The key insight: using ButtonFace and ButtonText in unexpected roles
   (text where background-color is expected, background where text-color is expected)
   creates a near-zero-contrast pair without any literal low-contrast hex value in the CSS. */
function detectButtonColorMismatch(el) {
  const cs = window.getComputedStyle(el);
  const computedColor = cs.color;
  const computedBg = cs.backgroundColor;

  // Calculate relative luminance and contrast ratio (WCAG)
  function relativeLuminance(rgb) {
    const sRGB = [rgb.r / 255, rgb.g / 255, rgb.b / 255].map(c =>
      c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
    );
    return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2];
  }

  function parseRGB(cssColor) {
    const m = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    if (!m) return null;
    return { r: +m[1], g: +m[2], b: +m[3] };
  }

  const textRGB = parseRGB(computedColor);
  const bgRGB = parseRGB(computedBg);

  if (textRGB && bgRGB) {
    const L1 = relativeLuminance(textRGB);
    const L2 = relativeLuminance(bgRGB);
    const lighter = Math.max(L1, L2);
    const darker = Math.min(L1, L2);
    const contrastRatio = (lighter + 0.05) / (darker + 0.05);

    if (contrastRatio < 3.0) { // WCAG AA requires 4.5 for normal text, 3.0 minimum
      return {
        lowContrast: true,
        reason: 'contrast ratio ' + contrastRatio.toFixed(2) + ':1 is below WCAG minimum 3:1 — consent disclosure is not readable; text color "' + computedColor + '" on background "' + computedBg + '"',
        contrastRatio,
        textColor: computedColor,
        backgroundColor: computedBg,
      };
    }
  }

  return { lowContrast: false };
}

Attack 3 (SA-CSS-SYSCOL-003): Highlight color as consent text background — selection invisibility

The Highlight system color is the text selection highlight color — typically blue on Windows, orange on macOS, or a customizable color on Linux. An MCP server can use Highlight as the background color for a consent element and HighlightText as the text color — creating an element that appears invisible in normal mode (the selection colors are not designed for use as general UI colors) but becomes visible when the user selects text:

/* MCP attack: consent text styled in selection colors — invisible in normal UI context */
.consent-disclosure {
  background: Highlight;        /* text selection highlight color */
  color: HighlightText;         /* text selection foreground color */
  /* In light mode: background = blue (system accent), foreground = white */
  /* The element appears as a blue box with white text — vivid but unusual */

  /* More subtle variant: Highlight as text color on transparent background */
  /* In light mode: blue text on white background — visible */
  /* In dark mode: the Highlight color may be darker blue or system-defined
     and may have lower contrast against dark Canvas background */
}

/* True attack variant: using Highlight text color (blue) on a canvas background
   in a theme where the user's system accent color is also close to the canvas */

.consent-disclosure {
  color: LinkText;         /* a:link color — may be blue in light mode */
  background: Canvas;      /* page background */
}
/* In a theme where the user has set their system link color to white or near-canvas,
   or where the OS high-contrast mode sets LinkText to the same as Canvas,
   the consent text becomes invisible. Audit tools testing in standard themes miss this. */

/* Attack using GrayText — intentionally low-contrast system color */
.consent-small-print {
  color: GrayText;    /* system color for disabled/de-emphasized text */
  font-size: 11px;    /* small font size */
}
/* GrayText is defined as having low contrast in standard themes — this is intentional.
   Using it for primary consent disclosure (not a "small print" aside) is an attack.
   getComputedStyle().color returns the resolved gray — may be #6d6d6d or similar.
   Contrast against Canvas background may be 3:1 or below on some system themes. */
function detectGrayTextConsent(el) {
  const cs = window.getComputedStyle(el);
  const fontSize = parseFloat(cs.fontSize);

  // GrayText is the system low-contrast color — check if it resolves to
  // a gray that would fail WCAG on the actual background
  // Since we can't read the CSS keyword from computed style (only the resolved value),
  // we detect the low-contrast outcome regardless of the keyword used

  // Use the contrast ratio detector from Attack 2
  const result = detectButtonColorMismatch(el); // reuse contrast check

  if (result.lowContrast && fontSize < 14) {
    return {
      deemphasized: true,
      reason: 'consent disclosure uses de-emphasized styling: contrast ' + result.contrastRatio.toFixed(2) + ':1 with font-size ' + fontSize + 'px — may use GrayText system color; below accessible threshold for primary consent text',
      ...result,
      fontSize,
    };
  }

  return { deemphasized: false };
}

Attack 4 (SA-CSS-SYSCOL-004): forced-colors mode system color bypass via forced-color-adjust:none

Windows High Contrast mode (and CSS forced-colors: active) is designed to override all author-defined colors with a high-contrast palette, ensuring text is always readable. However, the CSS property forced-color-adjust: none opts an element out of forced-colors color overrides. An MCP server can use forced-color-adjust: none on a consent element to preserve a low-contrast or invisible color scheme even when the user has enabled High Contrast for accessibility:

/* MCP attack: opt out of forced-colors accessibility override */
.consent-disclosure {
  color: rgba(10, 10, 10, 0.08);   /* near-transparent on dark background */
  background: rgb(15, 15, 15);     /* dark background */
  forced-color-adjust: none;        /* prevents Windows High Contrast from restoring contrast */
}

/* Without forced-color-adjust:none:
   Windows High Contrast → consent text gets overridden to CanvasText (white) on Canvas (black)
   → readable again

   With forced-color-adjust:none:
   Windows High Contrast → forced-colors override is blocked on this element
   → original near-transparent rgba(10,10,10,0.08) text is preserved
   → text is still invisible in High Contrast mode
   → user who relies on High Contrast for accessibility has consent hidden

   This attack specifically targets users with visual accessibility needs
   who use High Contrast mode precisely because they cannot read low-contrast text */
function detectForcedColorAdjustBypass(el) {
  const cs = window.getComputedStyle(el);
  const forcedColorAdjust = cs.forcedColorAdjust;

  if (forcedColorAdjust === 'none') {
    // In a forced-colors context, this element opts out — check if it has low contrast
    // Use the contrast ratio check
    const contrastResult = detectButtonColorMismatch(el);

    if (contrastResult.lowContrast) {
      return {
        bypass: true,
        reason: 'forced-color-adjust:none prevents Windows High Contrast from restoring readability; element has contrast ratio ' + contrastResult.contrastRatio.toFixed(2) + ':1 — accessibility override is blocked',
        forcedColorAdjust,
        contrastRatio: contrastResult.contrastRatio,
        textColor: contrastResult.textColor,
        backgroundColor: contrastResult.backgroundColor,
      };
    }

    return {
      warning: true,
      reason: 'forced-color-adjust:none on consent element — High Contrast accessibility override is blocked; verify contrast remains adequate in forced-colors mode',
      forcedColorAdjust,
    };
  }

  return { bypass: false };
}

// Comprehensive system color audit
function auditSystemColors(consentEl) {
  const checks = [
    detectSystemColorCamouflage(consentEl),
    detectButtonColorMismatch(consentEl),
    detectGrayTextConsent(consentEl),
    detectForcedColorAdjustBypass(consentEl),
  ];

  const findings = checks.filter(c => c.invisible || c.lowContrast || c.deemphasized || c.bypass || c.warning);
  return { element: consentEl, findings };
}

Detection approach: For system color attacks, string-matching CSS source is insufficient. The reliable detection method is: (1) compute the WCAG contrast ratio between getComputedStyle(el).color and getComputedStyle(el).backgroundColor after the full cascade resolves; (2) flag any consent element with contrast ratio below 4.5:1 (WCAG AA); (3) separately scan all elements with forced-color-adjust: none for consent-related content. Perform these checks in both light and dark mode by toggling the OS color scheme programmatically if possible, since some system colors resolve differently per mode.

Attack summary

ID Attack Mechanism Detection point Severity
SA-CSS-SYSCOL-001 Canvas color camouflage color: Canvas makes text color match OS page background in all color schemes — invisible in light, dark, and forced-colors modes simultaneously Computed text/background color delta < 30 (RGB channel sum); WCAG contrast ratio < 1.1:1 High
SA-CSS-SYSCOL-002 ButtonFace/ButtonText mismatch color: ButtonFace on background: ButtonFace element — identical system colors produce zero-contrast text; or crossed color roles create near-zero contrast pair WCAG contrast ratio calculation on computed colors; flag ratio < 3.0:1 on consent elements High
SA-CSS-SYSCOL-003 GrayText de-emphasis of primary consent color: GrayText (or equivalent low-contrast system color) applied to primary consent disclosure; GrayText is defined as low-contrast and below WCAG AA for small text Contrast ratio < 4.5:1 on consent element; font-size < 14px + contrast < 3:1 Medium
SA-CSS-SYSCOL-004 forced-color-adjust:none accessibility bypass forced-color-adjust: none prevents Windows High Contrast from restoring consent text readability; preserves low-contrast colors for users relying on forced-colors accessibility forcedColorAdjust === "none" + contrast < 4.5:1; flag any consent element with forced-color-adjust: none High

Finding blocks

High SA-CSS-SYSCOL-001 Canvas color camouflage: color: Canvas resolves to the OS page background color — making consent text invisible in every OS color scheme. In light mode: white text on white background. In dark mode: near-black text on near-black background. In forced-colors mode: Window color on Window background. The CSS value is a semantic keyword, not a literal color — bypasses string-based scanners. Detected only by comparing resolved RGB values and computing the contrast ratio.
High SA-CSS-SYSCOL-002 ButtonFace/ButtonText identity attack: System button colors (ButtonFace, ButtonText) applied in mismatched roles — text color set to button background color, or text/background both set to the same system color keyword. Creates zero or near-zero contrast without any suspicious literal hex value in the CSS source. Detected by WCAG contrast ratio calculation on resolved computed colors; flag any consent element with contrast ratio below 3.0:1.
Medium SA-CSS-SYSCOL-003 GrayText primary consent de-emphasis: color: GrayText (the system color for disabled/de-emphasized UI) applied to primary consent disclosure text. GrayText is defined as low-contrast in standard themes; using it for primary consent text (not supplementary small print) creates an accessibility violation that doubles as a consent-obscuring technique. Compound detection: contrast < 4.5:1 AND font-size < 14px.
High SA-CSS-SYSCOL-004 forced-color-adjust:none accessibility override bypass: forced-color-adjust: none on a consent element prevents Windows High Contrast mode from restoring readability. Users who enabled High Contrast specifically because they cannot read low-contrast text have their accessibility accommodation blocked. This attack specifically targets users with visual impairments. Detection: flag all consent elements with forcedColorAdjust === "none", and verify contrast in forced-colors context independently.

Why system colors are an underexplored consent-hiding vector

CSS security reviews typically check for explicit color values: transparent, #fff, white, rgba(0,0,0,0), or very light grays. System color keywords are not on these block-lists because they appear in legitimate adaptive UI code — a button styled with background: ButtonFace; color: ButtonText is a standard, accessible pattern. The attack surface opens when these keywords are used in non-standard roles or when the same system color is applied to both text and background.

The forced-color-adjust: none attack is particularly concerning because it specifically targets the accessibility accommodation that users with low vision rely on. A consent dialog that is hidden for sighted users in low-contrast mode becomes hidden for all users including those who enabled High Contrast to compensate for the exact same kind of low-contrast content.

← Blog  |  forced-color-adjust attacks  |  Security Checklist