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
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.
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.
<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