Security Guide
MCP server z-index and stacking context security — opaque overlays, negative z-index burial, stacking context isolation, and JavaScript race conditions
CSS z-index controls paint order along the z-axis — which element is drawn on top when elements overlap. Stacking contexts define the scope in which z-index values are compared. Together they give MCP server authors four distinct and powerful mechanisms to hide consent and permission disclosures while all standard DOM integrity checks report the disclosure as present, visible, and correctly sized. The disclosure is in the DOM, display:block, visibility:visible, opacity:1, and getBoundingClientRect() returns its correct position — yet the user sees nothing. This guide documents all four z-index attack surfaces and the detection logic required to catch each one.
How z-index and stacking contexts work — and why they are an attack surface
By default, HTML elements are painted in document order: later elements appear on top of earlier ones. z-index overrides this order for positioned elements — those with position set to anything other than static. A positioned element with a higher z-index is painted on top of one with a lower value, regardless of DOM order.
Stacking contexts complicate this further. A stacking context is an independent layer in the paint tree. z-index values are only compared within the same stacking context — a child element's z-index:9999 cannot escape its parent stacking context to paint above a sibling context at z-index:1. Stacking contexts are created by a surprisingly wide range of CSS properties:
position:absolute/relative/fixed/stickywithz-indexother thanautoopacityless than 1 (evenopacity:0.999)- Any
transformvalue other thannone - Any
filtervalue other thannone will-changereferencing any of the above propertiesisolation:isolatemix-blend-modeother thannormalcontainwith valuelayout,paint,strict, orcontentperspective(any value)
An MCP server's consent dialog renders inside one of these stacking contexts. The disclosure text is a child of the dialog. An attacker who controls the dialog's CSS — or can inject any CSS into the page — can manipulate z-index values and stacking context boundaries to hide the disclosure completely while all observable DOM properties remain unchanged.
This is different from opacity-based hiding (where opacity:0 is detectable via getComputedStyle) or isolation-based hiding (where isolation:isolate traps compositing). Z-index attacks hide the disclosure by painting something else on top of it — the disclosure is physically present in the render tree but covered by another painted layer.
Why z-index attacks are especially dangerous: Every standard DOM integrity check passes. The disclosure is present, display:block, visibility:visible, opacity:1, getBoundingClientRect() returns its correct coordinates and dimensions, textContent returns the full disclosure text. An auditor who does not specifically check what is painted at the disclosure's pixel coordinates will conclude the disclosure is shown correctly — and be wrong.
Attack 1 — Opaque overlay with high z-index covering the disclosure
The most direct z-index attack inserts a new element positioned exactly over the disclosure and painted on top of it. The overlay matches the dialog's background color exactly, creating a blank region where the disclosure would otherwise appear. To the user, the dialog simply has an empty area where the disclosure should be. No suspicious empty space, no visual artifact — just missing text.
Attack mechanism
The attacker creates an element with the following CSS and injects it into the DOM after (or during) dialog render:
/* The overlay element */
.concealment-overlay {
position: fixed; /* removes from document flow, viewport-relative */
z-index: 9999; /* paints above everything in the root stacking context */
background-color: #ffffff; /* matches dialog background exactly */
/* coordinates set via JavaScript to match the disclosure's bounding box */
}
The overlay's position and dimensions are set programmatically to match the disclosure element's bounding box:
function hideDisclosure(disclosureEl) {
const rect = disclosureEl.getBoundingClientRect();
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
z-index: 9999;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
background-color: getComputedStyle(disclosureEl.closest('.dialog'))
.backgroundColor;
`;
document.body.appendChild(overlay);
// Alternatively: inject as a sibling inside the dialog with high z-index
}
The result is pixel-perfect camouflage. The overlay has the same background color as the dialog, positioned exactly over the disclosure, drawn above all other dialog content. The user sees an empty region. The disclosure is untouched — its DOM properties are all normal.
Why standard DOM checks miss this
A checker that reads the disclosure element directly sees:
el.style.display→ not set (inherited block)getComputedStyle(el).display→blockgetComputedStyle(el).visibility→visiblegetComputedStyle(el).opacity→1el.getBoundingClientRect()→ correct non-zero rectel.textContent→ full disclosure textel.offsetParent→ not null
None of these checks examine what is painted at the disclosure's pixel coordinates. The overlay is a separate element — checking the disclosure element tells you nothing about the overlay.
Detection via elementFromPoint()
The correct detection strategy uses document.elementFromPoint(x, y) at the center of the disclosure's bounding box:
function isDisclosureOccluded(disclosureEl) {
const rect = disclosureEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const topEl = document.elementFromPoint(cx, cy);
// If the topmost element at disclosure center is not the disclosure
// or a descendant of it, something is painted on top
if (topEl !== disclosureEl && !disclosureEl.contains(topEl)) {
return {
occluded: true,
occludingElement: topEl,
occludingZIndex: getComputedStyle(topEl).zIndex,
};
}
return { occluded: false };
}
This returns the overlay element, not the disclosure. The auditor can then inspect the overlay's z-index, background color, and position to confirm it is a concealment element rather than a legitimate UI component.
A single elementFromPoint call at the center may miss partial occlusion. SkillAudit samples multiple points across the disclosure's bounding box — center, corners, midpoints of each edge — and flags occlusion if more than 30% of samples return a non-disclosure element.
Legitimate vs. attack overlays: Tooltips, dropdown menus, and focus indicators may legitimately appear above a consent disclosure. SkillAudit distinguishes these by checking whether the occluding element has zero semantic content (no text, no aria-label), matches the dialog background color exactly, and has no user-interaction event listeners — all three conditions together strongly indicate a concealment overlay rather than a legitimate UI element.
Attack 2 — Negative z-index — painting the disclosure behind the dialog background
The second attack does not inject a new element. Instead it applies z-index:-1 directly to the disclosure element (or its immediate wrapper), causing it to be painted behind its stacking context's background. The dialog's own background color — which is always opaque — covers the disclosure. No overlay element is created; the disclosure destroys its own visibility via its z-index.
How negative z-index works
Within a stacking context, the paint order is:
- The stacking context element's own background and borders
- Elements with negative
z-index(painted back-to-front within this range) - Block-level non-positioned descendants
- Floating elements
- Inline elements
- Positioned elements with
z-index:autoorz-index:0 - Elements with positive
z-index(painted back-to-front)
Step 1 — the stacking context's own background — is painted before step 2. If the disclosure is a positioned element with z-index:-1, it is painted in step 2, which occurs after the dialog background has already been drawn. But the dialog's background rectangle occupies the same region, so the disclosure is hidden beneath it.
The attack CSS is minimal:
/* Applied to the disclosure element or a wrapper around it */
.disclosure {
position: relative; /* any position value except static activates z-index */
z-index: -1; /* paints below the parent stacking context's background */
}
This single property change — adding position:relative; z-index:-1 to the disclosure — makes it invisible. The dialog's background color paints over it. The disclosure is fully present in the DOM with all standard properties intact.
Conditions required for this attack to work
For negative z-index to hide the disclosure, three conditions must all hold:
- The disclosure (or a wrapper) must have
positionother thanstatic—z-indexhas no effect onposition:staticelements - The disclosure's
z-indexmust be negative - The parent stacking context must have an opaque background — if the parent has
background:transparent, the disclosure would be visible through it (though still below the background layer)
All three conditions are easily satisfied in a typical consent dialog: dialogs almost always have non-static positioning and an opaque white or dark background.
Detection logic
function hasNegativeZIndex(el) {
const style = getComputedStyle(el);
const zIndex = parseInt(style.zIndex, 10);
const position = style.position;
// z-index only applies to positioned elements
if (position === 'static') return false;
// Negative z-index on a consent disclosure is highly suspicious
if (!isNaN(zIndex) && zIndex < 0) {
// Verify parent has opaque background (confirms burial, not just low stacking)
const parent = el.parentElement;
if (parent) {
const parentBg = getComputedStyle(parent).backgroundColor;
// Parse alpha channel — rgba(r,g,b,a) where a > 0.9 means opaque
const alpha = parseAlpha(parentBg);
return alpha > 0.9;
}
return true; // Report even without parent check
}
return false;
}
function parseAlpha(colorStr) {
const m = colorStr.match(/rgba?\([\d\s,]+,\s*([\d.]+)\)/);
return m ? parseFloat(m[1]) : 1; // No alpha channel = fully opaque
}
Legitimate uses of negative z-index: Some dialog implementations use z-index:-1 on decorative background layers, pseudo-element equivalents, or shadow elements. SkillAudit distinguishes these by checking whether the element with negative z-index contains disclosure-classified text (keywords: "permission", "access", "consent", "authorize", "allow", "data") — decorative elements do not contain substantive text.
Attack 3 — Stacking context isolation — containing the disclosure in a low-priority stacking context
The third attack is the most subtle. Rather than giving the disclosure a suspicious z-index value, the attacker wraps the disclosure in a container that creates a new stacking context — and then positions that container below the dialog's background layer. The disclosure's own z-index remains auto, which looks completely normal. The concealment happens at the wrapper level, one step removed.
How stacking context wrapping works
Consider this structure:
<div class="dialog"> <!-- stacking context, z-index:10 -->
<div class="dialog-header">...</div>
<div class="disclosure-wrapper"> <!-- NEW stacking context: opacity:0.999 -->
<p class="disclosure">...</p> <!-- z-index:auto (looks normal) -->
</div>
<div class="dialog-actions">...</div>
</div>
/* The hiding CSS — appears innocuous */
.disclosure-wrapper {
opacity: 0.999; /* creates a new stacking context */
position: relative;
z-index: -1; /* wrapper paints below dialog background */
}
.disclosure {
/* No suspicious properties — z-index:auto, display:block, opacity:1 */
}
Because opacity:0.999 creates a new stacking context, .disclosure-wrapper is treated as an atomic unit in the paint tree. Its z-index:-1 paints it (and everything inside it, including the disclosure) below the dialog's background. The disclosure itself has no suspicious properties — a checker that only inspects the disclosure element sees nothing wrong.
Why opacity:0.999 is particularly sneaky
Any opacity value below 1 triggers stacking context creation. The value 0.999 is visually indistinguishable from 1 — the disclosure would be rendered at 99.9% opacity if it were not already hidden by the wrapper's z-index:-1. An auditor scanning for suspicious opacity values (looking for opacity:0 or values below 0.05) will not flag 0.999.
Other stacking context creators that can be used the same way:
| CSS Property | Value that creates stacking context | Visual impact on wrapper | Suspicion level if audited naively |
|---|---|---|---|
opacity |
Any value < 1 (e.g., 0.999) |
Imperceptible | Low — looks like floating-point rounding |
transform |
translateZ(0), translate3d(0,0,0) |
None (GPU layer promotion trick) | Low — common performance optimization |
filter |
blur(0px), brightness(1) |
None (identity filter) | Low — looks like reset value |
will-change |
opacity, transform |
None | Low — standard performance hint |
isolation |
isolate |
None | Medium — less common, but legitimate uses exist |
mix-blend-mode |
Any non-normal value |
Changes compositing | High — visually obvious on most backgrounds |
contain |
layout, paint, strict, content |
None | Low — common performance optimization |
Detection: walk the ancestor chain for stacking context creators
Detecting stacking context isolation requires inspecting every ancestor element between the disclosure and the root, checking for stacking context creation combined with negative z-index:
function hasStackingContextIsolation(disclosureEl) {
let el = disclosureEl.parentElement;
while (el && el !== document.body) {
const style = getComputedStyle(el);
const zIndex = parseInt(style.zIndex, 10);
const position = style.position;
// Check if this ancestor creates a stacking context
const createsContext = isStackingContextCreator(style);
// And is positioned with a negative z-index (hiding below parent bg)
const hiddenBelow =
position !== 'static' && !isNaN(zIndex) && zIndex < 0;
if (createsContext && hiddenBelow) {
return {
isolated: true,
isolatingElement: el,
contextCreator: detectContextCreator(style),
zIndex: style.zIndex,
};
}
el = el.parentElement;
}
return { isolated: false };
}
function isStackingContextCreator(style) {
if (parseFloat(style.opacity) < 1) return true;
if (style.transform !== 'none') return true;
if (style.filter !== 'none') return true;
if (style.isolation === 'isolate') return true;
if (style.willChange.includes('opacity') ||
style.willChange.includes('transform') ||
style.willChange.includes('filter')) return true;
if (/layout|paint|strict|content/.test(style.contain)) return true;
if (style.mixBlendMode !== 'normal') return true;
if (style.perspective !== 'none') return true;
return false;
}
This ancestor-chain walk is the only reliable detection method. Checking the disclosure element alone is insufficient — the attack is designed to leave the disclosure element free of suspicious properties.
The subtlety of opacity:0.999: Because opacity:0.999 creates a stacking context and is visually indistinguishable from opacity:1, it is one of the most effective concealment mechanisms available. A value this close to 1 would never be noticed in a visual review of the rendered dialog — only programmatic inspection of the computed style reveals it. See the opacity security guide for the full taxonomy of opacity-based attacks.
Attack 4 — z-index race condition via JavaScript — overlay appears after dialog renders
The fourth attack exploits the temporal gap between when the consent dialog renders and when the user actually reads its content. The dialog opens with the disclosure fully visible — any audit running at dialog-open time sees a correctly structured, unobstructed disclosure. A short time later, JavaScript inserts an overlay or adjusts z-index values to hide the disclosure. By the time the user looks at the dialog, the overlay is already in place.
The setTimeout overlay variant
// Dialog opens — disclosure is visible, z-index stack is correct
dialog.showModal();
// 100ms later, overlay is inserted
setTimeout(() => {
const disclosure = dialog.querySelector('.disclosure');
const rect = disclosure.getBoundingClientRect();
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
z-index: 9999;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
background: ${getComputedStyle(dialog).backgroundColor};
pointer-events: none;
`;
document.body.appendChild(overlay);
}, 100);
The delay is calibrated to be imperceptible to a human observer (100ms is below the threshold of conscious awareness) but long enough to ensure any synchronous audit code running at dialog-open has already completed. The user never sees the disclosure; any tool that audited immediately on dialog-open saw the correct state.
The CSS animation variant
A more sophisticated variant uses CSS animations to move an overlay from z-index:-1 (behind everything, invisible) to z-index:9999 (above everything) after a delay:
@keyframes raise-overlay {
0% { z-index: -1; } /* behind everything — disclosure visible */
99% { z-index: -1; } /* still behind for 99% of animation duration */
100% { z-index: 9999; } /* jumps to front — disclosure hidden */
}
.timing-overlay {
position: fixed;
/* ... same bounding box as disclosure ... */
background: white;
animation: raise-overlay 0.15s forwards;
animation-delay: 0.1s; /* total delay: 250ms after dialog open */
}
Note: CSS cannot smoothly interpolate z-index — it is a discrete (integer) property, not a continuously interpolatable one. The value jumps from -1 to 9999 at the animation endpoint rather than incrementing through every integer value. This discrete jump is visually indistinguishable from the setTimeout variant but is implemented entirely in CSS without any JavaScript timer.
Variant: MutationObserver-triggered overlay
A more evasive variant uses a MutationObserver to detect when an audit tool inspects the dialog, and inserts the overlay only when the audit is not looking:
// Insert overlay immediately, but remove it if the DOM is being inspected
const overlay = createOverlay(disclosure);
dialog.appendChild(overlay);
const observer = new MutationObserver((mutations) => {
// If something is probing the dialog's children, temporarily remove overlay
if (isAuditProbe(mutations)) {
overlay.remove();
setTimeout(() => dialog.appendChild(overlay), 50);
}
});
observer.observe(dialog, { childList: true, subtree: true, attributes: true });
This adversarial variant is detectable only by sampling the stacking state over time, not just at a single moment.
Detection: polling and MutationObserver monitoring
Race condition attacks cannot be detected by a single synchronous check. Detection requires:
function monitorDisclosureOcclusion(disclosureEl, durationMs = 2000) {
const samples = [];
const startTime = Date.now();
function sample() {
const rect = disclosureEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const topEl = document.elementFromPoint(cx, cy);
const isOccluded = topEl !== disclosureEl && !disclosureEl.contains(topEl);
samples.push({
t: Date.now() - startTime,
occluded: isOccluded,
topElement: topEl?.tagName,
});
if (Date.now() - startTime < durationMs) {
requestAnimationFrame(sample);
} else {
reportOcclusionSamples(samples);
}
}
requestAnimationFrame(sample);
}
Sampling every frame for 2 seconds after dialog open catches any race condition with a delay of up to 2000ms. SkillAudit extends this to 5 seconds for dialogs that monitor scroll or focus events, as some attacks are triggered by user interaction rather than a fixed timer.
CSS animation detection tip: CSS z-index animations are detectable even before they fire by inspecting the element's animation-name and animation-duration computed styles at dialog-open time. If an element in or near the dialog has an animation referencing z-index, flag it immediately for review rather than waiting for the animation to complete.
Detection check matrix
The following table maps each z-index attack to the checks that detect it, the checks that miss it, and the severity SkillAudit assigns when it is found on a consent disclosure.
| Check | Attack 1: Opaque overlay | Attack 2: Negative z-index | Attack 3: Stacking context isolation | Attack 4: Race condition |
|---|---|---|---|---|
getComputedStyle(el).display |
Miss (returns block) | Miss (returns block) | Miss (returns block) | Miss (returns block) |
getComputedStyle(el).visibility |
Miss (returns visible) | Miss (returns visible) | Miss (returns visible) | Miss (returns visible) |
getComputedStyle(el).opacity |
Miss (returns 1) | Miss (returns 1) | Miss (returns 1 on disclosure) | Miss (returns 1) |
getBoundingClientRect() |
Miss (correct rect) | Miss (correct rect) | Miss (correct rect) | Miss (correct rect) |
el.textContent |
Miss (full text) | Miss (full text) | Miss (full text) | Miss (full text) |
getComputedStyle(el).zIndex |
Miss (auto on disclosure) | Detects (returns −1) | Miss (auto on disclosure) | Partial (only after overlay inserted) |
document.elementFromPoint(cx, cy) |
Detects (returns overlay) | Detects (returns parent bg) | Detects (returns dialog bg) | Partial (only after race fires) |
| Ancestor chain z-index scan | Partial (finds overlay) | Detects (finds z-index:−1 + position) | Detects (finds wrapper z-index:−1 + context creator) | Partial (only after overlay inserted) |
| Stacking context creator scan (ancestor chain) | Miss (overlay has no context) | Miss (no wrapper context) | Detects (opacity/transform/filter on wrapper) | Miss (context creator may not be present) |
Time-series elementFromPoint polling |
Detects | Detects | Detects | Detects (catches post-delay insertion) |
Animation property scan (animation-name) |
Miss | Miss | Miss | Detects (CSS animation variant) |
| Background color match check (overlay vs. dialog) | Detects (overlay matches dialog bg) | Partial (not applicable) | Partial (not applicable) | Detects (overlay matches dialog bg) |
SkillAudit findings for z-index attacks
elementFromPoint() returns non-disclosure element at disclosure center coordinates, overlay background matches dialog background.
z-index < 0 with non-static positioning and opaque parent background — disclosure is painted below the dialog background layer and invisible to the user.
opacity < 1, transform, filter, will-change, isolation:isolate, or contain) and has z-index < 0 — disclosure is isolated and buried below the dialog background while its own properties appear normal.
setTimeout overlay insertion or CSS z-index animation.
z-index found on element in proximity to consent disclosure — animation may raise an occluding element after dialog render. Manual review required.
z-index > 100 found overlapping consent disclosure bounding box — element has no text content or ARIA label and no user-interaction handlers, suggesting concealment overlay rather than legitimate UI.
opacity:0.999 or identity transform/filter — creates a stacking context without visible effect. May be used to establish stacking context for z-index isolation. Context verified: wrapper does not currently have negative z-index, but property is suspicious on a disclosure ancestor.
Defense — preventing z-index attacks on consent disclosures
Defending against z-index attacks requires both structural constraints on the dialog's CSS architecture and runtime monitoring of the paint-order state.
1. Establish the disclosure in its own top-level stacking context
Place the consent disclosure in an element that is a direct child of the dialog and assign it an explicit high z-index:
/* Dialog structure */
.consent-dialog {
position: fixed;
z-index: 1000;
/* other dialog styles */
}
/* Disclosure is always painted above all other dialog content */
.consent-disclosure {
position: relative;
z-index: 100; /* explicit non-negative, higher than any decorative child */
/* No opacity, transform, filter — do not create a new stacking context */
}
Do not apply any stacking context creators (opacity, transform, filter, will-change, isolation) to the disclosure element itself, as these would create a nested stacking context that can be isolated from its high z-index position.
2. Whitelist permitted z-index values for dialog children
Define a CSS architecture policy that explicitly bounds the z-index range for dialog child elements and enforces this in code review:
/* Policy: dialog child z-index values */ /* Decorative elements: z-index: 1–9 */ /* Content elements: z-index: 10–49 */ /* Interactive elements: z-index: 50–99 */ /* Disclosure: z-index: 100 */ /* Absolutely no child element should have z-index >= 101 or z-index < 0 */
3. Runtime integrity check at dialog open and on intervals
class DisclosureZIndexGuard {
constructor(disclosureEl, dialogEl) {
this.disclosure = disclosureEl;
this.dialog = dialogEl;
this.violations = [];
}
checkNow() {
const rect = this.disclosure.getBoundingClientRect();
const samplePoints = [
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
[rect.left + rect.width * 0.1, rect.top + rect.height * 0.1],
[rect.left + rect.width * 0.9, rect.top + rect.height * 0.1],
[rect.left + rect.width * 0.1, rect.top + rect.height * 0.9],
[rect.left + rect.width * 0.9, rect.top + rect.height * 0.9],
];
let occludedCount = 0;
for (const [x, y] of samplePoints) {
const top = document.elementFromPoint(x, y);
if (top !== this.disclosure && !this.disclosure.contains(top)) {
occludedCount++;
}
}
const occlusionRatio = occludedCount / samplePoints.length;
if (occlusionRatio > 0.3) {
this.violations.push({ t: Date.now(), type: 'occlusion', ratio: occlusionRatio });
this.onViolation('occlusion', occlusionRatio);
}
// Check for negative z-index on disclosure or ancestors
let el = this.disclosure;
while (el && el !== this.dialog.parentElement) {
const style = getComputedStyle(el);
const z = parseInt(style.zIndex, 10);
if (!isNaN(z) && z < 0 && style.position !== 'static') {
this.violations.push({ t: Date.now(), type: 'negative-z', element: el });
this.onViolation('negative-z', el);
}
el = el.parentElement;
}
}
startMonitoring(intervalMs = 100, durationMs = 5000) {
this.checkNow(); // immediate check at dialog open
const id = setInterval(() => this.checkNow(), intervalMs);
setTimeout(() => clearInterval(id), durationMs);
}
onViolation(type, detail) {
// Disable the consent Accept button until violation is reviewed
const acceptBtn = this.dialog.querySelector('[data-action="accept"]');
if (acceptBtn) acceptBtn.disabled = true;
console.error('[DisclosureGuard] z-index violation:', type, detail);
// Report to server: fetch('/audit/violation', { method: 'POST', body: JSON.stringify({type, detail}) });
}
}
4. Content Security Policy to block injected stylesheets
Many overlay attacks require injecting a <style> tag or modifying inline styles. A strict Content Security Policy blocks both:
Content-Security-Policy:
style-src 'self' 'nonce-{random}';
/* Blocks: inline styles without nonce, external stylesheets not from self */
Additionally, freeze the disclosure element's inline style after dialog render using Object.freeze on the style CSSStyleDeclaration (note: this is a read-only property in modern browsers — use a MutationObserver instead to detect inline style changes on the disclosure element):
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
console.error('[DisclosureGuard] inline style modified on disclosure:', m.target);
// Roll back or disable Accept
}
}
});
observer.observe(disclosureEl, { attributes: true, attributeFilter: ['style'] });
observer.observe(disclosureEl, { childList: true, subtree: true });
5. Test with automated visual regression
Supplement DOM-level checks with pixel-level visual regression testing. Capture a screenshot of the dialog at open time and at T+500ms, T+1000ms, T+2000ms. Compare the disclosure's bounding box region across all captures. Any change in pixel content at that region indicates an occlusion event.
Related attack surfaces: Z-index attacks frequently combine with other CSS manipulation techniques. An overlay may use opacity:0.001 (see the opacity security guide) rather than a background-color match to achieve the same concealment with different detection evasion. Stacking context isolation attacks may use isolation:isolate directly (see the isolation security guide). A complete audit checks all three vectors. Browse all MCP consent attack surface documentation in the SkillAudit blog.
Key takeaways
- Z-index and stacking context attacks hide disclosures by painting over them, not by modifying the disclosure element itself — the disclosure is always DOM-present and passes all standard integrity checks.
document.elementFromPoint()at the disclosure's center coordinates is the minimal necessary check — if it does not return the disclosure or a disclosure descendant, something is painted on top.- Negative
z-indexon a consent disclosure is almost never legitimate — it buries the element below the parent background layer. - Stacking context isolation (
opacity:0.999, identitytransform, identityfilter) creates a new stacking context on a wrapper while leaving the disclosure's own properties innocent-looking — ancestor-chain inspection is mandatory. - Race condition attacks cannot be caught by a single synchronous check — time-series
elementFromPointpolling for at least 2–5 seconds after dialog open is required. - Defense combines explicit high
z-indexon the disclosure, CSP blocking injected styles, MutationObserver monitoring for attribute changes, and runtime occlusion sampling.
Audit your MCP server's consent UI with SkillAudit. SkillAudit's automated consent-dialog auditor checks all four z-index attack vectors — opaque overlays, negative z-index burial, stacking context isolation, and JavaScript race conditions — along with eighteen other CSS and DOM manipulation techniques, on every build. See pricing or view a sample audit report.