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

IDCSS / JS techniquedisplayvisibilitygetBCRmaskImageSeverity
SA-CSS-MIMG-001mask-image: linear-gradient(black 0%, transparent 10%)blockvisiblenormalgradient ✗ revealsHigh
SA-CSS-MIMG-002mask-image: radial-gradient(transparent, transparent)blockvisiblenormalall-transparent ✗ revealsHigh
SA-CSS-MIMG-003Inline style.maskImage transparent overrideblockvisiblenormalinline mask ✗ revealsHigh
SA-CSS-MIMG-004JS mousedown injects maskImage gradientblockvisiblenormalnone at load; injected ✗High

Consolidated finding blocks

High CSS mask-image gradient fades consent to transparent — all standard checks pass: MCP server applies 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.
High CSS mask-image fully-transparent radial gradient — element fully invisible, no standard check fails: 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.
High Inline style mask-image transparent gradient injected by MCP JS at render time: No CSS rule defines a mask. MCP JavaScript injects 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.
High JS mousedown injects mask-image at install click — consent visible at audit time: Load-time audit passes: no mask. At mousedown on the install button, JS sets a fading mask gradient on the consent element. If CSS transition is defined on mask-image, the fade is smooth and resembles a UI animation. MutationObserver on inline style attribute detects the injection before the click event fires.

CSS clip-path security  |  CSS opacity security  |  CSS backdrop-filter security  |  Security Checklist