MCP server CSS mask-image security: transparent gradient mask hides consent while display:block and visibility:visible pass, fully-transparent radial mask, -webkit-mask-image prefix variant, and JS mousedown mask injection at install time
Published 2026-08-07 — SkillAudit Research
The CSS mask-image property applies an alpha mask to an element's rendered output: wherever the mask is transparent, the element's painting is hidden. A consent element with mask-image: linear-gradient(to bottom, black 0%, transparent 100%) shows only the topmost pixels of the first line of text — the rest is masked to transparency. Unlike opacity, visibility, or display, the mask-image property does not affect any of the standard visibility checks: getComputedStyle().display is 'block', .visibility is 'visible', .opacity is '1', and getBoundingClientRect() returns the full element dimensions. The user sees nothing (or only partial characters); the audit sees a fully visible, normally-sized element.
CSS masking is distinct from clip-path-security (which clips the element's geometry) and from opacity-security (which affects the entire element's alpha channel). A mask operates at the painting stage — the element's box is fully present, but only the non-masked portion of its painted pixels is composited onto the page. The vendor-prefixed -webkit-mask-image property is widely used and requires a separate check from the standard property.
Detection gap: CSS mask-image does not affect display, visibility, opacity, offsetWidth/Height, or getBoundingClientRect(). Standard audit checks miss this attack entirely. Detection requires reading getComputedStyle(el).maskImage and getComputedStyle(el).webkitMaskImage and checking for gradient values containing transparent or rgba(*, *, *, 0) that cover the consent text area.
Attack 1: mask-image:linear-gradient() — gradient fade hides bottom 90% of consent text (SA-CSS-MIMG-001)
mask-image: linear-gradient(to bottom, black 0%, transparent 10%) applied to the consent element creates an alpha mask that fades from fully opaque at the very top to fully transparent at 10% of the element's height. If the consent text starts at the top of the element, only the very first few pixels of the first text line's ascenders are visible — not enough to read. The element has full offsetHeight, full getBoundingClientRect().height, display: block, visibility: visible. Only getComputedStyle(el).maskImage reveals the gradient mask.
/* MCP attack: */
.consent-disclosure {
mask-image: linear-gradient(to bottom, black 0%, transparent 10%);
/* Top 10% of element visible (top few px of first text line)
Remaining 90% fully masked = invisible
getComputedStyle().display: 'block' ← audit passes
getComputedStyle().visibility: 'visible' ← audit passes
getComputedStyle().opacity: '1' ← audit passes
getBoundingClientRect().height: 80px ← audit passes
getComputedStyle().maskImage: 'linear-gradient(...)' ← only this reveals */
}
// Detection:
function detectMaskImageGradient(el) {
const cs = window.getComputedStyle(el);
const maskImg = cs.maskImage || cs.webkitMaskImage;
if (!maskImg || maskImg === 'none') return;
// Any gradient containing transparent or rgba(*,*,*,0)
if (/transparent|rgba\([^)]*,\s*0\s*\)/i.test(maskImg)) {
console.error('SA-CSS-MIMG-001: CSS mask-image gradient contains transparent stops', {
el,
maskImage: maskImg,
display: cs.display, // 'block' — standard check passes
visibility: cs.visibility, // 'visible' — standard check passes
opacity: cs.opacity // '1' — standard check passes
});
}
}
Attack 2: mask-image:radial-gradient(transparent, transparent) — fully-transparent mask, element fully hidden (SA-CSS-MIMG-002)
A radial gradient where all stops are transparent creates a fully transparent mask — the entire element's painting is hidden. mask-image: radial-gradient(circle at center, transparent, transparent) is equivalent in rendering to visibility: hidden, but it does not set the visibility property. Every standard visibility check passes. The value may be further obfuscated using rgba(0,0,0,0) instead of the transparent keyword, evading literal-keyword scanners.
/* MCP attack variant A: fully transparent radial gradient */
.consent-disclosure {
mask-image: radial-gradient(circle at center, transparent, transparent);
/* Entire element masked to transparent — fully invisible
No display/visibility/opacity/dimension check fails */
}
/* MCP attack variant B: rgba instead of 'transparent' keyword */
.consent-disclosure {
mask-image: radial-gradient(
circle at 50% 50%,
rgba(0, 0, 0, 0) 0%, /* not 'transparent' keyword */
rgba(0, 0, 0, 0) 100% /* literal-keyword scanner misses */
);
}
/* MCP attack variant C: -webkit-mask-image prefix (wider compatibility) */
.consent-disclosure {
-webkit-mask-image: radial-gradient(circle, transparent, transparent);
mask-image: radial-gradient(circle, transparent, transparent);
/* Some audit tools only check unprefixed mask-image */
}
// Detection:
function detectFullyTransparentMask(el) {
const cs = window.getComputedStyle(el);
for (const prop of ['maskImage', 'webkitMaskImage']) {
const val = cs[prop];
if (!val || val === 'none') continue;
// Check for rgba(*,*,*,0) pattern (non-keyword transparent)
const rgbaMatches = val.match(/rgba\([^)]+\)/g) || [];
const allZeroAlpha = rgbaMatches.length > 0 && rgbaMatches.every(rgba => {
const parts = rgba.match(/[\d.]+/g) || [];
return parseFloat(parts[3] ?? '1') < 0.05;
});
if (allZeroAlpha || (val.includes('transparent') && !val.match(/\btransparent\b.*\b(?:black|white|#|rgb(?!a))/i))) {
console.error('SA-CSS-MIMG-002: fully-transparent CSS mask hides consent', {
el, maskProp: prop, maskValue: val
});
}
}
}
Attack 3: mask-image:none override on child — parent mask reveals content, child override hides it (SA-CSS-MIMG-003)
A parent container has a deliberate mask-image that creates a visible "window" (e.g., a reveal animation). The consent text is inside a child element that overrides the mask with its own fully-transparent mask. The parent's mask is legitimate; the child's mask is the attack. Auditors checking only the direct consent element's styles see mask-image: none — which looks safe — but the effective rendering is hidden because the child's inline mask (set via JS or inline style) applies a transparent gradient. The combined masking of parent and child layers hides the consent.
/* Attack: parent has legitimate mask, child has transparent mask overlay */
.mcp-dialog {
/* No mask — audit checks dialog container, sees no mask */
}
.consent-disclosure {
/* No CSS class-level mask — static scanner misses attack */
}
// MCP JS applies inline mask to child at render time:
document.querySelector('.consent-disclosure').style.maskImage =
'linear-gradient(transparent, transparent)';
/* Effect: consent element's own mask overrides any parent masking:
element is fully transparent despite parent having no suspicious mask */
// Detection: check inline style mask in addition to computed
function detectInlineMask(el) {
if (el.style.maskImage || el.style.webkitMaskImage) {
const val = el.style.maskImage || el.style.webkitMaskImage;
if (/transparent/i.test(val)) {
console.error('SA-CSS-MIMG-003: inline style mask-image transparent value on consent', {
el, inlineMask: val
});
}
}
// MutationObserver for dynamic mask injection
const obs = new MutationObserver(() => {
const cs = window.getComputedStyle(el);
const mask = cs.maskImage || cs.webkitMaskImage;
if (mask && mask !== 'none' && /transparent/i.test(mask)) {
console.error('SA-CSS-MIMG-003: dynamic mask-image injection detected', { el, mask });
}
});
obs.observe(el, { attributes: true, attributeFilter: ['style'] });
}
Attack 4: JS mousedown injects mask-image at install click — consent visible at audit time (SA-CSS-MIMG-004)
No mask is applied at page load — the consent element is fully visible and passes all audit checks. At mousedown on the install button, JS sets consentEl.style.maskImage = 'linear-gradient(black, transparent 5%)'. If a CSS transition is defined on the mask-composite layer, the fade is smooth. Otherwise it's instant. The consent disappears at the moment of user interaction. A MutationObserver watching the style attribute of the consent element detects the mask injection within one rAF of the mousedown event.
/* Baseline: no mask, consent fully visible */
.consent-disclosure {
/* mask-image: unset at load time */
transition: mask-image 0.2s; /* CSS transition on mask for smooth fade */
}
// MCP JS — mask injection at mousedown:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-disclosure');
if (consent) {
consent.style.maskImage = 'linear-gradient(black 0%, transparent 5%)';
consent.style.webkitMaskImage = 'linear-gradient(black 0%, transparent 5%)';
/* 95% of element fades to transparent within 5% from top
Appears to user as a "confirm" collapse animation */
}
}, { capture: true });
// Detection:
function detectMousedownMaskInjection() {
document.querySelectorAll('[data-consent], .consent-disclosure').forEach(el => {
const obs = new MutationObserver(() => {
const mask = el.style.maskImage || el.style.webkitMaskImage
|| window.getComputedStyle(el).maskImage;
if (mask && mask !== 'none' && /transparent/i.test(mask)) {
console.error('SA-CSS-MIMG-004: mask-image injected at interaction time', {
el, mask
});
}
});
obs.observe(el, { attributes: true, attributeFilter: ['style'] });
// Simulate install mousedown
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
requestAnimationFrame(() => {
const mask = el.style.maskImage || window.getComputedStyle(el).maskImage;
if (mask && mask !== 'none' && /transparent/i.test(mask)) {
console.error('SA-CSS-MIMG-004: mask still present after mousedown simulation', { el, mask });
}
});
});
}
Root detection method for all mask-image attacks: Check both getComputedStyle(el).maskImage and getComputedStyle(el).webkitMaskImage (they are separate slots in some browsers). Any value that is not 'none' and contains transparent or rgba(*, *, *, 0) at positions covering the element's text area is a finding. Standard checks — display, visibility, opacity, getBoundingClientRect() — are completely bypassed by the mask layer. SkillAudit checks both the prefixed and unprefixed mask-image computed properties on every identified consent element.
Attack summary
| ID | CSS / JS technique | display | visibility | getBCR | maskImage | Severity |
|---|---|---|---|---|---|---|
| SA-CSS-MIMG-001 | mask-image: linear-gradient(black 0%, transparent 10%) | block | visible | normal | gradient ✗ reveals | High |
| SA-CSS-MIMG-002 | mask-image: radial-gradient(transparent, transparent) | block | visible | normal | all-transparent ✗ reveals | High |
| SA-CSS-MIMG-003 | Inline style.maskImage transparent override | block | visible | normal | inline mask ✗ reveals | High |
| SA-CSS-MIMG-004 | JS mousedown injects maskImage gradient | block | visible | normal | none at load; injected ✗ | High |
Consolidated finding blocks
mask-image: linear-gradient(black 0%, transparent 10%) to the consent element. Only the top few pixels of the first text line are visible. display, visibility, opacity, and getBoundingClientRect() all return normal values. Only getComputedStyle().maskImage reveals the attack.
mask-image: radial-gradient(transparent, transparent) masks the entire element to transparent. Equivalent in visual rendering to visibility:hidden but none of the standard visibility checks catch it. Checking for transparent keyword or rgba(0,0,0,0) in both maskImage and webkitMaskImage computed values detects both the keyword and rgba variants.
el.style.maskImage = 'linear-gradient(transparent, transparent)' at render or interaction time. Static CSS scanner misses it; MutationObserver watching style attribute changes detects it within one rAF.
CSS clip-path security | CSS opacity security | CSS backdrop-filter security | Security Checklist