CSS mask-image as a Consent Bypass Vector

CSS mask-image controls element visibility through the alpha channel of a composited mask layer — independently of the element's opacity, color, visibility, and display. A consent element with a fully transparent mask renders zero visible pixels while every standard accessibility and security auditor reports it as fully legible. This post explains the structural reason mask attacks evade detection, maps the four canonical attack patterns, provides a step-by-step detection algorithm, compares mask to the two closest CSS attack classes (opacity and clip-path), and covers browser support and Content Security Policy scope.

In this post

  1. The CSS mask rendering model
  2. Why auditors miss mask attacks
  3. Attack 1: Fully transparent gradient
  4. Attack 2: Zero-size mask tile
  5. Attack 3: Transparent PNG data URL
  6. Attack 4: Custom property indirection
  7. Detection algorithm
  8. Comparison: mask vs opacity vs clip-path
  9. Browser support matrix
  10. CSP mitigations
  11. Summary

The CSS mask rendering model

Standard CSS visibility properties work by modifying the element's own rendering: opacity: 0 sets the element's alpha to zero, display: none removes it from the layout tree, visibility: hidden suppresses its paint step, color: transparent makes its text invisible. All four of these are readable directly from getComputedStyle(element) — they are properties of the element itself.

mask-image works differently. The element renders normally — full color, full opacity, full layout box — and then the browser composites a mask image over the rendered output. Wherever the mask image has alpha = 1 (fully opaque), the element's pixels are visible. Wherever the mask image has alpha = 0 (fully transparent), the element's pixels are erased from the composited output. The element itself hasn't changed; only the mask layer controls what reaches the screen.

The key invariant: getComputedStyle(consentEl).opacity returns "1". getComputedStyle(consentEl).color returns a dark, legible color. consentEl.getBoundingClientRect() returns a normal-size box. consentEl.textContent contains the consent text. All of these are true while the element is entirely invisible due to a transparent mask.

This separation — mask alpha governs the final pixels; element properties govern the rendering inputs — is what makes mask consent attacks structurally different from every other CSS consent hiding technique. There is no change to any property the element "owns." The attack lives entirely in the mask layer.

The CSS mask shorthand decomposes into mask-image (the mask source), mask-size (tile dimensions), mask-repeat, mask-position, and mask-mode (alpha vs luminance masking). Each sub-property is a separate avenue of attack. The four patterns below exploit different combinations.


Four reasons auditors miss mask attacks

WCAG 2.1 and 2.2 do not address CSS masking. The specification's color contrast algorithm (Success Criterion 1.4.3) operates on the element's computed foreground color vs its background color — not the composited output. An auditor implementing SC 1.4.3 calls getComputedStyle on the consent element, reads color and background-color, computes the contrast ratio, and reports a pass. The mask that erases the element from the composited frame is never consulted.

This structural gap produces four concrete failure modes:

1. Color/opacity checks pass. The element's computed color is dark, its opacity is 1, and its background contrast ratio is 7:1 or higher. Every legibility check reports pass. The mask is not a color or opacity property — it is a compositing operation — and WCAG does not define a mask-image check.

2. Dimension checks pass. getBoundingClientRect() returns the element's layout box. The element occupies its normal space in the document flow. There is nothing in the layout dimensions to indicate the element is invisible. Contrast with clip-path: inset(0 0 100% 0), which also preserves layout dimensions but is at least a recognizable "clip the element to zero area" instruction that a CSS static analyzer can flag.

3. Static CSS analysis is insufficient. A static CSS parser can identify that a mask is present, but determining whether the mask produces zero coverage requires evaluating the mask image. For a gradient, this means parsing the gradient function, extracting all color stops, and computing their alpha values. For a data URL, it means base64-decoding the image and reading pixel alpha channels. For a custom property reference, it means resolving the variable chain at runtime. Static analyzers typically neither parse gradients at this depth nor decode image data from URLs.

4. The -webkit-mask prefix doubles the attack surface. Older WebKit/Blink versions and some auditing tools only check the unprefixed mask-image property. The prefixed -webkit-mask-image is functionally identical and is currently required in Safari. An auditor that only checks mask-image misses all attacks delivered via -webkit-mask-image.

Audit gap summary: WCAG SC 1.4.3 checks computed color contrast. SC 1.4.11 checks non-text contrast. Neither addresses mask-image transparency. The only detection path is explicit mask inspection — check the property's presence on consent elements, then evaluate the mask source's alpha coverage.


Attack patterns
1

Fully transparent gradient mask-image — consent invisible at opacity 1 Critical

SA-CSS-MASK-001 — all-transparent linear-gradient mask erases consent from the composited frame

The simplest mask consent attack uses a CSS gradient as the mask source where every color stop has alpha = 0. linear-gradient(transparent, transparent) produces a mask image whose alpha channel is uniformly zero across its entire area. Applied to the consent element, the mask composites the element against an all-zero alpha surface, erasing it entirely from the rendered frame. The consent element is present in the DOM, has a normal layout box, and has legible computed color values. It is simply invisible.

The attack is effective because the natural reading of a gradient mask is that it creates a partial-transparency fade effect. The transparent-to-transparent form — which is the attack — looks syntactically similar to a legitimate fade. Static CSS analysis tools that flag opacity: 0 as a hiding technique do not flag mask-image: linear-gradient(transparent, transparent) at the same severity, because they do not evaluate gradient alpha channels for mask sources.

/* Attack: consent invisible via fully transparent gradient mask */
.consent-required {
  /* Standard visibility properties — all auditor-correct */
  color: #1a1a1a;          /* dark text — passes contrast check */
  opacity: 1;              /* no opacity reduction — passes opacity check */
  visibility: visible;     /* not hidden — passes visibility check */
  display: block;          /* in layout — passes display check */

  /* The attack: mask erases the element from the composited output */
  mask-image: linear-gradient(transparent, transparent);
  -webkit-mask-image: linear-gradient(transparent, transparent);
}

/* Variants with equivalent zero-alpha stops */
/* mask-image: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)); */
/* mask-image: linear-gradient(hsla(0,0%,0%,0), hsla(0,0%,100%,0)); */
/* mask-image: linear-gradient(color(srgb 0 0 0 / 0), color(srgb 1 1 1 / 0)); */

All four gradient syntaxes above produce identical alpha channels — uniformly zero — and all produce an invisible consent element. The transparent keyword, rgba(*,*,*,0), hsla(*,*,*,0), and color(srgb * * * / 0) are equivalent for this purpose. An auditor must check the effective alpha value of each gradient stop, not just whether the keyword "transparent" appears in the value string.

Detection checklist

  • Check consent element and all ancestors for mask-image and -webkit-mask-image properties
  • When mask-image is a gradient: parse all color stops, compute their alpha values. Flag if all stops have effective alpha ≤ 0.1
  • Account for all alpha-zero color representations: transparent, rgba(*,*,*,0), hsla(*,*,*,0), color(* * * / 0)
  • Test in headless browser with getComputedStyle(el).maskImage and getComputedStyle(el).webkitMaskImage

See the full reference: CSS mask consent security — all four mask attack patterns with detection code.

2

Zero-size mask-size tile produces zero mask coverage High

SA-CSS-MASK-002 — mask-size:0px 0px creates a zero-dimension tile that leaves the consent element with no mask coverage (erased)

CSS mask tiling works by repeating the mask image across the element's area. When mask-size is set to 0px 0px, the mask tile has zero dimensions. A zero-dimension tile cannot cover any area of the element. In Chrome and Edge, the browser treats this as zero mask coverage: the element is rendered transparent (no pixels pass through). This behavior is consistent with how background-size: 0px produces no background coverage, applied to the mask compositing layer.

/* Attack: zero-size mask tile — zero coverage — element invisible */
.consent-text {
  mask-image: linear-gradient(#000, #000); /* opaque mask source */
  mask-size: 0px 0px;                      /* zero-dimension tile */
  mask-repeat: repeat;                      /* tiling — but tile has no size */
  -webkit-mask-image: linear-gradient(#000, #000);
  -webkit-mask-size: 0px 0px;
  -webkit-mask-repeat: repeat;
}

/* The mask-image value looks like a standard black gradient — opaque
   The attack is in mask-size: 0px 0px, a separate sub-property
   Static analysis of mask-image alone reports a solid opaque mask — "safe" */

This attack is particularly evasive because the mask source — the mask-image value — is a normal, opaque gradient. Static analysis of the mask-image value alone would conclude the element has a fully opaque mask (black = alpha 1), which should mean full visibility. The hiding behavior is entirely in the mask-size sub-property, which controls the tile dimension rather than the mask alpha. An auditor that only inspects mask-image alpha values misses this pattern entirely.

Firefox behavior differs: some versions treat a zero-size tile as "no mask applied" and render the element normally. The attack is Chrome/Edge-specific. Given that the overwhelming majority of MCP skill installations happen on developer machines running Chrome or Chrome-based browsers (VS Code's built-in browser, Arc, Edge), Chrome-specific behavior is still effective against the real target population.

Detection checklist

  • Check mask-size and -webkit-mask-size on consent elements — flag any value of 0, 0px, 0px 0px, 0%, or 0em
  • Do not assess mask-image alpha independently from mask-size — an opaque mask with zero tile size is still a zero-coverage mask
  • Test in Chromium headless to observe actual rendering, since behavior differs from Firefox
3

Transparent PNG via base64 data URL mask-image — no color keywords in CSS High

SA-CSS-MASK-003 — base64-encoded 1×1 fully transparent PNG as mask-image source — no alpha zero keywords in the stylesheet

The first two attacks express the mask's alpha directly in the CSS value: gradient color stops have explicit transparent or rgba(*,*,*,0) keywords. A sufficiently sophisticated auditor could identify zero-alpha keywords in gradient values and flag them. The data URL attack eliminates any readable alpha value from the stylesheet: the mask-image value is an opaque-looking base64 string with a PNG MIME type, and the mask's transparency is encoded inside the binary image data.

/* Attack: 1x1 fully transparent PNG as mask — zero alpha pixels, no CSS color keywords */
.consent-required {
  /* 68-byte 1x1 fully transparent PNG as base64 */
  mask-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
  -webkit-mask-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
  mask-repeat: repeat;
  -webkit-mask-repeat: repeat;
}

/* The CSS value contains: a data URL with a PNG MIME type and base64 content
   No "transparent", rgba(), alpha, or zero keyword appears anywhere
   A CSS text scanner sees a URL reference — looks like a normal image asset
   The transparent PNG is only revealed after base64 decode + PNG alpha channel read */

The base64 string above decodes to a 1×1 pixel PNG where the single pixel has alpha = 0. Tiled across the consent element (the default mask-repeat: repeat behavior), this produces a mask with uniformly zero alpha — the element is invisible. The mask is functionally identical to the transparent gradient in Attack 1, but its transparency is invisible to any tool that does not decode the image data.

The same technique works with WebP and GIF: a 1×1 fully transparent WebP or a 1-frame transparent GIF produce identical behavior with slightly different data URL contents. An auditor checking only the MIME type and URL structure would miss the attack regardless of encoding.

Detection checklist

  • When mask-image is a url('data:image/...'): extract the base64 content, decode it, load the image, and compute the mean alpha across all pixels
  • Flag mask images with mean pixel alpha ≤ 0.1 on consent elements — this threshold catches uniform transparency regardless of encoding
  • Apply the same decode-and-check logic to WebP, GIF, JPEG (via alpha channel if present), and APNG data URLs
  • Do not rely on image MIME type alone — a transparent PNG and a solid PNG have identical MIME types
4

mask-image: var(--consent-mask) — mask value hidden in custom property High

SA-CSS-MASK-004 — transparent gradient mask-image value stored in a CSS custom property — the var() reference hides it from stylesheet text analysis

Attacks 1–3 have a common property: the mask-image value in the stylesheet is directly inspectable. A static analysis tool that reads the stylesheet text can find the mask-image rule and then evaluate its value. Attack 4 breaks this by moving the mask value into a CSS custom property. The stylesheet contains only mask-image: var(--consent-mask). The property's value — the transparent gradient — is set elsewhere: in a :root rule that looks like benign theme configuration, or via JavaScript at install-click time.

/* Attack part 1: :root custom property — looks like a theme variable */
:root {
  --brand-primary: #5b21b6;
  --brand-secondary: #7c3aed;
  --consent-mask: linear-gradient(transparent, transparent); /* buried in theme vars */
  --text-sm: 13px;
  --radius-lg: 12px;
}

/* Attack part 2: consent element uses the variable */
.consent-text {
  mask-image: var(--consent-mask);
  -webkit-mask-image: var(--consent-mask);
}

/* Attack part 3 (behavioral variant): JS sets the variable at mousedown */
/* installBtn.addEventListener('mousedown', () => {
     document.documentElement.style.setProperty(
       '--consent-mask', 'linear-gradient(transparent, transparent)'
     );
   }); */
/* At page load: --consent-mask is unset (mask-image resolves to 'none' — consent visible)
   At install-click mousedown: mask becomes transparent — consent disappears
   Static audit at page load: consent visible — PASS
   Audit that does not simulate mousedown interaction: PASS  */

The custom property form has a second-order evasion advantage: the property name (--consent-mask) looks like a deliberate design decision — "there's a CSS variable for the consent element's mask." This is plausible in a real CSS architecture. The attack value blends in with legitimate theming infrastructure. Without resolving every custom property used in mask declarations and evaluating the resolved values, the attack is invisible to static analysis.

The behavioral variant — where the variable is set to the transparent gradient only at mousedown on the install button — further defeats static and page-load auditing. The consent element is visible at page load and during any static scan. Only a behavioral auditor that simulates the install-click interaction and re-checks consent visibility after the mousedown event will observe the attack.

Detection checklist

  • When mask-image contains a var() reference: resolve the custom property using getComputedStyle(el).maskImage — this returns the computed (resolved) value, not the var() reference
  • Alternatively, resolve the custom property explicitly: getComputedStyle(el).getPropertyValue('--property-name'), then recursively resolve any nested var() references
  • For the behavioral variant: simulate mousedown on the install button, then re-check getComputedStyle(consentEl).maskImage before the click event fires
  • Flag consent elements where the resolved mask-image value contains a fully transparent gradient or data URL

Detection algorithm

The four attacks above require different detection paths, but they share a common entry point: a mask-image (or -webkit-mask-image) property on a consent element or its ancestors. The detection algorithm proceeds in five steps:

Step 1: Identify consent elements. Locate elements that contain or are associated with consent text. In practice this means scanning for elements with text matching consent patterns ("I agree", "by installing", "permission", "access to", "accept terms") and for elements with ARIA attributes indicating consent role.

Step 2: Check for mask properties on consent elements and ancestors. For each consent element, call getComputedStyle(el).maskImage and getComputedStyle(el).webkitMaskImage. Do the same for every ancestor up to the document body. If the value is not "none", proceed to Step 3.

Step 3: Resolve custom properties. If the computed mask-image value contains a var() reference (some browsers may return the resolved value directly, but older Chromium versions may not), explicitly read the custom property: getComputedStyle(el).getPropertyValue('--property-name'). Repeat for any nested var() references. After resolution, proceed with the fully resolved value.

Step 4: Evaluate the resolved mask source.

For gradient values: parse the gradient function, extract all color stop alpha values. Compute the mean alpha across all stops. Flag if mean alpha ≤ 0.1.

For data URL values: extract the base64 content from the url('data:...') wrapper. Decode the base64 string to binary. Load the binary as an image (using createImageBitmap() in a headless browser context). Read all pixel alpha values via a Canvas 2D context. Flag if mean pixel alpha ≤ 0.1.

Step 5: Check mask-size for zero-dimension tiles. Regardless of the mask-image value, check getComputedStyle(el).maskSize and getComputedStyle(el).webkitMaskSize. Flag any value where both dimensions are zero or near-zero (≤ 1px).

// Simplified detection implementation
async function checkConsentMask(consentEl) {
  const props = ['maskImage', 'webkitMaskImage'];
  const sizeProp = ['maskSize', 'webkitMaskSize'];
  const style = getComputedStyle(consentEl);

  for (const prop of props) {
    const val = style[prop];
    if (!val || val === 'none') continue;

    // Step 3: resolve var() if not already resolved
    const resolved = resolveCustomProperties(consentEl, val);

    // Step 4a: gradient — parse alpha values
    if (resolved.includes('gradient')) {
      const alphas = parseGradientStopAlphas(resolved);
      if (alphas.length > 0 && alphas.every(a => a <= 0.1)) {
        return { finding: 'SA-CSS-MASK-001', severity: 'CRITICAL', value: resolved };
      }
    }

    // Step 4b: data URL — decode image, read alpha channel
    if (resolved.startsWith('url("data:image/')) {
      const meanAlpha = await decodeMaskDataUrlAlpha(resolved);
      if (meanAlpha <= 0.1) {
        return { finding: 'SA-CSS-MASK-003', severity: 'HIGH', value: 'data URL' };
      }
    }
  }

  // Step 5: mask-size zero dimensions
  for (const sp of sizeProp) {
    const sz = style[sp];
    if (sz && /^0(px|%)?\s+0(px|%)?$|^0(px|%)?$/.test(sz)) {
      return { finding: 'SA-CSS-MASK-002', severity: 'HIGH', maskSize: sz };
    }
  }

  return null; // no mask consent bypass detected
}

Comparison: mask vs opacity vs clip-path

Mask attacks sit at the intersection of two other well-known CSS consent hiding classes: opacity attacks (which also operate on the element's visual rendering independently of layout) and clip-path attacks (which remove the element from the visible frame while preserving layout dimensions). Understanding the differences matters for audit prioritization.

Property
opacity: 0
clip-path: inset(0 0 100% 0)
mask-image: linear-gradient(transparent, transparent)
Element visible to user
No
No
No
getComputedStyle returns suspicious value
Yes — opacity: 0
Yes — inset(0 0 100% 0)
No — mask-image has a plausible gradient value
WCAG contrast check fails
No — opacity doesn't affect SC 1.4.3
No — color values unchanged
No — color values unchanged
Detectable by static CSS text analysis
Easy — literal "opacity: 0" in CSS
Moderate — inset() with 100% requires parsing
Hard — requires gradient alpha parsing or image decode
Requires image decode to detect
Never
Never
Yes (for data URL variant)
Behavioral (JS) variant possible
Yes — easy to detect via interaction
Yes — detectable via interaction
Yes — and harder (need to check mask after interaction)
Browser support of attack property
Universal
Universal modern
Modern only (-webkit- prefix in Safari)
Detection complexity
Low
Medium
High

The mask attack is the hardest of the three to detect reliably. An auditor can detect opacity: 0 with a string match. It can detect clip-path zero-area patterns with a geometric calculation on the clip function arguments. Detecting a mask attack requires gradient alpha parsing, image decode pipelines, and custom property resolution — a significantly higher implementation bar that most WCAG-based accessibility scanners have not crossed.


Browser support matrix

BrowserUnprefixed mask-webkit-mask prefixAttack viable
Chrome 120+YesYesYes (both forms)
Edge 120+YesYesYes (both forms)
Firefox 53+YesPartialYes (unprefixed)
Safari 15.4+YesRequiredYes (prefixed preferred)
Chrome < 120NoYesYes (prefixed only)

To cover all modern browsers with a single rule, an MCP server author would include both mask-image and -webkit-mask-image with identical values. An auditor must check both properties to avoid missing the attack on any browser segment. The prefixed form is not deprecated — Safari still requires it for most mask sub-properties — so its presence in stylesheets is not itself suspicious.


CSP scope and limitations

Content Security Policy does not have a directive that prevents mask-image usage. The style-src directive controls which stylesheets can be loaded, but once a stylesheet is allowed, all CSS properties within it are permitted — including mask-image with any gradient or data URL value. There is no way to restrict mask-image sources at the CSP level.

For data URL masks specifically: img-src does not apply to CSS mask-image: url(data:...)) in most browsers. The img-src directive covers <img> elements and CSS background-image in some browsers but not mask-image. There is ongoing discussion in the W3C CSP spec about adding mask-image source restrictions, but no current browser implements it.

Practical consequence: CSP cannot be used as a defence against mask consent bypass attacks. The only mitigation is runtime audit — checking that consent elements do not have masks with zero alpha coverage at the time of and during the install interaction.

The correct defence is audit-based: run a behavioral scanner that checks mask properties on consent elements at page load and after simulating the install interaction. SkillAudit's scanner checks all four mask attack patterns — transparent gradient, zero-size tile, transparent PNG data URL, and custom property indirection — as part of its standard consent-bypass detection suite. Run a free audit →


Summary

IDAttackSeverityCSS value hintDetection requirement
SA-CSS-MASK-001Fully transparent gradient mask-imageCriticalAll gradient stops alpha = 0Parse gradient stop alphas
SA-CSS-MASK-002Zero-size mask tile (mask-size: 0px)Highmask-size: 0 or 0px 0pxCheck mask-size dimensions
SA-CSS-MASK-003Transparent PNG via data URLHighurl('data:image/png;base64,...')Decode image, read mean alpha
SA-CSS-MASK-004Custom property var() indirectionHighmask-image: var(--name)Resolve var() → check value

SkillAudit detects all four mask attack patterns and their behavioral variants — custom property resolution, gradient alpha parsing, data URL decode, and post-interaction re-check. Run a free audit to check your MCP server's install UI for mask-based consent bypass.

Related posts: CSS @supports and @media as MCP consent bypass vectors · CSS clip-path consent security · CSS opacity consent security