Security Research
The Accessibility Media Query Audit Gap — CSS Consent Attacks That Only Fire in Accessibility Environments
CSS media features designed for accessibility — forced-colors, inverted-colors, scripting — activate only when the user has a specific accessibility feature enabled. Security auditors don't replicate these environments. Consent-hiding CSS placed inside these media blocks is invisible to every scanner that doesn't have the accessibility feature active, and visible specifically to the users who depend on it. This creates a class of attacks that target the most vulnerable users while remaining structurally invisible to every automated review tool.
The population gap problem
Most CSS security analysis works by fetching a stylesheet and reading the property values that apply to consent elements. The implicit assumption is that the CSS environment of the auditor's tool approximates the CSS environment of the end user. For most CSS properties, this assumption holds. For accessibility media features, it fails completely.
An accessibility media feature is a CSS conditional that fires only when the user's operating system or browser has a specific accessibility accommodation active. @media (forced-colors: active) fires only if the user has Windows High Contrast Mode (or an equivalent forced-colors environment) enabled. @media (inverted-colors: inverted) fires only if the user has OS-level color inversion active (macOS Smart Invert, iOS Color Filters, or equivalent). @media (scripting: enabled) fires only if JavaScript is available in the current context — which is the overwhelming majority of real user browsers but not necessarily all audit tool environments.
The attack premise is simple: put the consent-hiding CSS inside one of these media blocks. The auditor's tool never activates the media feature. It reads the fallback styles outside the block, which show consent normally, and reports clean. The real user, who has the accessibility feature enabled, gets the attack styles. Because the media feature is the trigger, the attack is self-selecting: it targets specifically and exclusively the users whose accessibility needs created the trigger condition.
The double perversity: The attack is invisible to the auditor AND visible specifically to the accessibility-dependent user the feature was designed to help. A forced-colors: active attack targets Windows High Contrast users. An inverted-colors: inverted attack targets users with photosensitivity who enabled OS inversion. A scripting: enabled attack targets virtually every real browser user. In each case, the scanner — which lacks the accessibility feature — sees nothing.
The three attack surfaces
@media (forced-colors: active)
Windows High Contrast Mode and CSS forced-colors enforcement. When active, the browser normalizes all foreground and background colors to system color keywords. forced-color-adjust: none is the escape hatch — it opts an element out of normalization, letting the MCP-injected CSS keep arbitrary colors. Place forced-color-adjust: none; color: Canvas; on a consent element inside a forced-colors: active block: Canvas is the system background color. Text color equals background color. The element is invisible specifically in High Contrast environments — the exact users who use High Contrast because other display conditions make text hard to read.
@media (inverted-colors: inverted)
OS-level color inversion for photosensitivity, vestibular disorders, and migraines. When the user inverts colors, every pixel on screen gets its color inverted — except elements with filter: invert(1) corrections applied by developers. The attack selects a consent element text color that, after OS inversion, equals the inverted background color: invisible only in the inverted state. Alternatively, the attack sets color: rgb(255, 255, 255) inside an inverted-colors: inverted block on an element with background: white — after inversion, both become black-on-black. The WCAG contrast checker, running without inversion, computes a perfectly valid 21:1 contrast ratio.
@media (scripting: enabled)
Not an accessibility feature in the traditional sense, but follows the same audit-gap pattern: the condition activates in real user browsers (JavaScript enabled) but not in static CSS parsing tools and some headless audit environments configured with script-src 'none'. Consent hidden inside @media (scripting: enabled) is invisible to static scanners that fetch CSS without a full browser rendering context. Unlike the accessibility features above, this targets essentially the entire real user population. See our detailed writeup in CSS @media scripting consent security.
Why audit tools miss accessibility media queries
The core problem is that activation of an accessibility media feature requires the auditor's tool to simulate that accessibility environment. For forced-colors, this means running the browser with High Contrast Mode enabled at the OS level — typically a Windows system setting, not a browser flag. For inverted-colors, it requires activating OS color inversion — macOS Smart Invert or iOS Color Filters. Neither of these is something a headless Chromium instance can trivially replicate without OS-level changes.
The scripting case is different: scripting availability is a browser-context property. But static CSS scanners — tools that fetch the stylesheet as a text file and parse it without a browser — have no scripting context at all. They parse the @media (scripting: enabled) block as a syntax structure but cannot evaluate whether the condition is true.
/* How the CSS looks to a static scanner */
/* Clean fallback — visible to scanner */
.consent-dialog {
color: #1a1a1a;
background: #ffffff;
}
/* Attack block — scanner parses structure but cannot evaluate condition */
@media (forced-colors: active) {
.consent-dialog .terms-section {
forced-color-adjust: none;
color: Canvas; /* = system background color in HC mode */
background: Canvas; /* = system background color in HC mode */
/* Text = background = invisible in HC mode */
/* Scanner sees color:Canvas — may not recognize as attack */
}
}
/* Scanner result: "consent visible, color #1a1a1a passes contrast"
HC Mode user result: consent section invisible in forced-colors context */
Attack details: forced-colors normalization bypass
The standard behavior in forced-colors: active mode is that the browser overrides all foreground and background colors with system color keywords. This means an MCP server cannot simply set color: transparent inside a forced-colors: active block — the browser will normalize that to a system text color. The attack requires using forced-color-adjust: none to opt the element out of normalization, then setting adversarial colors that the browser no longer corrects.
/* forced-colors normalization bypass — consent invisible in High Contrast Mode */
@media (forced-colors: active) {
.consent-dialog .disclosure-text {
/* opt out of HC color normalization */
forced-color-adjust: none;
/* Now set colors the browser won't correct */
color: Canvas; /* system background color */
background-color: Canvas; /* system background color */
/* Result: text = background = invisible */
/* Outside this block: HC normalization makes text ButtonText
(always readable against Canvas background) */
}
}
/* WCAG scanner result: runs without HC mode active
Sees: color:#1a1a1a (fallback) → contrast 14:1 → PASS
HC Mode user sees: color:Canvas on Canvas → invisible → FAIL */
The variant that targets only specific high-value consent terms:
/* Selective clause attack — legal-critical terms only */
@media (forced-colors: active) {
/* Only the terms section is attacked, not the heading */
.consent-dialog .terms-section strong,
.consent-dialog .terms-section .legal-clause,
.consent-dialog .terms-section [data-consent="mandatory"] {
forced-color-adjust: none;
color: Canvas;
background-color: Canvas;
}
/* Heading, buttons, and body prose remain visible — dialog looks complete */
/* Critical legal terms invisible only for HC Mode users */
}
For a full enumeration of four attack patterns with detection code specific to forced-colors, see our reference page: CSS @media (forced-colors: active) consent attacks.
Attack details: inverted-colors double-inversion math
Color inversion at the OS level is a mathematical transform: every rendered pixel color rgb(R, G, B) becomes rgb(255-R, 255-G, 255-B). The attack exploits this transform by selecting consent text and background colors such that after inversion, they become equal. The key insight is that the color relationship that matters for visibility is the post-inversion state, but WCAG contrast checkers and CSS auditors measure the pre-inversion state.
/* inverted-colors double-inversion attack */
/* Pre-inversion state (what WCAG and auditors see): */
/* consent-text: rgb(20, 20, 20) on background: rgb(255, 255, 255) */
/* Contrast ratio: (255-20) / (255-255) → approximately 18:1 — PASS */
/* Post-inversion state (what the affected user sees): */
/* rgb(20,20,20) inverts to rgb(235,235,235) */
/* rgb(255,255,255) inverts to rgb(0,0,0) */
/* Result: rgb(235,235,235) on rgb(0,0,0) — still readable — not the attack */
/* The attack uses the @media block to change colors inside the block: */
@media (inverted-colors: inverted) {
.consent-dialog .consent-body {
color: rgb(255, 255, 255); /* white */
background-color: rgb(255, 255, 255); /* white */
/* In the inverted-colors context: OS inverts these */
/* → color becomes rgb(0,0,0) = black */
/* → background becomes rgb(0,0,0) = black */
/* Black on black = invisible */
/* WCAG checker (no inversion): 1:1 ratio — but auditor never activates this block */
}
}
/* More subtle variant: pre-select colors that match after inversion */
@media (inverted-colors: inverted) {
.consent-dialog .consent-body {
color: rgb(40, 40, 40);
background-color: rgb(215, 215, 215);
/* After inversion: color becomes rgb(215,215,215) */
/* background becomes rgb(40,40,40) */
/* These are also close — low contrast after inversion */
/* Pre-inversion WCAG: rgb(40,40,40) on rgb(215,215,215) = 9.5:1 → PASS */
/* Post-inversion WCAG: rgb(215,215,215) on rgb(40,40,40) = 9.5:1 → PASS */
/* But a carefully chosen pair can make post-inversion contrast near 1:1 */
}
}
The most precise variant selects the background color as the known post-inversion value of the text color. If the page background after OS inversion is known to be a specific color (e.g., the base page background rgb(10, 10, 10) inverts to rgb(245, 245, 245)), then setting color: rgb(245, 245, 245) inside the inverted-colors: inverted block makes the text match the inverted background exactly — invisible.
Detection requires live browser evaluation with OS color inversion active. For full patterns and detection code, see CSS @media (inverted-colors) consent attacks.
Compound attacks: covering multiple accessibility populations simultaneously
The three media features can be combined in a single stylesheet to cover different accessibility populations with separate attacks, ensuring that no matter which accessibility feature a user has active, at least one attack fires. Since the media features are mutually exclusive for most users (a user is unlikely to have both forced-colors and inverted-colors active simultaneously), each block targets a distinct non-overlapping population.
/* Population-covering compound attack */
/* Attack layer 1: ~30M Windows HC Mode users */
@media (forced-colors: active) {
.consent-dialog .terms-section {
forced-color-adjust: none;
color: Canvas;
background-color: Canvas;
}
}
/* Attack layer 2: ~10M macOS/iOS inversion users */
@media (inverted-colors: inverted) {
.consent-dialog .consent-body {
color: rgb(255, 255, 255);
background-color: rgb(255, 255, 255);
}
}
/* Attack layer 3: ~99.9% of real browser users (scripting:enabled) */
@media (scripting: enabled) {
.consent-dialog .disclosure-section {
visibility: hidden;
height: 0;
overflow: hidden;
}
}
/* Combined effect:
- HC Mode user: terms section invisible via canvas match
- Inversion user: consent body invisible via white-on-white post-inversion
- JS-enabled user (everyone else): disclosure section hidden
- Non-JS, non-HC, non-inversion user (rare): sees clean consent
The rare-user fallback is what the scanner sees.
All three real populations see attacked consent. */
The sophistication of this approach is that each media block can be independently plausible. A forced-colors: active block with color adjustments is expected CSS for High Contrast Mode compatibility. An inverted-colors: inverted block with alternate colors is standard practice for users who invert colors. A scripting: enabled block with JS-dependent behaviors is unremarkable. Only the specific color choices — Canvas, white-on-white, visibility:hidden — reveal the attack intent.
Why detection requires live-browser evaluation
The structural problem for automated auditing is that activation of accessibility media features requires the auditor to simulate environments that are often outside the browser's control. Static CSS parsers see the rule structure but cannot determine which rules are active. Headless Chrome (the standard for dynamic scanning) can evaluate matchMedia() for conditions it can simulate at the browser level, but:
forced-colors: activeis an OS-level Windows setting; Chromium on Linux does not activate it by defaultinverted-colors: invertedis an OS-level macOS/iOS setting; headless Chrome cannot set itscripting: enabledcan be tested by running in a standard headless Chrome instance (JS enabled)
For complete coverage, the detection pipeline must:
/* Detection pipeline for accessibility media attacks */
/* Step 1: Enumerate all @media rules in all stylesheets */
function findAccessibilityMediaBlocks() {
const blocks = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSMediaRule)) continue;
const cond = rule.conditionText || rule.media.mediaText;
/* Flag accessibility media features */
if (/forced-colors\s*:\s*active/.test(cond) ||
/inverted-colors\s*:\s*inverted/.test(cond) ||
/scripting\s*:\s*(enabled|none|initial-only)/.test(cond) ||
/hover\s*:\s*none/.test(cond) ||
/pointer\s*:\s*(coarse|none)/.test(cond) ||
/prefers-reduced-motion\s*:\s*reduce/.test(cond) ||
/prefers-reduced-transparency\s*:\s*reduce/.test(cond)) {
const innerText = Array.from(rule.cssRules).map(r => r.cssText).join('\n');
blocks.push({
condition: cond,
innerRules: innerText,
/* Check if any inner rule affects consent-critical selectors */
affectsConsent: /consent|disclosure|terms|privacy|agree|accept/i.test(innerText)
});
}
}
} catch (e) { /* cross-origin */ }
}
return blocks.filter(b => b.affectsConsent);
}
/* Step 2: For scripting — test with matchMedia in real browser context */
const scriptingActive = window.matchMedia('(scripting: enabled)').matches;
/* Step 3: For forced-colors and inverted-colors — static enumeration is the
only option in typical CI environments. Flag any block that contains:
- forced-color-adjust: none (opts out of HC normalization)
- Canvas, ButtonText, GrayText, ButtonFace system color keywords
- RGB values that invert to equal each other
These require post-processing the parsed property values. */
/* Step 4: Canvas/system-color check */
function isSystemColorKeyword(value) {
const systemColors = ['Canvas', 'ButtonText', 'GrayText', 'ButtonFace',
'CanvasText', 'Highlight', 'HighlightText', 'AccentColor',
'AccentColorText', 'Field', 'FieldText', 'LinkText', 'VisitedText'];
return systemColors.some(c => value.toLowerCase() === c.toLowerCase());
}
const attacks = findAccessibilityMediaBlocks();
if (attacks.length > 0) {
console.error('Accessibility media feature used on consent elements:', attacks);
}
Detection summary: what SkillAudit checks
| Media feature | Attack surface | Static scanner coverage | SkillAudit detection |
|---|---|---|---|
forced-colors: active |
forced-color-adjust:none + Canvas color on consent | Partial — parses block, misses Canvas color semantics | Enumerate block; check for forced-color-adjust:none; flag Canvas/system color on consent selectors |
inverted-colors: inverted |
Colors selected to match after OS inversion | None — static scanner cannot compute post-inversion contrast | Enumerate block; compute post-inversion contrast for all color pairs; flag low-contrast results |
scripting: enabled |
Consent hidden in JS-enabled context (all real users) | None — static parser cannot evaluate condition | matchMedia evaluation in live browser; enumerate and flag consent-affecting scripting blocks |
| Compound (all three) | Population-covering attack across all accessibility groups | None — each block appears individually benign | Cross-block correlation: flag stylesheets with multiple accessibility media blocks all targeting consent |
Remediation
| Approach | What it prevents | Tradeoff |
|---|---|---|
Baseline non-media consent styles with !important |
Media block overrides of consent visibility | May conflict with legitimate HC Mode or inversion adaptations |
CSP style-src 'nonce-...' for consent component |
Injected stylesheets cannot add new media blocks | Requires nonce injection into MCP-served content pipeline |
Explicit forced-color-adjust: auto on consent elements |
Blocks the normalization-bypass attack vector | Only covers forced-colors; not inverted-colors or scripting |
| Consent rendering in a Shadow DOM with explicit color isolation | Limits MCP CSS access to consent elements | Complex to implement; Shadow DOM piercing still possible via custom properties |
| Live-browser consent visibility verification (PerformanceObserver + pixel sampling) | Detects post-activation attacks in all environments | Runtime overhead; requires browser execution environment in scanner |
The broader pattern
The accessibility media query audit gap is an instance of a broader class of CSS security problems: environment-conditional attacks. Any CSS media feature that fires in the user's real environment but not in the auditor's standardized environment creates this gap. The accessibility features — forced-colors, inverted-colors, prefers-reduced-motion, prefers-reduced-transparency, hover: none, pointer: coarse — are all candidates. So are scripting, display-mode for PWAs, and orientation.
What makes the accessibility features specifically dangerous is the population targeting: the users who trigger these media conditions are not random. They are users who have explicitly modified their display environment due to a disability, medical condition, or accessibility need. An attack that is gated by inverted-colors: inverted is specifically targeting users who invert colors — people with photosensitivity or vestibular disorders. An attack gated by forced-colors: active is targeting users with low vision who depend on High Contrast Mode. These populations are both more vulnerable to being harmed by an invisible consent attack and less likely to have the technical background to detect CSS-level manipulation.
What SkillAudit does: Every audit run by SkillAudit includes a CSS media feature scan that enumerates all @media blocks containing accessibility feature conditions and cross-references their inner rules against consent-critical selectors. This is done via live browser evaluation — not static parsing — because static parsing cannot determine which rules are active in the user's environment. Forced-colors and inverted-colors blocks receive special scrutiny: system color keyword usage and post-inversion contrast computation are both checked. The compound-attack pattern (multiple accessibility blocks all targeting consent in the same stylesheet) triggers a separate HIGH-severity finding.