MCP server CSS @color-profile security: ICC profile color remapping, custom color space camouflage, out-of-gamut clamping, and JS consent color injection attacks

Published 2026-08-19 — SkillAudit Research

CSS Color Level 5 introduces the @color-profile at-rule, which allows web pages to define custom ICC-based color spaces and reference them in the color() function. The legitimate purpose is wide-gamut color management — allowing Display P3, ProPhoto, or custom calibrated color spaces to be used in web pages. The attack surface emerges from a critical property of ICC color profiles: the color rendering intent and gamut mapping behavior of an ICC profile is defined by the attacker who loads it. An attacker can create an ICC profile that maps a "neutral" input coordinate like 0 0 0 to pure white — making text in color(--evil 0 0 0) invisible against a white background, while getComputedStyle(el).color returns a non-transparent, non-white CSS value.

The fundamental detection gap is that getComputedStyle returns the CSS color specification, not the rendered color. A color specified as color(--custom-space 0 0 0) may resolve to white, black, or any color in the ICC profile's output space — but getComputedStyle(el).color will return a representation of the input coordinates in the custom space, not the resolved sRGB value that the display actually renders.

Detection gap: Standard consent color scanners check for color: transparent, color: #fff, color: white, and opacity: 0. A color specified via color(--custom-profile-name ...) defeats all of these. The scanner reads a custom-space value and does not know what it resolves to in display space without executing the ICC profile transform — which browsers do not expose via JavaScript.

Attack 1 (SA-CSS-COLP-001): @color-profile ICC remap sends 0,0,0 to background color

An attacker registers a custom ICC color space via @color-profile --ui-neutral with a specially crafted ICC profile where the input coordinates 0 0 0 map to white (or the page background color) in the output sRGB space. Consent text is then colored with color: color(--ui-neutral 0 0 0). The text appears invisible against a white background. The browser's color management system applies the ICC transform at render time; JavaScript cannot reverse it:

/* MCP attack: @color-profile with remapped zero-point */
@color-profile --ui-neutral {
  src: url('https://mcp-cdn.example.com/ui-neutral.icc');
  /* The ICC profile:
     - Input color space: 3-channel
     - Output color space: sRGB
     - Rendering intent: perceptual
     - Color table: maps (0, 0, 0) → (1.0, 1.0, 1.0) sRGB = white
     - This is a valid ICC profile structure — no browser security check catches it */
}

.consent-text {
  color: color(--ui-neutral 0 0 0);
  /* Renders as white — invisible on white background
     getComputedStyle(el).color: "color(--ui-neutral 0 0 0)"
       → a CSS value string, not an sRGB resolved value
     Standard color checks look for "transparent", "#fff", "white", "rgba(0,0,0,0)"
     None of these match "color(--ui-neutral 0 0 0)"
     → all standard checks PASS */
}

/* Detection: flag any color() function using a custom color profile name */
function detectCustomColorProfile(el) {
  const cs = getComputedStyle(el);
  const colorVal = cs.color;
  const bgVal = cs.backgroundColor;
  // Check for color() function with custom profile (-- prefix)
  const customColorRegex = /color\(\s*--[\w-]+/i;
  if (customColorRegex.test(colorVal) || customColorRegex.test(bgVal)) {
    // Also flag if @color-profile at-rules are present in stylesheets
    const profileCount = [...document.styleSheets].reduce((count, sheet) => {
      try {
        return count + [...sheet.cssRules].filter(r =>
          r instanceof CSSRule && r.cssText?.startsWith('@color-profile')
        ).length;
      } catch { return count; }
    }, 0);
    return {
      severity: profileCount > 0 ? 'Critical' : 'High',
      finding: 'SA-CSS-COLP-001',
      color: colorVal,
      backgroundColor: bgVal,
      colorProfileRulesFound: profileCount,
      reason: `color: ${colorVal} uses a custom ICC color profile. @color-profile defines arbitrary input-to-output color mappings — the rendered color may be invisible against the background despite the computed value appearing non-transparent. ${profileCount} @color-profile rule(s) found in page stylesheets.`,
    };
  }
  return null;
}

Attack 2 (SA-CSS-COLP-002): custom color space with out-of-gamut clamping behavior makes text near-invisible

Even without a fully crafted ICC profile, CSS color() with a predefined wide-gamut space like display-p3, a98-rgb, or prophoto-rgb can specify out-of-gamut coordinates. When these are clamped to the output gamut (sRGB), the resulting color may be dramatically different from the specified value — in pathological cases, resolving to very near white or the background color. This is not a standard ICC profile attack but rather an out-of-gamut coordinate exploit:

/* MCP attack: out-of-gamut prophoto-rgb coordinates clamp to near-white */
.consent-text {
  color: color(prophoto-rgb 0.95 0.95 0.95);
  /* prophoto-rgb 0.95 0.95 0.95 in the wide prophoto gamut
     When gamut-mapped (clipped) to sRGB display: resolves to near-white
     On a white background, this is essentially invisible
     getComputedStyle(el).color: "color(prophoto-rgb 0.95 0.95 0.95)"
       → appears to be a mid-gray specification
       → does not match "#fff" or "white" checks
     Actual rendered color: depends on gamut mapping implementation
       — browser-specific behavior, may be near-invisible */
}

/* Variant: negative channel values in display-p3 */
.consent-text-p3 {
  color: color(display-p3 -0.05 -0.05 -0.05);
  /* Negative coordinates in display-p3 are out-of-gamut for sRGB
     Clamping behavior is browser-defined — may clamp to black
     If background is black: text invisible
     getComputedStyle: shows negative p3 values — not matched by #000 check */
}

/* Detection strategy: */
function detectOutOfGamutColor(el) {
  const colorStr = getComputedStyle(el).color;
  // Match color() function with any named color space
  const colorFnMatch = colorStr.match(/color\((\S+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)/i);
  if (!colorFnMatch) return null;
  const [, space, c1, c2, c3] = colorFnMatch;
  const vals = [parseFloat(c1), parseFloat(c2), parseFloat(c3)];
  const customSpace = space.startsWith('--');
  const outOfGamut = vals.some(v => v < 0 || v > 1.0);
  const allHighValue = vals.every(v => v > 0.85);  // near-white in most spaces
  const allLowValue = vals.every(v => v < 0.15);   // near-black in most spaces
  if (customSpace || outOfGamut || allHighValue || allLowValue) {
    return {
      severity: customSpace ? 'Critical' : 'High',
      finding: 'SA-CSS-COLP-002',
      color: colorStr,
      colorSpace: space,
      channelValues: vals,
      flags: { customSpace, outOfGamut, allHighValue, allLowValue },
      reason: `color: ${colorStr} in ${space} color space. ${customSpace ? 'Custom ICC profile space — rendered color is ICC-profile-defined.' : ''} ${outOfGamut ? 'Out-of-gamut coordinates — clamping behavior is browser-defined.' : ''} ${allHighValue ? 'Near-maximum channel values — may resolve near white.' : ''} ${allLowValue ? 'Near-zero channel values — may resolve near black.' : ''}`,
    };
  }
  return null;
}

Attack 3 (SA-CSS-COLP-003): @color-profile applied to background — consent text/background color convergence

The attack can target the background color rather than the text color. If the element's background is specified in a custom color space that resolves to match the text color, the result is the same: invisible consent text. This compound is harder to detect because the text color appears normal (e.g., color: #222) while the background color is the ICC-mapped value:

/* Attack: custom profile background resolves to match text color */
@color-profile --dialog-bg {
  src: url('https://mcp-cdn.example.com/dialog-bg.icc');
  /* ICC profile maps (0.5, 0.5, 0.5) in --dialog-bg space to sRGB #222222
     — the same dark value as the consent text color */
}

.consent-dialog {
  color: #222;                                    /* normal text color — passes all checks */
  background-color: color(--dialog-bg 0.5 0.5 0.5);  /* resolves to #222 via ICC profile */
  /* Text: dark gray, background: also dark gray (via ICC remap)
     Text/background contrast: 1:1 — completely invisible
     getComputedStyle(el).color: "rgb(34, 34, 34)"  — looks like real text color
     getComputedStyle(el).backgroundColor: "color(--dialog-bg 0.5 0.5 0.5)"
       → standard contrast checks compare color against backgroundColor
       → they compare CSS strings: "#222" != "color(--dialog-bg 0.5 0.5 0.5)"
       → the contrast check never resolves both to sRGB to compare
       → PASSES all standard checks */
}

/* Detection: flag any background-color or color using custom color() profiles */
function detectColorProfileBackground(el) {
  const cs = getComputedStyle(el);
  const bg = cs.backgroundColor;
  const col = cs.color;
  const customProfileInBg = /color\(\s*--/i.test(bg);
  const customProfileInColor = /color\(\s*--/i.test(col);
  const wideGamutBg = /color\(\s*(display-p3|prophoto-rgb|a98-rgb|rec2020)/i.test(bg);
  if (customProfileInBg || customProfileInColor || wideGamutBg) {
    return {
      severity: 'Critical',
      finding: 'SA-CSS-COLP-003',
      color: col,
      backgroundColor: bg,
      reason: `${customProfileInBg ? 'Background' : 'Text'} color uses custom @color-profile space: "${customProfileInBg ? bg : col}". ICC profile rendering intent determines actual sRGB output — contrast cannot be evaluated without executing the ICC transform. Flag for manual inspection.`,
    };
  }
  return null;
}

Attack 4 (SA-CSS-COLP-004): JS mousedown injects @color-profile stylesheet at install time

The @color-profile at-rule can be injected at runtime by inserting a new <style> element. An MCP server can wait until mousedown on the install button, then inject a stylesheet containing both the @color-profile definition and the color: color(--evil ...) rule on the consent element — making the consent text invisible at the exact moment of commit while it was visible at audit time:

/* MCP JS: inject @color-profile + color rule at mousedown */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
  const style = document.createElement('style');
  style.textContent = `
    @color-profile --consent-overlay {
      src: url('https://mcp-cdn.example.com/white-remap.icc');
    }
    .consent-disclosure {
      color: color(--consent-overlay 0 0 0) !important;
    }
  `;
  document.head.appendChild(style);
  // At mousedown: consent text turns invisible via ICC-remapped white
  // At click: user confirms install with invisible consent
}, { capture: true });

/* Detection: MutationObserver on document.head for style element injection */
function detectRuntimeColorProfileInjection(root = document) {
  const injections = [];
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.nodeName === 'STYLE' || node.nodeName === 'LINK') {
          const text = node.textContent || '';
          if (text.includes('@color-profile') || text.includes('color(--')) {
            injections.push({
              severity: 'Critical',
              finding: 'SA-CSS-COLP-004',
              injectedContent: text.slice(0, 200),
              reason: '@color-profile rule injected via runtime style element. Dynamic ICC color space injection may remap consent text to background color at interaction time.',
            });
          }
        }
      }
    }
  });
  observer.observe(document.head, { childList: true });
  return { observer, injections };
}

Safe baseline: Legitimate consent dialogs have no reason to use @color-profile or color(--custom-space ...) on consent text or background. Any such usage on a consent element is a Critical finding. Flag all color() function uses with custom space names (starting with --) unconditionally. Wide-gamut predefined spaces (display-p3, prophoto-rgb) are High severity when channel values are out-of-gamut or in near-white/near-black ranges.

Attack summary

ID Attack Mechanism Detection point Severity
SA-CSS-COLP-001 ICC profile remap 0,0,0 → white @color-profile ICC maps neutral input to background color; color: color(--custom 0 0 0) renders invisible; getComputedStyle shows custom-space value, not resolved sRGB Regex match for color\(-- pattern in computed color/background Critical
SA-CSS-COLP-002 Out-of-gamut clamping to near-white Wide-gamut coordinates (e.g. prophoto-rgb 0.95 0.95 0.95) clamp to near-white in sRGB; browser-specific gamut mapping; appears as a mid-gray spec to scanners Check channel values >0.85 or out-of-gamut (<0 or >1) in color() functions High
SA-CSS-COLP-003 ICC-mapped background matches text color Text is normal dark color; background ICC-maps to same dark value; contrast 1:1; standard contrast comparison fails because it compares CSS strings not resolved sRGB Flag any background-color using custom ICC space — contrast check is unreliable Critical
SA-CSS-COLP-004 Runtime @color-profile injection at mousedown JS injects @color-profile + color(--evil...) rule at mousedown; consent visible at audit time, invisible at commit time; MutationObserver on head detects injection MutationObserver on document.head watching for @color-profile in injected style elements Critical

Finding blocks

Critical SA-CSS-COLP-001 ICC profile color remap: Consent element color or background-color uses a custom ICC profile space (color(--...)). The ICC profile's rendering behavior is attacker-defined — the resolved sRGB output is opaque to JavaScript. Any use of a custom @color-profile on a consent element is Critical. Flag the color\(-- pattern in computed styles.
High SA-CSS-COLP-002 out-of-gamut color space coordinates: Wide-gamut color() function with channel values outside [0, 1] or in near-white (>0.85) or near-black (<0.15) ranges on all channels. Gamut mapping behavior is browser-specific and may produce near-invisible rendered colors. Cannot be resolved to sRGB via JavaScript — flag for manual inspection.
Critical SA-CSS-COLP-003 ICC-mapped background convergence: Background color uses a custom ICC color profile while text color appears normal. Standard contrast checks compare CSS strings and cannot detect that the ICC-mapped background resolves to the same sRGB value as the text color. Flag any consent element background using a custom ICC space unconditionally.
Critical SA-CSS-COLP-004 runtime @color-profile injection: JS injects a <style> element containing an @color-profile at-rule and a color(--...) rule at mousedown — making consent invisible at install commit time. MutationObserver on document.head watching for injected style elements containing @color-profile or color(-- is the detection mechanism.

← Blog  |  color-scheme attacks  |  system-color attacks  |  color-opacity attacks  |  Security Checklist