MCP server CSS cross-fade() security: zero-opacity disclosure blend, background wash, mask-image compositing, and sub-pixel alpha remainder attacks
Published 2026-07-23 — SkillAudit Research
CSS cross-fade() is an image function that composites two or more images by blending them at specified opacity percentages. Originally introduced as -webkit-cross-fade() and standardized in CSS Images Level 4, it accepts any CSS image value (URLs, gradients, or other image functions) and produces a weighted blend. Intended for smooth image transitions in CSS animations and for blending decorative image layers, it becomes an attack surface when an MCP server uses it to blend consent disclosure images toward transparent — either to zero percent disclosure opacity or to a high-percentage wash of white or background color that renders the disclosure unreadable.
This article documents four CSS cross-fade() attack vectors: blending a disclosure image to zero percent opacity in the standard two-argument form, compositing a white wash layer at near-100% over the disclosure, using cross-fade() in mask-image to generate a transparency mask from a blend, and using a sub-pixel alpha remainder (1% disclosure opacity) to pass simple opacity threshold checks while remaining visually indistinguishable from blank.
Background-image versus text content: These cross-fade() attacks apply to consent disclosures rendered as images (e.g., a PNG or SVG of the consent text, commonly used in rich dialog UIs) or to elements that use a background-image to display the disclosure. They do not affect text nodes directly — a plain text node is not a CSS image. MCP servers that cause the consent host to render disclosure as an image (which happens in some framework-based consent UIs that use canvas or SVG) are the primary target environment for these attacks.
Attack 1: cross-fade() zero-percent disclosure blend
The cross-fade() function composites two images by mixing them at the specified percentages. When the disclosure image is placed at 0% and a blank/white image at 100%, the result is the blank image — the disclosure is entirely absent from the composited output. This produces an image that has the same dimensions as the disclosure but shows only the overlay image:
/* cross-fade() zero-percent disclosure blend */
.consent-disclosure-area {
background-image: cross-fade(
url("/disclosures/permissions-required.png") 0%, /* disclosure at 0% → invisible */
url("/ui/white-fill.png") 100% /* white fill at 100% → all you see */
);
/* The element's background renders as a white rectangle.
The disclosure image is technically present as an argument
to cross-fade() but contributes zero to the rendered output.
Network request for permissions-required.png IS sent (browser fetches all candidates)
but the fetched image has zero visual weight in the rendered pixel output. */
width: 400px;
height: 120px; /* same dimensions as the disclosure */
}
/* Equivalent using transparent instead of white-fill: */
.consent-disclosure-area {
background-image: cross-fade(
url("/disclosures/permissions-required.png") 0%,
transparent 100% /* gradient/color stop as second argument */
);
/* Renders as fully transparent background — element is blank */
}
The zero-percent variant is the most obvious form, but it is also the most detectable: the percentage value 0% in a cross-fade() function targeting a disclosure image is a clear signal. More subtle variants use near-zero percentages:
/* Near-zero percentage — harder to detect by threshold */
.consent-disclosure-area {
background-image: cross-fade(
url("/disclosures/permissions-required.png") 2%, /* 2% disclosure opacity */
url("/ui/white-fill.png") 98% /* 98% white wash */
);
/* At 2% opacity, text glyphs in the disclosure image are rendered at
2/255 ≈ 0.8% of their full alpha — invisible against a white background.
The percentage passes a threshold check of "> 0%" but the disclosure
remains completely unreadable to a human viewer. */
}
Attack 2: white-wash compositing via cross-fade() at high percentage
The inverse attack applies cross-fade() with the disclosure at a visible percentage but composited under a high-percentage white layer. At 80-99% white wash, the disclosure image is present in the composited output at too low a contrast ratio to be readable:
/* White-wash cross-fade() attack */
.consent-disclosure {
background-image: cross-fade(
url("/disclosures/permissions.png") 15%, /* 15% disclosure: low contrast */
url("/ui/white.png") 85% /* 85% white overlay */
);
/* Result: text glyphs appear as very light gray against white background.
Contrast ratio at 15% disclosure on white background:
dark text (e.g., #1a1a1a) blended at 15% with white produces ~#d9d9d9 on white.
WCAG contrast ratio: #d9d9d9 on #ffffff = 1.12:1 — far below the 4.5:1 minimum. */
}
/* The attack exploits the difference between "element has a background-image"
(which static checks will confirm) and "the background-image is readable"
(which requires contrast ratio analysis). */
/* Compound variant: cross-fade() with background-blend-mode */
.consent-disclosure {
background-image:
linear-gradient(rgba(255,255,255,0.9), rgba(255,255,255,0.9)),
cross-fade(url("/disclosures/permissions.png") 80%, url("/ui/bg.png") 20%);
background-blend-mode: screen, normal;
/* The linear-gradient layer at 90% opacity screens over the cross-faded disclosure.
Screen blend mode on white further washes out the disclosure below. */
}
Detection requires computing the effective contrast ratio of the composited output rather than simply checking whether a background-image is set:
function detectCrossFadeWashAttack(el) {
const cs = window.getComputedStyle(el);
const bgImage = cs.backgroundImage;
if (!bgImage.includes('cross-fade')) return { clean: true };
// Parse cross-fade() arguments to extract percentages
const crossFadeMatch = bgImage.match(/cross-fade\(([^)]+)\)/i);
if (!crossFadeMatch) return { clean: true };
const args = crossFadeMatch[1];
const percentages = [...args.matchAll(/(\d+(?:\.\d+)?)\s*%/g)].map(m => parseFloat(m[1]));
const urls = [...args.matchAll(/url\(["']?([^"')]+)["']?\)/gi)].map(m => m[1]);
// Flag if any disclosure URL candidate has very low percentage
const suspiciousUrls = urls.filter(url =>
url.includes('disclosure') || url.includes('permission') || url.includes('consent')
);
if (suspiciousUrls.length > 0) {
const idx = urls.indexOf(suspiciousUrls[0]);
const pct = percentages[idx] ?? 50;
if (pct < 20) {
return {
clean: false,
reason: `cross-fade() applies disclosure URL at ${pct}% opacity — below readability threshold (20%)`,
disclosureUrl: suspiciousUrls[0],
percentage: pct,
};
}
}
return { clean: true };
}
Attack 3: cross-fade() in mask-image — transparency mask from blended images
When used in mask-image, cross-fade() produces a mask from the blend of two images. If the blend produces a fully or mostly transparent result, the masked element becomes invisible even though its element properties (dimensions, text content, visibility state) remain normal:
/* mask-image cross-fade() attack */
.consent-disclosure {
/* Layout properties: element remains in flow with full dimensions */
padding: 16px 20px;
border: 1px solid #e5e7eb;
/* Mask: cross-fade of two transparent images → element becomes invisible */
-webkit-mask-image: cross-fade(
url("/masks/transparent-1.png") 50%,
url("/masks/transparent-2.png") 50%
);
mask-image: cross-fade(
url("/masks/transparent-1.png") 50%,
url("/masks/transparent-2.png") 50%
);
/* Both mask images are fully transparent → composited mask is transparent
→ masked element is fully invisible.
Element still occupies space in the layout; text is in the DOM. */
}
/* Variant: use cross-fade() with a color stop as a mask gradient */
.consent-disclosure {
-webkit-mask-image: cross-fade(
linear-gradient(white, white) 0%, /* opaque gradient (visible) at 0% */
linear-gradient(transparent, transparent) 100% /* transparent gradient at 100% */
);
mask-image: cross-fade(
linear-gradient(white, white) 0%,
linear-gradient(transparent, transparent) 100%
);
/* Result: 100% transparent → invisible element */
}
Attack 4: sub-pixel alpha remainder — bypassing opacity percentage threshold checks
A detection approach that flags cross-fade() when the disclosure percentage is below a threshold (e.g., below 5%) can be bypassed by using a percentage that exceeds the threshold but still produces an unreadable image. The sub-pixel alpha remainder attack uses the minimum percentage that passes the threshold check while producing a visually indistinguishable-from-blank result:
/* Sub-pixel alpha remainder — bypasses "> 5% disclosure percentage" check */
.consent-disclosure-image {
background-image: cross-fade(
url("/disclosures/permissions.png") 6%, /* just above 5% threshold */
url("/ui/white.png") 94%
);
/* At 6% disclosure opacity on a white-wash:
Black text (#000000) in the disclosure blends as:
(0 * 0.06 + 255 * 0.94) = 239.7 → #EFF0F0 — 97% white
WCAG contrast against white: #EFF0F0 on #FFFFFF = 1.01:1 — invisible.
The check "cross-fade percentage > 5%" passes.
The actual contrast ratio is 1.01:1 — the disclosure is unreadable. */
}
/* Table of effective contrast ratios by cross-fade percentage (dark text on white wash):
5% disclosure: contrast ≈ 1.00:1 (invisible)
10% disclosure: contrast ≈ 1.01:1 (invisible)
20% disclosure: contrast ≈ 1.05:1 (invisible)
30% disclosure: contrast ≈ 1.12:1 (effectively invisible)
40% disclosure: contrast ≈ 1.25:1 (below readable threshold)
50% disclosure: contrast ≈ 1.50:1 (still below WCAG AA minimum of 4.5:1)
The disclosure must be approximately 75% or higher to reach WCAG AA contrast on white.
Any cross-fade() applying less than 75% to the disclosure over a white layer
produces unreadable output. */
Detection using contrast ratio: The reliable detection for cross-fade() wash attacks is not checking the percentage threshold but computing the effective contrast ratio of the composited output. SkillAudit's scanner evaluates the WCAG contrast ratio of the blended result: if a cross-fade() function in background-image on a consent element produces an effective contrast ratio below 4.5:1 for AA compliance, it is flagged regardless of what percentage the disclosure appears at. This closes the sub-pixel bypass.
Attack summary
| Attack | cross-fade() form | Effect | Detection point | Severity |
|---|---|---|---|---|
| Zero-percent blend | cross-fade(url(disclosure) 0%, url(white) 100%) |
Disclosure at 0% → entirely absent from render | Percentage extraction from backgroundImage |
High |
| White-wash compositing | cross-fade(url(disclosure) 15%, url(white) 85%) |
Contrast ratio 1.05:1 — unreadable against white | WCAG contrast ratio of blended output | High |
| mask-image transparency | mask-image: cross-fade(transparent 50%, transparent 50%) |
Element paint fully masked — invisible with normal layout | Any non-none mask-image on consent element |
High |
| Sub-pixel alpha remainder | cross-fade(url(disclosure) 6%, url(white) 94%) |
Passes >5% threshold check; contrast 1.01:1 — invisible | Contrast ratio check (not percentage threshold) | High |
Consolidated finding blocks
cross-fade() function applies disclosure image at 0% opacity in background-image. Disclosure is fetched but contributes zero pixels to the rendered output. Detected by parsing backgroundImage for cross-fade() and extracting the per-candidate percentages.
cross-fade() blend rather than checking the raw percentage.
mask-image: cross-fade() composites two transparent images to produce a fully-transparent mask, making the consent element invisible while layout dimensions and DOM content remain unchanged. Flag any non-none mask-image value on a consent disclosure element.
cross-fade(url(disclosure) 6%, url(white) 94%) passes a ">5% disclosure" threshold check but produces contrast ratio of 1.01:1 against white — visually indistinguishable from blank. Closed by computing WCAG contrast ratio of the blended output instead of checking percentage values.