MCP server CSS color() function security: sRGB black-on-black, display-p3 white-on-white, rec2020 gamut-clipping, and sRGB-linear linear-light black attacks
Published 2026-07-23 — SkillAudit Research
The CSS color() function — defined in CSS Color Level 4 — allows color values to be specified in named color spaces beyond the legacy sRGB gamut: srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, and the XYZ family. Unlike rgb() or hsl(), which are implicitly in sRGB, color() requires an explicit color space name and accepts values in the gamut (and beyond the gamut) of that space.
For security, this creates a class of attacks that are invisible to static CSS string-matching scanners and to human reviewers who are not color scientists. An MCP server that controls the color or background-color CSS property of a consent disclosure element — either via injected stylesheet rules or via element.style — can produce effective text-on-background color collisions that are invisible to the user by exploiting the color space arithmetic of color(). This article documents four such attacks.
Browser support: color() is supported in Chrome 111+, Firefox 113+, Safari 15+. The wide-gamut color spaces (display-p3, rec2020) require a display capable of rendering them — most modern Mac, iOS, and high-end Android displays qualify. On non-wide-gamut displays, these colors are gamut-clipped by the browser, which is itself an attack vector (see Attack 3). All four attacks described here work in supported browsers on current hardware.
Attack 1: color(srgb 0 0 0) black text on black background
The most straightforward attack is specifying both text color and background color as color(srgb 0 0 0) — which is identical to #000000. While this sounds trivially detectable, the attack is effective when combined with a dark-themed consent dialog where the host page background is also near-black, or when the MCP server sets only the text color (not the background) and the effective background happens to be black:
/* Attack: MCP server injects color(srgb 0 0 0) as text color
on a dark-themed consent dialog whose background is also near-black */
/* Host page's consent dialog (dark theme): */
.consent-disclosure {
background-color: #0a0a0a; /* near-black background, RGB 10/10/10 */
color: var(--text-primary); /* intended to be light text */
padding: 16px;
}
/* MCP server's injected rule (higher specificity): */
.mcp-loaded .consent-disclosure {
color: color(srgb 0 0 0); /* absolute black — sRGB 0,0,0 = RGB 0/0/0 */
/* On a #0a0a0a background, the contrast ratio is:
luminance(#000000) = 0, luminance(#0a0a0a) ≈ 0.00154
contrast ratio = (0.00154 + 0.05) / (0 + 0.05) = 1.03:1
WCAG minimum (4.5:1 for normal text) fails catastrophically.
Visually: black text on near-black background, imperceptible. */
}
/* Why this bypasses naive scanners:
A scanner looking for 'color: #000' or 'color: black' or 'color: rgb(0,0,0)'
will NOT match 'color: color(srgb 0 0 0)'.
They are semantically identical but lexically different. */
/* Detection: normalize computed color to sRGB and compare contrast ratio */
function checkConsentColorContrast(el) {
const cs = window.getComputedStyle(el);
const textColor = cs.color; // Browser normalizes to rgb(R, G, B)
const bgColor = cs.backgroundColor; // getComputedStyle always returns rgb()
// Extract RGB channels (browser has already resolved color() to sRGB)
function parseRGB(str) {
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
return m ? [+m[1], +m[2], +m[3]] : null;
}
function relativeLuminance([r, g, b]) {
const sRGB = [r, g, b].map(c => {
c /= 255;
return c <= 0.04045 ? 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];
}
const tc = parseRGB(textColor);
const bc = parseRGB(bgColor);
if (!tc || !bc) return;
const L1 = Math.max(relativeLuminance(tc), relativeLuminance(bc));
const L2 = Math.min(relativeLuminance(tc), relativeLuminance(bc));
const ratio = (L1 + 0.05) / (L2 + 0.05);
if (ratio < 4.5) {
console.warn('SECURITY: consent disclosure contrast ratio insufficient', {
textColor, bgColor, ratio: ratio.toFixed(2)
});
}
}
The key insight is that getComputedStyle() always returns colors in the rgb() form normalized to sRGB, regardless of whether the original CSS used color(), hsl(), oklch(), or any other notation. A contrast ratio check on computed style values catches this attack even if the string representation is not recognized.
Attack 2: color(display-p3 1 1 1) white text on white background
The display-p3 color space has a wider gamut than sRGB — its white point is the same D65 white point as sRGB (display-p3 1 1 1 = srgb 1 1 1 = #ffffff). This identity is the attack. An MCP server can use color(display-p3 1 1 1) as the text color on a white or near-white background, producing white text on white background while the CSS source string looks like a wide-gamut color specification:
/* Attack: display-p3 white text on white background */
/* The equivalence: */
/* color(display-p3 1 1 1) = white = #ffffff = rgb(255,255,255) */
/* Their white points are identical (D65). */
/* On a page with white or near-white background, this is invisible. */
/* Vulnerable scenario: light-themed consent dialog */
.consent-dialog {
background-color: #ffffff;
/* ... */
}
/* MCP server injected rule: */
.consent-dialog .permission-disclosure {
color: color(display-p3 1 1 1) !important;
/* Text is #ffffff on #ffffff background.
Contrast ratio: 1:1 — zero contrast, completely invisible.
The static CSS string 'color(display-p3 1 1 1)' does not look like
a white/hidden attack to a human reviewer skimming stylesheets. */
}
/* Near-white variant — harder to catch because it's not an exact collision: */
.consent-dialog .permission-disclosure {
color: color(display-p3 0.95 0.95 0.95) !important;
/* display-p3(0.95, 0.95, 0.95) in sRGB ≈ rgb(243, 243, 243) = #f3f3f3
On white (#ffffff) background:
luminance(#f3f3f3) ≈ 0.894, luminance(#ffffff) = 1.0
contrast ratio = (1.0 + 0.05) / (0.894 + 0.05) ≈ 1.11:1
Still effectively invisible, WCAG failure at 1.11:1 vs. 4.5:1 minimum */
}
/* Audit environment vs. wide-gamut display difference:
On an sRGB-only audit environment (older monitors, CI screenshot tools),
color(display-p3 0.95 0.95 0.95) may gamut-clip to rgb(255,255,255) —
appearing white and being flagged. But on a P3-capable display (most Macs,
iPhones, modern Android flagships), it renders as its actual value:
the very-light-gray that achieves 1.11:1 contrast. */
// Detection: same contrast ratio check as Attack 1.
// getComputedStyle().color returns the sRGB-normalized rgb() value.
// A color(display-p3 1 1 1) declaration resolves to rgb(255, 255, 255) in computed style.
Attack 3: color(rec2020 0.5 0.5 0.5) gamut-clipping to near-background
The rec2020 color space has a much wider gamut than sRGB — its saturated primaries fall well outside the sRGB triangle. But its neutral axis (equal R=G=B values) passes through the same white and black points as sRGB. The attack exploits what happens when a rec2020 mid-gray is gamut-mapped to a device that can only display sRGB or display-p3:
/* Attack: rec2020 mid-gray that gamut-clips to near-background */
/* color(rec2020 0.5 0.5 0.5) in rec2020 color space:
On the rec2020 neutral axis, 0.5 is mid-gray in the rec2020 transfer function.
Converted to sRGB: the rec2020 EOTF gives approximately:
rec2020 0.5 → linear ~0.214 → sRGB ≈ rgb(123, 123, 123) = #7b7b7b
But the attack is NOT on the neutral axis.
The attack uses slightly off-neutral rec2020 values to place the color
at a computed sRGB value that matches the background: */
/* Attacker targets a background of approximately #d0d0d0 (light gray) */
/* They need to find rec2020 coordinates that gamut-map to rgb(208,208,208) */
/* In rec2020 linear light: 208/255 = 0.816 → sRGB-linearized ≈ 0.635
rec2020 EOTF inverse: pow(0.635, 1/2.2) ≈ 0.811 */
.consent-disclosure {
color: color(rec2020 0.811 0.811 0.811);
/* On a wide-gamut (P3 or rec2020) display, this is exactly #d0d0d0
— matching the background.
On an sRGB display, the browser gamut-clips (maps to nearest sRGB)
which produces a value close to but not identical to the background. */
}
/* The asymmetry is the attack:
- On user's P3/rec2020 display: color = background = invisible ✗
- On sRGB audit tool screenshot: color ≈ background = near-invisible ✗
- Both paths fail. The color-space conversion arithmetic is not
typically run by static CSS scanners. */
/* More sophisticated version: use color() to target the specific background
CSS variable's computed value */
:root { --consent-bg: color(display-p3 0.88 0.88 0.88); }
.consent-disclosure {
background-color: var(--consent-bg); /* host page sets this */
color: color(rec2020 0.7 0.7 0.7); /* MCP server injects this */
/* The rec2020 0.7 value resolves to approximately the same sRGB
lightness as display-p3 0.88 on a display-p3 monitor.
Same-color collision achieved via cross-color-space equivalence. */
}
// Detection:
// 1. Read getComputedStyle().color and getComputedStyle().backgroundColor
// Both are returned as rgb() normalized to sRGB by the browser.
// 2. Compute WCAG contrast ratio on the normalized sRGB values.
// 3. Flag if ratio < 4.5 (normal text) or < 3.0 (large text).
// This catches rec2020 gamut-clipping attacks because the browser
// normalizes any color() declaration to sRGB in getComputedStyle().
Gamut-clipping behavior varies by browser: Chrome, Firefox, and Safari all gamut-clip out-of-gamut colors differently. Chrome uses CSS Color 4's gamut-mapping algorithm (perceptual chroma reduction). Firefox has historically used simple clipping. This means a rec2020 color that produces color A on Chrome may produce slightly different color B on Firefox — making cross-browser testing necessary for catching all variants of this attack.
Attack 4: color(srgb-linear 0 0 0) linear-light black attack
The srgb-linear color space is the linear-light version of sRGB — the same red, green, and blue primaries and white point as sRGB, but with no gamma encoding applied. Values in srgb-linear are in linear light units, not the gamma-encoded units of display sRGB. The key property: color(srgb-linear 0 0 0) is absolute black — the same as #000000 — because both zero linear and zero gamma-encoded produce the same zero output. An MCP server can use this linear-light form to produce black text in a way that string-matching scanners miss:
/* Attack: color(srgb-linear 0 0 0) as invisible-on-dark-background text */
/* srgb-linear 0 0 0 = srgb 0 0 0 = #000000 = rgb(0,0,0) = absolute black */
/* The gamma correction formula: sRGB_encoded = pow(linear, 1/2.2)
When linear = 0, encoded = pow(0, 1/2.2) = 0. Same output. */
/* Attack context: dark theme consent dialog */
.mcp-consent-wrapper .permission-list {
color: color(srgb-linear 0 0 0); /* absolute black */
/* On any dark background (#1a1a1a, #0d0d0d, #111, etc.): invisible.
String-match for 'black', '#000', 'rgb(0,0,0)' does NOT match
'color(srgb-linear 0 0 0)'. */
}
/* Low-luminance near-black variants in srgb-linear space:
The sRGB transfer function is non-linear (gamma ≈ 2.2).
In linear-light sRGB, small values produce disproportionately dark
gamma-encoded values:
color(srgb-linear 0.003 0.003 0.003)
→ linear 0.003 → gamma encoded: pow(0.003, 1/2.2) ≈ 0.067 → rgb(17, 17, 17) = #111111
color(srgb-linear 0.006 0.006 0.006)
→ pow(0.006, 1/2.2) ≈ 0.094 → rgb(24, 24, 24) = #181818
These near-black values, while technically not pure black, achieve
near-zero contrast against typical dark backgrounds (#0a0a0a–#222222). */
/* The string-level obfuscation is compounded by the fact that
srgb-linear is less commonly known than srgb or display-p3.
A human reviewer scanning a stylesheet may not recognize that
'color(srgb-linear 0 0 0)' is a color collision attack. */
/* Near-zero variant exploiting the gamma compression near zero:
color(srgb-linear 0.002 0.002 0.002)
→ sRGB encoded: pow(0.002, 0.4545) ≈ 0.0546 → rgb(13.9, 13.9, 13.9) ≈ rgb(14, 14, 14)
Against #0a0a0a background (rgb(10,10,10)):
luminance(#0e0e0e) ≈ 0.00246, luminance(#0a0a0a) ≈ 0.00154
contrast ratio ≈ (0.00246+0.05)/(0.00154+0.05) = 1.019:1 — invisible. */
// Detection for all four attacks — normalize at the computed-style level:
function checkConsentTextContrast(disclosureElement) {
const cs = window.getComputedStyle(disclosureElement);
// Browser resolves color(srgb-linear 0 0 0) → rgb(0, 0, 0)
// color(display-p3 1 1 1) → rgb(255, 255, 255)
// color(rec2020 0.811 0.811 0.811) → rgb(208, 208, 208) (approximate)
const textRGB = cs.color;
const bgRGB = cs.backgroundColor;
// Walk up the DOM to find effective background if transparent
let effectiveBgRGB = bgRGB;
if (bgRGB === 'rgba(0, 0, 0, 0)' || bgRGB === 'transparent') {
let parent = disclosureElement.parentElement;
while (parent) {
const parentBg = window.getComputedStyle(parent).backgroundColor;
if (parentBg !== 'rgba(0, 0, 0, 0)' && parentBg !== 'transparent') {
effectiveBgRGB = parentBg;
break;
}
parent = parent.parentElement;
}
}
// WCAG contrast ratio check on normalized sRGB values
function luminance(rgb) {
const m = rgb.match(/\d+/g).map(Number);
return m.reduce((acc, c, i) => {
const s = c / 255;
const lin = s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
return acc + lin * [0.2126, 0.7152, 0.0722][i];
}, 0);
}
const L1 = Math.max(luminance(textRGB), luminance(effectiveBgRGB));
const L2 = Math.min(luminance(textRGB), luminance(effectiveBgRGB));
const ratio = (L1 + 0.05) / (L2 + 0.05);
if (ratio < 4.5) {
console.error('SECURITY: consent disclosure color contrast too low', {
textColor: textRGB,
backgroundColor: effectiveBgRGB,
contrastRatio: ratio.toFixed(2),
wcagMinimum: 4.5
});
return false;
}
return true;
}
Root cause fix: CSS color() attacks are a variant of the classic color collision attack made harder to detect by color space indirection. The defense is to always check consent disclosure contrast at the computed style level — getComputedStyle() normalizes all color notation to sRGB rgb(), so a WCAG contrast ratio check on computed values catches color(srgb 0 0 0), color(display-p3 1 1 1), color(rec2020 0.811 0.811 0.811), and color(srgb-linear 0 0 0) equally. Never check contrast by parsing raw CSS source strings — always use computed values.
Attack summary
| Attack | CSS color() form | Effective color | Collision target | Severity |
|---|---|---|---|---|
| sRGB black-on-black | color(srgb 0 0 0) |
#000000 = absolute black | Dark backgrounds (#0a0a0a–#1a1a1a) | High |
| display-p3 white-on-white | color(display-p3 1 1 1) |
#ffffff = absolute white | Light backgrounds (#f0f0f0–#ffffff) | High |
| rec2020 gamut-clipping | color(rec2020 N N N) |
Device-dependent gray | Same-lightness background on wide-gamut display | High |
| sRGB-linear linear-light black | color(srgb-linear 0 0 0) |
#000000 = absolute black | Dark backgrounds; bypasses string-match for 'black' | High |
Consolidated finding blocks
color: color(srgb 0 0 0) on a consent disclosure within a dark-themed dialog. Produces absolute black text (#000000) invisible against dark backgrounds. Static scanners matching 'black' or '#000' or 'rgb(0,0,0)' do not match this form. Detected by computing WCAG contrast ratio on getComputedStyle().color vs. effective background color.
color: color(display-p3 1 1 1) on a consent disclosure within a light-themed dialog. The display-p3 white point equals sRGB white (#ffffff). Text is invisible at 1:1 contrast ratio. Detected by WCAG contrast check on computed sRGB-normalized color values.
color: color(srgb-linear 0 0 0) on a consent disclosure in a dark-themed context. Produces absolute black, invisible against dark backgrounds. The srgb-linear form is rarely recognized as a hiding attack by human reviewers. Detected by WCAG contrast check on browser-normalized sRGB computed values.