Security reference · CSS injection · Stacking contexts · Isolation · Consent hiding
MCP server CSS isolation stacking context consent security
CSS stacking contexts determine which elements render above others. isolation: isolate, will-change: transform, and explicit z-index layering all create or manipulate stacking contexts — giving MCP servers mechanisms to suppress consent visibility without touching the consent element's own CSS properties. will-change: transform on an ancestor traps position: fixed consent inside a scrolling container. isolation: isolate combined with z-index mismatch layers the install button above a consent block that has no z-index context. A white cover element in the same stacking context obscures consent without affecting any property of the consent element itself. Four attack patterns: will-change-trapped fixed positioning, isolation-based z-index defeat, z-index staircase cover, and implicit stacking context scroll trap.
Stacking context creation: the hidden attack surface
| CSS trigger | Creates stacking context? | Consent attack vector |
|---|---|---|
isolation: isolate | Yes (explicit) | Isolates consent from page blend modes; z-index competition with install button |
will-change: transform | Yes (implicit) | Traps position:fixed descendants — consent scrolls with ancestor, not viewport |
transform: translateX(0) | Yes (implicit) | Same as will-change — position:fixed uses transformed ancestor as containing block |
position: relative; z-index: N | Yes | Cover layer with matching or higher z-index whiteouts consent in paint order |
opacity < 1 | Yes (implicit) | Creates stacking context; blend-mode attacks from outside cannot composite through |
Stacking context attacks change layout behavior, not element properties: The consent element retains display: block, visibility: visible, opacity: 1, and valid color contrast. The attack operates at the ancestor level — an element outside the consent subtree has a property that changes how the consent subtree is positioned, composited, or layered. Standard element-level CSS audits miss these patterns entirely.
Attack 1: will-change: transform traps position:fixed consent inside scrolling ancestor
CSS specifies that position: fixed positions an element relative to the viewport — but this rule has a critical exception. If any ancestor has transform, perspective, filter (non-none), will-change: transform, or will-change: perspective, that ancestor becomes the containing block for all position: fixed descendants instead of the viewport. An MCP server exploits this to make a "fixed" consent dialog scroll with its parent container:
/* Malicious CSS — SA-CSS-ISO-001 */
.mcp-install-dialog-wrapper {
/* This ancestor creates an implicit stacking context and new containing block
for position:fixed descendants — WITHOUT any visible effect on layout */
will-change: transform;
/* or equivalently: transform: translateX(0); */
/* or: filter: blur(0px); */
/* The dialog wrapper scrolls within the page */
overflow-y: auto;
max-height: 400px;
}
.mcp-consent-disclosure {
/* Intended: fixed to viewport so user always sees it */
/* Actual: fixed relative to .mcp-install-dialog-wrapper */
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* From user's perspective: the consent "sticks" briefly but scrolls away
when the dialog is scrolled, instead of remaining in viewport */
}
/* Detection: */
function detectWillChangeContainingBlock() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
if (getComputedStyle(el).position !== 'fixed') continue;
let ancestor = el.parentElement;
while (ancestor && ancestor !== document.body) {
const s = getComputedStyle(ancestor);
const wc = s.willChange || '';
const tf = s.transform;
if (wc.includes('transform') || wc.includes('perspective') ||
(tf && tf !== 'none') || s.filter !== 'none') {
findings.push({ id: 'SA-CSS-ISO-001', severity: 'high',
message: `Consent element uses position:fixed but ancestor "${ancestor.tagName}[class="${ancestor.className}"]" has "${wc || tf || s.filter}" which overrides the viewport as containing block. The "fixed" consent element scrolls with the ancestor instead of staying in the viewport.` });
break;
}
ancestor = ancestor.parentElement;
}
}
return findings;
}
Attack 2: isolation: isolate + z-index mismatch layers install button above consent
isolation: isolate creates a new stacking context without any visible side effects (no transform, no opacity change, no filter). It isolates an element's subtree from page-level blend modes. An MCP server uses this to create a controlled stacking arena where the install button has an explicit z-index: 2 within the isolated context and the consent block is placed at default stacking order — causing the install button to paint above the consent text:
/* Malicious CSS — SA-CSS-ISO-002 */
.mcp-install-panel {
/* Creates a new stacking context — all z-index values inside
are scoped to this context. Outside elements cannot interleave. */
isolation: isolate;
position: relative; /* needed to establish stacking context with isolation */
}
/* Install button: explicitly placed at z-index 2 */
.mcp-install-button {
position: relative;
z-index: 2;
/* Button is visually and interactively above consent text */
}
/* Semi-transparent cover between button and consent */
.mcp-button-glow {
position: absolute;
inset: -20px;
z-index: 1;
background: rgba(255, 255, 255, 0.85); /* near-opaque white */
/* This element is between z-index 0 (consent) and z-index 2 (button)
in the isolated stacking context — it whiteouts the consent below */
}
.mcp-consent-text {
/* No z-index — stacks at z-index: auto (below z-index: 1 cover) */
position: relative;
}
/* Result: install button (z:2) > white cover (z:1) > consent text (z:auto)
Consent text is covered by the white cover but still present in DOM */
Attack 3: z-index cover layer whiteout — consent beneath a same-context white element
Without any isolation tricks, a simple absolutely-positioned white element placed after the consent in DOM order (later siblings paint above earlier in the same stacking context) can cover the consent text. The cover has a transparent background on the page but a solid white fill over the consent area:
/* Malicious CSS — SA-CSS-ISO-003 */
.mcp-dialog {
position: relative; /* stacking context for positioned descendants */
}
/* Consent text — appears first in DOM */
.mcp-consent-text {
position: relative; /* participates in stacking order */
z-index: 0;
/* Contains full consent disclosure text */
}
/* White cover — appears AFTER consent in DOM; later = higher paint order */
.mcp-dialog-frame {
position: absolute;
/* Covers exactly the consent area */
top: 0; left: 0; right: 0;
height: 60px; /* height of consent block */
background: #ffffff;
z-index: 1; /* explicitly above consent z-index: 0 */
/* The cover element is "part of the dialog design" — looks like
a decorative frame or header strip in isolation, but functionally
it whiteouts the consent area */
}
/* Install button is ABOVE the cover — user sees button above white space */
.mcp-install-btn {
position: relative;
z-index: 2;
}
/* Detection: look for positioned elements with solid/near-solid white backgrounds
that overlap consent text bounding boxes */
function detectZIndexCover() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
const consentEls = [];
for (const el of document.querySelectorAll('*')) {
if (CONSENT.test(el.textContent?.substring(0, 300) || '')) consentEls.push(el);
}
for (const ce of consentEls) {
const cr = ce.getBoundingClientRect();
const stack = document.elementsFromPoint(cr.left + cr.width/2, cr.top + cr.height/2);
for (const over of stack) {
if (over === ce) break; /* only check elements above consent */
const s = getComputedStyle(over);
const bg = s.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
findings.push({ id: 'SA-CSS-ISO-003', severity: 'high',
message: `Element "${over.tagName}" with background "${bg}" overlays the consent area via z-index stacking order. Consent text may be covered.` });
}
}
}
return findings;
}
Attack 4: implicit stacking context via opacity — scroll trap for consent-in-modal
An ancestor with opacity slightly below 1 (e.g., opacity: 0.9999) creates an implicit stacking context and acts as a containing block for positioned descendants. Combined with overflow: hidden and a fixed pixel height that is smaller than the consent content, this creates a modal where the consent is present in the DOM but permanently clipped by the stacking-context container:
/* Malicious CSS — SA-CSS-ISO-004 */
.mcp-install-modal {
/* opacity just below 1 — creates implicit stacking context AND
containing block for position:absolute/fixed descendants */
opacity: 0.9999;
/* Visually: completely indistinguishable from opacity:1 */
/* Fixed height that clips consent but shows install button */
overflow: hidden;
height: 120px; /* install button fits; consent below the fold */
position: relative;
}
.mcp-consent-disclosure {
/* Positioned relative to .mcp-install-modal (not viewport)
because of the opacity stacking context */
position: absolute;
top: 80px; /* starts below the 120px clip boundary */
}
/* From a pure DOM check: the consent element exists, has correct text,
and has display:block — but it's clipped by the ancestor height.
getBoundingClientRect() for the consent element returns a rect
that is partially or fully outside the visible modal area. */
Stacking context attacks require ancestor-level inspection: An element-focused audit checks the consent element's own CSS properties: color, opacity, visibility, display, transform. None of these reveal stacking context attacks, which operate at the ancestor level. The containing-block check (does any ancestor have will-change: transform, transform: non-none, filter: non-none, or opacity < 1?), the z-index cover check (do any elements overlap the consent bounding box?), and the isolation check (getComputedStyle(ancestor).isolation === 'isolate') must all be performed explicitly by walking the ancestor chain.
SkillAudit findings for CSS stacking context consent attacks
position: fixed but an ancestor has will-change: transform, transform, perspective, or filter set, overriding the viewport as the containing block. The consent does not remain fixed to the viewport — it scrolls with the ancestor. This is a commonly missed CSS gotcha that MCP servers can exploit deliberately.isolation: isolate creating a scoped stacking context. An element within this context with a higher z-index (install button or white cover) paints above the consent text. The consent text is present and readable in the DOM but visually covered in the rendered stacking order.opacity: 0.9999 (or another near-1 value) creating an implicit stacking context with overflow: hidden clip. The consent element is positioned within this context and falls outside the visible clip area. Visually imperceptible from opacity: 1 but changes containment behavior.Related MCP consent attack research
- CSS will-change consent attacks — implicit stacking context and containing block traps
- CSS mix-blend-mode attacks — blend mode consent text color collapse
- CSS z-index attacks — layering install button above consent
- CSS perspective/3D transform attacks — scale and project consent off-screen
- CSS filter effects as a consent bypass vector — hue, luminance, and blend mode attacks
SkillAudit's consent audit walks every ancestor of consent-text elements checking for stacking context triggers (isolation, will-change, transform, sub-1 opacity) and performs an overlap check against all rendered elements at the consent bounding box coordinates. This catches SA-CSS-ISO stacking-context attacks that are completely invisible to element-focused CSS property checks. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-ISO findings.