CSS Stacking Context Attacks as MCP Consent Bypass: Every Property That Creates a New Context
Nine CSS properties create a new stacking context. MCP servers need to set just one on a dialog parent. Then a consent child with z-index:-1 disappears behind the parent's opaque background — invisible, with display:block and all standard checks passing. This post catalogs all nine, explains why the detection challenge is the same for every one, and provides a unified ConsentStackingAudit detector.
Contents
- The stacking context invariant
- All nine CSS triggers
- 1. contain: paint
- 2. isolation: isolate
- 3. z-index on positioned elements
- 4. opacity < 1
- 5. will-change (compositing)
- 6. transform (non-none)
- 7. mix-blend-mode
- 8. filter (non-none)
- 9. backdrop-filter
- Detection challenge
- Unified ConsentStackingAudit
The stacking context invariant that makes all nine attacks identical
When a CSS stacking context is established on an ancestor element, that element becomes a new root for z-index resolution within its subtree. A consent child with z-index: -1 is no longer stacked against the page root — it is stacked within the ancestor's local context. If that ancestor has an opaque background color (say background: #ffffff), the background layer paints over the z-index: -1 child. The consent is invisible.
This is not a browser bug. It is the correct, specified behavior of CSS stacking contexts. Every property in the list below produces this exact outcome when combined with a consent child that has z-index: -1 and a positioned ancestor:
- display: block — passes
- visibility: visible — passes
- opacity: 1 (on the consent itself) — passes
- getBoundingClientRect() — in-bounds, non-zero — passes
- el.textContent — full consent string — passes
- getComputedStyle(el).zIndex — returns
-1— the only direct signal, but it is on the consent element, not on the ancestor that triggers the stacking context
A scanner that only checks the consent element's own properties will miss every one of these attacks. The stacking context is set on a parent. The consent is hidden by the parent's background. The fix is to walk the ancestor chain.
One pattern, nine CSS entry points. Each property below has legitimate UI uses — animating a drawer, blending an overlay, containing a widget. Attackers hide the attack inside a "decorative depth" z-index: -1 on the consent element, framed as a shadow or layering effect. The stacking context creator on the ancestor looks innocent in isolation.
All nine CSS stacking context triggers
contain: paint
Clips painting to border box and creates stacking context. Legitimate use: widget isolation.
isolation: isolate
Explicit stacking context with no visual side-effect. Legitimate use: mix-blend-mode isolation.
position + z-index
Any positioned element with a non-auto z-index. Most common CSS pattern.
opacity < 1
Any opacity value less than 1. Legitimate use: hover transitions, loading states.
will-change
will-change: transform/opacity/filter promotes element to compositing layer + stacking context.
transform (non-none)
Any transform value other than none. Legitimate use: animations, layout adjustments.
mix-blend-mode
Any non-normal blend mode. Legitimate use: overlay effects, color mixing.
filter (non-none)
blur(), drop-shadow(), etc. Legitimate use: image processing, frosted glass.
backdrop-filter
Applies filter to content behind element. Legitimate use: glassmorphism, modal backdrops.
1. contain: paint — stacking context + paint boundary
contain: paint is the most surgical trigger: it establishes a new stacking context and clips all painting of descendants to the element's border box. An MCP server applies contain: paint to the install dialog container alongside an opaque background: #ffffff. The consent element inside gets position: relative; z-index: -1. The dialog's background layer renders over the consent. The consent has normal display, visibility, and opacity — every element-level check passes. The consent is not visible.
See the full attack breakdown (including CSS variable indirection and JS mousedown injection) in our contain:paint security reference. The key detection: check getComputedStyle(ancestor).contain for 'paint', 'strict', or 'content' on every ancestor of the consent element.
/* Attack: */
.mcp-dialog {
contain: paint; /* creates stacking context */
background: #ffffff; /* opaque — paints over z-index:-1 child */
}
.consent-disclosure {
position: relative;
z-index: -1; /* behind dialog background in dialog's local context */
}
2. isolation: isolate — explicit context, zero visual effect
isolation: isolate creates a stacking context whose only purpose is to isolate elements from mix-blend-mode composition. It has no visual side-effect of its own — no clipping, no opacity change, no transform. An element with isolation: isolate looks completely normal visually. Yet it creates a full stacking context, meaning a consent child with z-index: -1 inside an isolated ancestor will fall behind the ancestor's background layer.
This is the most invisible trigger on the list. getComputedStyle(ancestor).isolation returns 'isolate' — that is the only way to detect it without reading every element's CSS. See our CSS isolation:isolate security reference for the full variant breakdown.
/* Attack: */
.mcp-dialog {
isolation: isolate; /* stacking context with zero visual side-effect */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
/* display:block, visibility:visible, opacity:1 — ALL pass */
}
3. z-index on positioned elements — the baseline pattern
The foundational stacking context trigger: any element with position set to relative, absolute, fixed, or sticky combined with any z-index value other than auto creates a stacking context. This is the most common CSS pattern in existence — virtually every dialog, modal, or overlay in production code creates a stacking context this way, often unintentionally.
MCP servers exploit the ubiquity: the dialog container needs position: relative; z-index: 1 to appear above other page content, which is entirely standard. Adding a consent child with z-index: -1 then hides it behind the dialog's background. Any consent visibility scanner that doesn't check ancestor stacking contexts will call this layout safe.
/* This is standard dialog CSS — and also the attack: */
.mcp-dialog {
position: relative;
z-index: 10; /* stacking context created — completely standard */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1; /* hidden behind dialog background */
}
4. opacity < 1 — the loading-state disguise
Any element with opacity less than 1 creates a stacking context. An MCP server applies opacity: 0.98 to the dialog container — visually imperceptible, justified as a "loading fade-in" state. The consent child with z-index: -1 disappears behind the container's background. The dialog's own opacity of 0.98 does not affect the consent's visibility; the consent is invisible because of the stacking context order, not the opacity value itself.
A scanner that checks getComputedStyle(el).opacity on the consent element sees '1' (the consent's own opacity is normal). The detection requires checking for opacity < 1 on every ancestor. See CSS opacity consent bypass and the distinct opacity:inherit attack for related patterns.
/* Attack: */
.mcp-dialog {
opacity: 0.98; /* barely visible — creates stacking context */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
/* getComputedStyle(consent).opacity === '1' — does NOT inherit opacity for stacking */
/* The stacking context from opacity:0.98 on parent is the attack vector */
}
5. will-change (compositing) — the performance optimization cover
will-change: transform, will-change: opacity, and will-change: filter all promote the element to a GPU compositing layer and create a stacking context as a side effect. An MCP server applies will-change: transform to the dialog container — justified as a performance optimization for smooth animations. The resulting stacking context means a consent child with z-index: -1 renders behind the dialog's background.
Detection: check getComputedStyle(ancestor).willChange for values 'transform', 'opacity', 'filter', or 'scroll-position'. Any of these on an ancestor that has an opaque background, combined with z-index < 0 on the consent child, is a finding. See our will-change security reference for the dynamic injection variant.
/* Attack: */
.mcp-dialog {
will-change: transform; /* GPU promotion — creates stacking context */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
/* Hidden behind dialog background — "performance optimization" cover */
}
6. transform (non-none) — the transition animation disguise
Any non-none value for the transform property creates a stacking context. An MCP server applies transform: translateX(0) to the dialog container — no visual displacement, zero apparent effect, but a new stacking context is established. Alternatively, transform: scale(1), transform: rotate(0deg), or any identity transform creates the same stacking context while looking like a no-op or animation baseline.
This attack is also related to the individual CSS transform properties covered in CSS individual transform properties — the translate, rotate, and scale individual properties also create stacking contexts when set to non-initial values. Detection must check both transform and translate/rotate/scale individual properties. See CSS transform consent bypass for full variant breakdown.
/* Attack — identity transform, zero visual effect, creates stacking context: */
.mcp-dialog {
transform: translateX(0); /* zero displacement — but creates stacking context */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
/* Appears as animation baseline; stacking context is the actual attack */
}
7. mix-blend-mode — the overlay effect cover
Any mix-blend-mode value other than normal creates a stacking context. MCP servers apply blend modes to dialog containers for "frosted glass" or color overlay effects — mix-blend-mode: multiply darkens the dialog against the page, appearing as intentional design. The stacking context created by the blend mode means consent children with z-index: -1 render behind the dialog's background layer.
Detection: check getComputedStyle(ancestor).mixBlendMode — any non-'normal' value on an ancestor with an opaque background combined with a z-index < 0 consent child is a finding. See our mix-blend-mode security reference.
/* Attack: */
.mcp-dialog {
mix-blend-mode: multiply; /* non-normal blend mode — creates stacking context */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
}
8. filter (non-none) — the visual effect cover
Any filter property value other than none creates a stacking context. MCP servers apply subtle filter effects to dialog containers — filter: drop-shadow(0 2px 8px rgba(0,0,0,0.2)) for a card shadow, or filter: blur(0px) as a zero-radius blur that is visually indistinguishable from unfiltered but creates a stacking context. The consent child with z-index: -1 renders behind the dialog's background.
Detection: check getComputedStyle(ancestor).filter for any non-'none' value. Note that filter: blur(0px) returns 'blur(0px)', not 'none' — the zero-value variant is intentionally designed to be undetectable visually. See our CSS filter security reference.
/* Attack — zero-blur filter, invisible visual effect, stacking context: */
.mcp-dialog {
filter: blur(0px); /* renders identically to filter:none — creates stacking context */
background: #ffffff;
}
.consent-disclosure {
position: relative;
z-index: -1;
}
/* Variant — legit-looking shadow: */
.mcp-dialog {
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.15)); /* looks like CSS box-shadow */
background: #ffffff;
}
9. backdrop-filter — the glassmorphism cover
backdrop-filter applies filter effects to the content behind an element. Any non-none backdrop-filter value creates a stacking context. MCP servers apply glassmorphism effects — backdrop-filter: blur(10px) — to dialog containers for a frosted glass appearance over page content. This is a popular 2025–2026 design trend, making it an effective cover. The stacking context created by the backdrop filter hides consent children with z-index: -1.
Detection: check getComputedStyle(ancestor).backdropFilter for any non-'none' value. See our backdrop-filter security reference for the full variant breakdown including JS dynamic injection at mousedown.
/* Attack: */
.mcp-dialog {
backdrop-filter: blur(10px); /* glassmorphism — creates stacking context */
background: rgba(255,255,255,0.85); /* semi-opaque still hides z-index:-1 child */
}
.consent-disclosure {
position: relative;
z-index: -1;
/* Hidden by the dialog's own background layer within the stacking context */
}
The detection challenge: legitimacy and ancestor distance
Every property in the list above has a completely legitimate UI use. transform: translateX(0) on a dialog container is a common animation baseline. backdrop-filter: blur(10px) is a mainstream design trend. contain: paint is recommended for widget isolation. will-change: transform is a documented performance optimization. A scanner that flags any of these properties as suspicious will produce an unacceptable false-positive rate.
The real signal is the combination: stacking context creator on ancestor + opaque background on ancestor + z-index: -1 on consent child. No single property is malicious; it is the combination that creates the bypass. A consent element with z-index: -1 is nearly always suspicious — there is almost no legitimate reason to render a consent disclosure behind its parent container's background. That is the stable detection anchor.
Ancestor distance problem: The stacking context ancestor does not need to be the direct parent. An MCP server can place the stacking context trigger three levels up the DOM tree, making the connection between the trigger and the consent element harder to trace. The ConsentStackingAudit below walks the full ancestor chain, not just the immediate parent.
Unified ConsentStackingAudit class
The following detector walks the full ancestor chain for every consent element and checks all nine stacking context triggers. When a trigger is found on an ancestor that has an opaque background, and the consent element has a negative z-index, it reports a finding. It also simulates a mousedown event to catch JS-injected dynamic attacks.
class ConsentStackingAudit {
// Properties that create stacking contexts when set to specific values
static STACKING_CONTEXT_CHECKS = [
{
prop: 'contain',
label: 'contain:paint',
check: v => v.includes('paint') || v === 'strict' || v === 'content'
},
{
prop: 'isolation',
label: 'isolation:isolate',
check: v => v === 'isolate'
},
{
prop: 'opacity',
label: 'opacity<1',
check: v => parseFloat(v) < 1 && parseFloat(v) >= 0
},
{
prop: 'willChange',
label: 'will-change(compositing)',
check: v => ['transform','opacity','filter','scroll-position'].some(k => v.includes(k))
},
{
prop: 'transform',
label: 'transform(non-none)',
check: v => v !== 'none'
},
{
prop: 'translate',
label: 'CSS translate property',
check: v => v && v !== 'none'
},
{
prop: 'rotate',
label: 'CSS rotate property',
check: v => v && v !== 'none'
},
{
prop: 'scale',
label: 'CSS scale property',
check: v => v && v !== 'none'
},
{
prop: 'mixBlendMode',
label: 'mix-blend-mode',
check: v => v !== 'normal'
},
{
prop: 'filter',
label: 'filter(non-none)',
check: v => v !== 'none'
},
{
prop: 'backdropFilter',
label: 'backdrop-filter',
check: v => v !== 'none'
}
];
static isOpaqueBackground(bg) {
if (!bg) return false;
if (bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)') return false;
// rgba with alpha < 0.1 treated as transparent
const m = bg.match(/rgba\([^)]+,\s*([\d.]+)\)/);
if (m && parseFloat(m[1]) < 0.1) return false;
return true;
}
static checkConsentElement(el) {
const cs = window.getComputedStyle(el);
const zi = parseInt(cs.zIndex, 10);
if (isNaN(zi) || zi >= 0) return; // z-index must be negative
let ancestor = el.parentElement;
const findings = [];
while (ancestor && ancestor !== document.body) {
const acs = window.getComputedStyle(ancestor);
for (const { prop, label, check } of this.STACKING_CONTEXT_CHECKS) {
const val = acs[prop];
if (val && check(val)) {
const bg = acs.backgroundColor;
if (this.isOpaqueBackground(bg)) {
findings.push({
trigger: label,
prop,
value: val,
ancestor,
ancestorBackground: bg,
zIndex: zi
});
}
}
}
ancestor = ancestor.parentElement;
}
if (findings.length > 0) {
console.error('SA-STACKING: Stacking context ancestor hides z-index:-1 consent', {
consentEl: el,
zIndex: zi,
findings
});
}
}
static auditAll() {
const selectors = [
'.consent-disclosure', '[data-consent]',
'[aria-label*="consent"]', '[aria-label*="permission"]',
'[class*="consent"]', '[class*="permission"]'
];
const candidates = document.querySelectorAll(selectors.join(','));
candidates.forEach(el => this.checkConsentElement(el));
// Simulate mousedown on install button to catch dynamic attacks
const installBtn = document.querySelector(
'#install-btn, [data-action="install"], [aria-label*="Install"], button[class*="install"]'
);
if (installBtn) {
installBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
requestAnimationFrame(() => {
candidates.forEach(el => this.checkConsentElement(el));
});
}
// Watch for JS-injected stacking context triggers
document.querySelectorAll('[class*="dialog"], [class*="modal"], [class*="mcp"]').forEach(dialog => {
new MutationObserver(() => {
candidates.forEach(el => this.checkConsentElement(el));
}).observe(dialog, { attributes: true, attributeFilter: ['style', 'class'] });
});
}
}
// Run audit
ConsentStackingAudit.auditAll();
SkillAudit runs this ancestor chain check on every audit. Our scanner walks the full DOM ancestor chain of each detected consent element, checking all eleven stacking context triggers. Static code analysis is insufficient — many of these attacks are set in external stylesheets or injected at runtime via JS. The full audit requires a rendered DOM inspection in a headless browser environment. Free tier includes 3 audits/month for public repos.
Why this attack class persists
The CSS stacking context specification was not designed with consent element security in mind. The properties that create stacking contexts were designed for visual composition, animation, performance, and isolation — all entirely legitimate goals. The fact that they also establish a new local z-index scope is a side effect of the compositing model, not a primary feature.
MCP server authors exploiting these properties can point to legitimate uses in adjacent elements. A dialog with will-change: transform for smooth animation, backdrop-filter: blur(10px) for glassmorphism, and contain: paint for widget isolation is a pattern found in real production code. Adding a z-index: -1 on the consent child and an opaque background on the dialog — the actual attack — hides inside this legitimacy.
The only reliable defense is what SkillAudit's scanner does: check the consent element's z-index, walk the ancestor chain, and flag any stacking context creator + opaque background combination. A consent element with z-index: -1 has no legitimate use. That is the anchor that makes the combined check both sensitive and specific.
Related SkillAudit research
- CSS contain:paint — stacking context + paint boundary (4 attack variants)
- CSS isolation:isolate — explicit context, zero visual effect
- CSS mix-blend-mode — overlay effect stacking context
- CSS filter — zero-blur and drop-shadow stacking context attacks
- CSS backdrop-filter — glassmorphism stacking context attacks
- CSS will-change — compositing layer promotion attack
- CSS individual transform properties — translate/rotate/scale stacking context