CSS Security

CSS forced-colors: The Privacy Leak Hidden in Windows High Contrast Mode

SkillAudit Research 2026-07-12 ~1,900 words

Windows High Contrast Mode (WHCM) is an accessibility feature used by roughly 1.5% of Windows users — people with low vision, photosensitivity, or cognitive contrast requirements. The CSS forced-colors media feature was designed to let developers honour HC mode gracefully: adopt system colors so that content looks correct in any HC theme. But for MCP servers with CSS injection capability, those same system color keywords become a precise side channel — one that reveals the user's specific HC theme, their Windows accent color as a stable device fingerprint, and lets injected content escape forced-color enforcement entirely. The people this feature was built to protect are the ones most at risk from these attacks.

What forced-colors does — and what it exposes

When a user enables Windows High Contrast Mode, the browser activates the forced-colors: active media query and replaces most author-specified colors with a constrained palette of system color keywords: ButtonText, ButtonFace, Highlight, HighlightText, GrayText, LinkText, VisitedText, Canvas, CanvasText, Field, FieldText, Mark, MarkText, AccentColor, and AccentColorText. Each keyword resolves to the actual color value from the user's active HC theme.

Windows ships four built-in HC themes — HC Black, HC White, HC #1 (Desert), HC #2 (Night sky) — and allows users to create fully custom themes. Each theme assigns distinct RGB values to each system color keyword. Those values are deterministic: every Windows machine running HC Black will resolve Highlight to the same RGB. Custom themes vary, but the built-in four are a reliable fingerprint surface.

The attack surfaces cluster around four findings: theme identity fingerprinting via system color keyword resolution, the AccentColor keyword as a stable cross-session device fingerprint, contrast reversal via forced-colors-adjust: none, and CSS custom property bypass of forced-color enforcement.


Attack 1 — System color keyword resolution: which HC theme is the user running?

An MCP server can determine exactly which High Contrast theme the user has active — or detect any of the four built-in themes — by reading the resolved values of system color keywords. The technique works even inside a CSS sandbox: it uses getComputedStyle() on an off-screen element that references a system color keyword via a CSS custom property.

/* MCP server: probe each system color keyword by assigning to a custom property */
/* Custom properties do NOT get forcibly colorized — they store whatever the
   author assigns, including system color keyword resolved values */

const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;';
document.body.appendChild(probe);

function resolveSystemColor(keyword) {
  probe.style.color = keyword;
  return getComputedStyle(probe).color; /* returns resolved rgb() value */
}

const themeProfile = {
  canvas:       resolveSystemColor('Canvas'),
  canvasText:   resolveSystemColor('CanvasText'),
  highlight:    resolveSystemColor('Highlight'),
  highlightText:resolveSystemColor('HighlightText'),
  buttonText:   resolveSystemColor('ButtonText'),
  grayText:     resolveSystemColor('GrayText'),
  linkText:     resolveSystemColor('LinkText'),
};

document.body.removeChild(probe);

/* Known resolved values for Windows built-in HC themes (Chromium):
   HC Black:  Canvas=rgb(0,0,0)   Highlight=rgb(26,198,204)  ButtonText=rgb(255,255,255)
   HC White:  Canvas=rgb(255,255,255) Highlight=rgb(0,0,128) ButtonText=rgb(0,0,0)
   HC #1:     Canvas=rgb(0,0,0)   Highlight=rgb(255,215,0)   ButtonText=rgb(255,255,255)
   HC #2:     Canvas=rgb(0,0,0)   Highlight=rgb(0,203,255)   ButtonText=rgb(255,255,255)
   Custom:    Values vary — but the profile is stable and unique per user machine

   Matching themeProfile against this lookup table identifies the exact theme.
   A custom theme that doesn't match any built-in profile is still a high-entropy
   fingerprint — the combination of 7+ resolved RGB values is unique per user. */

Protected-class disclosure. Knowing that a user is running High Contrast Mode is, by itself, a data point about disability status or medical condition — the primary populations who enable HC mode are those with low vision, photosensitivity (including migraine disorders), and certain cognitive processing conditions. Exposing this to a third-party MCP server without consent is a potential privacy violation under GDPR Article 9 (special category data) and similar frameworks. The theme fingerprint goes further, providing stable cross-session identity.


Attack 2 — AccentColor: the stable device fingerprint that persists in incognito

The AccentColor and AccentColorText system color keywords resolve to the user's Windows personalisation accent color — the same color chosen in Settings → Personalisation → Colors. This is distinct from HC mode: AccentColor resolves in any Windows browser in forced-colors mode, including the default theme on Windows 11 which now applies the accent color to active title bars, taskbar highlights, and interface elements.

What makes AccentColor particularly powerful as a fingerprint is its persistence properties:

/* MCP server: read AccentColor in forced-colors mode */
@media (forced-colors: active) {
  .mcp-probe {
    --accent-probe: AccentColor;  /* store in custom property to read via JS */
  }
}

/* JS side */
const probe = document.createElement('div');
probe.className = 'mcp-probe';
probe.style.cssText = 'position:absolute;left:-9999px;';
document.body.appendChild(probe);

/* Read the resolved AccentColor value from the custom property */
const accentRGB = getComputedStyle(probe).getPropertyValue('--accent-probe').trim();
/* accentRGB = "" outside forced-colors, "AccentColor" resolved in forced-colors
   In practice, use getComputedStyle(probe).color after setting color: AccentColor */
probe.style.color = 'AccentColor';
const resolvedAccent = getComputedStyle(probe).color;
/* resolvedAccent = "rgb(r, g, b)" — stable per device, per OS user profile */

document.body.removeChild(probe);

/* The resolved RGB is now a device-level identifier:
   - Combine with HC theme profile → ~40-bit fingerprint
   - Combine with font metrics, navigator.language, screen.colorDepth
     → effectively unique device identity achievable without canvas or WebGL */

Browser support note. AccentColor as a CSS color keyword is available in Chrome 93+, Firefox 92+, Safari 15.4+. It resolves in forced-colors mode on all platforms, but on macOS and Linux it maps to the OS accent color from the respective OS personalisation API — providing the same cross-session stability on non-Windows platforms as well.


Attack 3 — forced-colors-adjust: none: MCP content keeps its colors while host security UI is forced to system colors

The forced-colors-adjust property allows an author to opt specific elements out of the forced-colors algorithm. Elements with forced-colors-adjust: none retain their author-specified colors even when the browser's forced-colors mode would otherwise replace them with system color keywords. This is legitimate for elements like charts, data visualisations, or color-coded categories where losing specific colors would destroy meaning.

For an MCP server, it is an escape hatch. By setting forced-colors-adjust: none on MCP-controlled elements while the host's security UI is subjected to forced-colors enforcement, the MCP server creates a contrast reversal: host content may become difficult to read (forced to system colors that may not match the host's intended design) while MCP content retains full-color rendering. More seriously, MCP content can render arbitrary colors in a context where the user expects all colors to be from the system palette — the color diversity itself is a signal that breaks the user's mental model of what to trust in HC mode.

/* MCP server injects this to escape forced-colors normalization */
@media (forced-colors: active) {
  .mcp-widget,
  .mcp-banner,
  [data-mcp-ui="true"] {
    forced-colors-adjust: none !important;
    /* All author-specified colors in these elements are now preserved.
       Background, text, border, box-shadow — everything the MCP server
       specified in its own stylesheet is displayed as-is.

       Meanwhile, host elements that did NOT set forced-colors-adjust:none
       have their colors replaced by the browser with system color keywords.
       The visual result in HC Black:
         - Host nav: white text on black background (forced to Canvas/CanvasText)
         - MCP widget: any colors the MCP server chose — preserved in full

       Users in HC mode have configured their system to use high-contrast
       colors specifically because they cannot reliably distinguish low-contrast
       content. MCP content that escapes this restriction may be:
         1. Lower contrast than the HC theme the user chose
         2. Visually prominent in a way that draws attention to the MCP element
            at the expense of the host security UI around it
         3. Using colors that clash with the HC palette, triggering visual
            discomfort for photosensitive users */
  }

  /* Further: use forced-colors-adjust:none to achieve contrast reversal on
     host security elements by covering them with a same-z-index MCP element
     that has forced-colors-adjust:none and a deliberately low-contrast palette.
     Host's error messages, CSRF tokens, session warnings become hard to read
     when surrounded by MCP content that bypasses HC normalization. */
}

Impact on accessibility users. This attack disproportionately harms the population High Contrast Mode was designed to protect. A user who relies on HC mode for visibility may not be able to read MCP-injected content if it uses low-contrast colors that are only preserved because of forced-colors-adjust: none. The attack also undermines the user's trust model: in HC mode, all content should conform to the selected palette. Injected content that does not conform is both harder to read and structurally anomalous.


Attack 4 — CSS custom property persistence: colors that survive forced-color enforcement

The CSS forced-colors algorithm normalises resolved color values — it does not affect CSS custom property storage. A custom property that holds an RGB value string (--brand: rgb(99, 102, 241)) is stored verbatim. The forced-colors algorithm only applies when the custom property's value is used in a color context — and even then, only on the computed color that the browser would paint.

This creates a covert channel: an MCP server can store arbitrary color state in custom properties that persists and is readable even when the user is in forced-colors mode. The values survive across page navigations if stored in a CSS rule on :root and the stylesheet is not removed. They also survive any content-script isolation boundary that only sanitises paint outputs rather than CSS custom property storage.

/* MCP server: store data in custom properties that survive forced-colors */

/* Set custom properties on :root — they persist for the lifetime of the stylesheet */
const stateSheet = new CSSStyleSheet();
stateSheet.replaceSync(`:root {
  --mcp-user-tier: 3;          /* arbitrary integer stored as unitless number */
  --mcp-session-id: 0xA4F2;    /* stored as a hex-like string */
  --mcp-last-action: 1720780800; /* unix timestamp as integer */
}`);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stateSheet];

/* These custom property values are NOT affected by forced-colors normalization:
   - The browser only normalises colors at paint time, when a custom property
     is used in a 'color', 'background-color', or similar CSS color property.
   - The stored INTEGER value of --mcp-user-tier:3 is never subject to
     forced-colors processing — it's not a color.
   - Even a custom property holding a color string like rgb(255,0,0) is stored
     as-is; forced-colors normalises the resolved paint output, not the storage.

   Later retrieval — even from a different MCP module in the same origin:
*/
const tier = parseInt(
  getComputedStyle(document.documentElement).getPropertyValue('--mcp-user-tier')
);
/* tier = 3 — persists across forced-colors mode changes without any storage API */

/* Why this matters:
   1. State exfiltration: MCP server A encodes application state into custom properties.
      MCP server B (same origin, different code path) reads those properties.
      No postMessage, no shared DOM node, no localStorage — state passed via CSS.
   2. Fingerprint anchoring: the same custom property can hold a resolved system color
      (read via the probe technique in Attack 1) and make it available to any later
      getComputedStyle() call on :root, without requiring the original probe element.
   3. Session rehydration: custom properties set by an MCP server on :root survive
      SPA client-side route changes that swap inner DOM content but preserve the
      document-level stylesheet. The MCP server's state persists across "page loads"
      that don't trigger a full navigation. */

What the forced-colors algorithm protects — and what it doesn't

Element / technique Affected by forced-colors? MCP bypass method
Author-specified color and background-color Yes — replaced with system color keywords forced-colors-adjust: none on MCP elements
Author-specified border-color, outline-color Yes — replaced forced-colors-adjust: none on MCP elements
CSS custom property storage No — stored value unaffected Store color data directly in custom properties
getComputedStyle().color on a system-color element Returns resolved system RGB Read to fingerprint the HC theme
AccentColor keyword resolution Resolves to OS accent color RGB Read as stable cross-session device fingerprint
SVG fill / stroke colors Partially (depends on presentation vs. stylesheet) forced-colors-adjust: none on SVG container

Defences

For host application developers and MCP server security reviewers, the following mitigations address these four attack surfaces:


SkillAudit findings for this attack surface

HIGH AccentColor cross-session device fingerprint: MCP server resolves AccentColor system keyword via getComputedStyle probe — returns the user's Windows accent color as a stable RGB value that persists across sessions, incognito windows, and profile switches, providing a device-level identifier without any storage API
HIGH HC theme identity fingerprint with protected-class disclosure: MCP server probes ButtonText, Highlight, Canvas, and GrayText to identify which of the four built-in HC themes is active, or construct a custom-theme fingerprint — leaking that the user requires accessibility accommodations (low vision, photosensitivity) as a special-category data point
MEDIUM forced-colors-adjust:none contrast reversal: MCP server sets forced-colors-adjust:none on its own elements, escaping HC color normalization while host security UI conforms to system colors — MCP content retains arbitrary colors the user may not be able to distinguish, and the visual anomaly breaks the HC trust model
LOW CSS custom property state persistence bypassing forced-colors: MCP server stores application state (user tier, session ID, resolved color fingerprint) in custom properties on :root — values survive forced-colors mode changes and SPA route changes without using any storage API, providing a cross-component covert channel

The bigger picture: accessibility features as attack surfaces

The forced-colors attack surfaces illustrate a recurring pattern in MCP server security: features designed to improve the experience for a protected or vulnerable population are repurposed as attack vectors specifically targeting that population. High Contrast Mode users are more likely to be using assistive technology, more likely to be in a heightened-trust mental model ("my accessibility settings mean the browser is protecting me"), and less likely to have encountered security research about CSS injection in their accessibility context.

This is not unique to forced-colors. The same pattern appears in CSS media features security (prefers-reduced-motion, prefers-contrast) and CSS color-scheme security (prefers-color-scheme dark mode detection). The common thread: any CSS mechanism that exposes OS-level user preferences creates a side channel from those preferences to injected third-party code. MCP servers with CSS injection access all of these channels.

SkillAudit flags all four forced-colors attack surfaces — system color keyword probing, AccentColor fingerprinting, forced-colors-adjust bypass, and custom property persistence — in its CSS injection security axis. A MCP server that triggers any of these findings receives a failing grade on the CSS injection sub-dimension, regardless of its scores on other axes.

Related reading: CSS forced-colors attack surface reference — detailed technical breakdown of all four vectors with browser support tables. CSS color-scheme security — the prefers-color-scheme equivalent. CSS media features security — broader OS preference fingerprinting via media queries.

← Blog