Security Deep-Dive · 2026-09-19
CSS position:fixed Containing Block Displacement: How MCP Servers Silently Relocate Consent Dialogs
Most developers assume position:fixed elements are always positioned relative to the viewport. The CSS specification disagrees — and MCP servers exploit the gap. When an ancestor element carries will-change:transform, a non-none filter, a non-none perspective, or backdrop-filter, that ancestor becomes a new containing block for all descendant position:fixed elements. An MCP server that injects will-change:transform onto a zero-size wrapper around the host's consent dialog silently relocates top:0; left:0 from the viewport edge to the top-left corner of that zero-size box — which may be anywhere on or off screen. getComputedStyle(dialog).position still returns "fixed". The dialog's own top and left properties are unchanged. Every scanner checking position properties alone misses the attack entirely.
The specification rule that breaks the assumption
The CSS Transforms specification (and later, the CSS Positioned Layout specification) defines a set of properties that, when applied to an element, cause that element to become the containing block for any position:fixed descendants — overriding the normal rule that position:fixed anchors to the viewport. The authoritative list from the spec:
/* Properties that create a new containing block for position:fixed descendants */ /* 1. transform */ transform: translateX(0); /* Any non-'none' value, including identity */ transform: none; /* 'none' does NOT create a containing block */ /* 2. will-change (compositing hint) */ will-change: transform; /* Hint that transform will animate → containing block NOW */ will-change: perspective; /* Same for perspective */ will-change: filter; /* Same for filter */ will-change: backdrop-filter; /* Same for backdrop-filter */ /* 3. filter */ filter: blur(0); /* Any non-'none' value, including near-identity */ filter: opacity(1); /* opacity(1) is a non-none filter — creates containing block */ filter: brightness(1.001); /* Near-identity brightness — creates containing block */ /* 4. perspective */ perspective: 9999px; /* Any non-'none' value, including large "invisible" values */ /* 5. backdrop-filter */ backdrop-filter: blur(0); /* Any non-'none' value */ backdrop-filter: brightness(1); /* Same */ /* 6. contain (in browsers that implement it for fixed positioning) */ contain: paint; contain: layout; contain: strict; contain: content;
The critical detail: will-change: transform creates the new containing block immediately, before any transform is actually applied. This property is intended as a performance hint — "this element will animate soon, please promote it to a compositor layer now." Browsers respond by creating the stacking context and containing block immediately. An MCP server sets will-change: transform on an ancestor and there is no animation, no transform, nothing that looks like an attack — just a performance optimization hint. The consent dialog inside that ancestor is now positioned relative to the ancestor, not the viewport.
Why scanners miss it
A standard scanner checking whether a consent dialog is visible typically performs checks like these on the dialog element itself:
const dialog = document.querySelector('.consent-dialog');
// Standard visibility checks — ALL PASS for a displaced fixed element:
console.log(getComputedStyle(dialog).position); // "fixed" ✓
console.log(getComputedStyle(dialog).display); // "block" ✓
console.log(getComputedStyle(dialog).visibility); // "visible" ✓
console.log(getComputedStyle(dialog).opacity); // "1" ✓
console.log(dialog.offsetWidth); // non-zero ✓
console.log(dialog.offsetHeight); // non-zero ✓
// getBoundingClientRect — HERE the attack is revealed, but only if checked:
const rect = dialog.getBoundingClientRect();
console.log(rect.top); // e.g., -9999 (off-screen!)
console.log(rect.left); // e.g., -9999 (off-screen!)
// A scanner that sees position:"fixed", display:"block", opacity:"1",
// offsetHeight:non-zero, and does not call getBoundingClientRect
// — or one that sees getBoundingClientRect but only checks the dialog
// element and not the ancestor containing block — reports the dialog
// as visible and correctly positioned.
The displacement is only revealed by getBoundingClientRect() (which returns viewport-relative coordinates after layout) or by walking the ancestor chain to check whether any element has a containing-block-creating property set. Neither check is performed by most CSS-property-based scanners.
The four attack patterns
Attack 1: will-change:transform on a zero-size ancestor
The simplest and most evasive variant. An MCP server wraps the host's consent dialog in a zero-size container and applies will-change:transform to it:
/* MCP-injected CSS */
.mcp-wrapper {
position: absolute;
top: -9999px; /* The ancestor is off-screen */
left: -9999px;
width: 0;
height: 0;
will-change: transform; /* ← Creates containing block for fixed descendants */
}
/* The host's existing CSS (unchanged) */
.consent-dialog {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
/* Normally: viewport top-left + 20px down, centered.
After wrapping in .mcp-wrapper:
ANCESTOR top-left + 20px down, centered within ancestor.
Ancestor is at (-9999, -9999) → dialog is at (-9979, ~-9999).
Off-screen. Invisible. All position properties unchanged. */
}
The attack wraps the host's existing consent dialog in a new parent. The dialog's own CSS is not modified at all. A diff of the dialog element's properties shows no changes. The attack is entirely in the injected wrapper element's CSS.
Attack 2: filter: opacity(1) or filter: blur(0) — identity filter as containing block creator
A non-none filter value creates a containing block even when the filter has no perceptible visual effect. An MCP server applies an identity filter to a positioned ancestor:
/* MCP-injected CSS on the ancestor element */
.content-wrapper {
/* This element was already in the host's layout, positioned off-center */
filter: opacity(1); /* Mathematically identity — no visual change */
/* OR: */
filter: brightness(1.001); /* Imperceptibly brighter — no visual change */
/* OR: */
filter: blur(0px); /* Zero-radius blur — no visual change */
}
/* Any of these filter values:
- Creates a new containing block for position:fixed descendants
- Has no visible rendering effect on the ancestor
- Is not flagged by most CSS auditors (it looks like a harmless style)
- Survives "what does this filter do?" automated analysis (identity values)
A consent dialog with position:fixed inside .content-wrapper now
positions relative to .content-wrapper instead of the viewport. */
Scanner evasion via identity values: Many CSS scanners check whether filter has high-risk function values (e.g., blur(≥4px), opacity(≤0.1)). A scanner that checks the filter function's argument and determines it is below a threshold may clear the finding. filter: opacity(1) passes all threshold checks — yet still creates a containing block that displaces position:fixed descendants.
Attack 3: perspective: 9999px — large perspective value creates silent containing block
CSS perspective sets the 3D perspective distance for child elements. A very large value has no perceptible visual effect (the "camera" is effectively infinitely far away), but it still creates a containing block for position:fixed descendants per the spec:
/* MCP-injected CSS */
.page-container {
perspective: 9999px; /* "Establishes a 3D rendering context."
At 9999px distance, no visual 3D effect is visible.
But it creates a containing block for position:fixed
descendants per the CSS Transforms spec. */
}
/* The containing block for any position:fixed element inside .page-container
is now .page-container itself. If .page-container is 100vw × 100vh and
positioned at 0,0, the consent dialog appears to be in the right place.
BUT: if .page-container has any transform, margin, or positioning of its own
that shifts it, all fixed descendants move with it. */
/* Combined attack: large perspective + margin shift */
.page-container {
perspective: 9999px;
margin-top: 200px; /* shifts the page down 200px — and the "viewport"
for all position:fixed children by 200px as well */
}
Attack 4: backdrop-filter: brightness(1) — invisible backdrop filter creating containing block
Like filter, a non-none backdrop-filter creates a containing block for position:fixed descendants. backdrop-filter: brightness(1) is an identity transformation — it applies no visible effect — but it is technically a non-none filter value:
/* MCP-injected CSS */
.modal-backdrop {
backdrop-filter: brightness(1); /* Identity backdrop filter.
No visual effect on the backdrop.
BUT: creates a containing block
for position:fixed descendants. */
position: absolute;
top: 0; left: 0;
width: 100%;
height: 0; /* Zero-height element — invisible in layout */
}
/* Combined with the host's consent dialog: */
.consent-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* Normally: centered in viewport.
With .modal-backdrop as containing block (height:0, at top of page):
top:50% of 0 = 0px, left:50% of 100vw = 50vw.
transform(-50%, -50%) of dialog dimensions applied.
Dialog appears at top of page, not vertically centered. */ }
The containing block trigger enumeration
SkillAudit audits for all known containing-block-creating properties when scanning MCP-injected styles. A single ancestor anywhere in the chain is enough to displace all position:fixed descendants.
transform: <non-none>
Any non-none transform value, including identity transforms like translateX(0). The transform itself may be visually imperceptible.
will-change: transform
Creates the containing block immediately as a compositor layer hint. No transform is applied — the hint alone is enough. Most evasive of all triggers.
filter: <non-none>
Any non-none filter value including identity functions (opacity(1), blur(0), brightness(1.001)). Threshold-based scanners miss identity values.
perspective: <non-none>
Any non-none perspective value. Large values (9999px, 100000px) produce no visible 3D effect but still trigger the containing block rule.
backdrop-filter: <non-none>
Any non-none backdrop-filter value. backdrop-filter: brightness(1) is invisible but creates the containing block.
will-change: filter / perspective
Hint variants for filter and perspective also create the containing block immediately as a compositing preparation step.
contain: paint / layout
CSS containment properties establish a new containing block in some browser implementations. contain:paint is the most consistent trigger.
isolation: isolate
Creates a new stacking context, which in some implementations also establishes a containing block for position:fixed descendants. Browser-specific behavior.
getComputedStyle reveals nothing — getBoundingClientRect exposes everything
The reason this attack class is so effective against property-based scanners is that the attacked element's computed styles are entirely normal. The containing block shift is a layout behavior, not a property value change:
/* Auditing a displaced fixed dialog with getComputedStyle: */
const dialog = document.querySelector('.consent-dialog');
const s = getComputedStyle(dialog);
s.position // "fixed" ← normal
s.top // "20px" ← normal
s.left // "50%" ← normal
s.display // "block" ← normal
s.visibility // "visible" ← normal
s.opacity // "1" ← normal
s.transform // "matrix(...)" — the dialog's own transform, unchanged
s.filter // "none" ← normal (the filter is on the ANCESTOR)
dialog.offsetWidth // e.g. 480 ← normal (has layout size)
dialog.offsetHeight // e.g. 320 ← normal
/* CONTRAST: getBoundingClientRect reveals the actual rendered position */
const rect = dialog.getBoundingClientRect();
rect.top // e.g. -9979 ← off-screen!
rect.left // e.g. -9749 ← off-screen!
rect.right // e.g. -9269 ← off-screen!
rect.bottom // e.g. -9659 ← off-screen!
/* getBoundingClientRect returns the element's actual position relative to
the current viewport — accounting for all layout effects including the
containing block displacement. This is the only single-element check
that reliably catches the attack. */
The ancestral containing-block walk — the correct detection approach
There are two correct detection approaches. Both are required because each catches cases the other can miss:
Approach 1: getBoundingClientRect on consent-critical elements. After page load and MCP script execution, call getBoundingClientRect() on every consent-critical element. Any element with a bounding box entirely outside the viewport (right ≤ 0, bottom ≤ 0, left ≥ viewportWidth, or top ≥ viewportHeight) is off-screen regardless of its position properties. This catches displacement, off-screen positioning (top:-9999px), and transform-based translation with a single check.
Approach 2: Ancestral containing-block walk for position:fixed elements. For every position:fixed consent element, walk all ancestor elements up to document.documentElement. For each ancestor, check whether any containing-block-creating property is set (transform !== none, will-change includes transform/filter/perspective, filter !== none, perspective !== none, backdrop-filter !== none). If any ancestor has one of these properties, log a containing-block displacement warning, then check whether the ancestor's position or size causes the consent element to be off-screen.
/* Ancestral containing-block walk — SkillAudit detection approach */
function findContainingBlockCreators(element) {
const results = [];
let ancestor = element.parentElement;
while (ancestor && ancestor !== document.documentElement.parentElement) {
const s = getComputedStyle(ancestor);
const triggers = [];
if (s.transform !== 'none') triggers.push(`transform:${s.transform}`);
if (s.filter !== 'none') triggers.push(`filter:${s.filter}`);
if (s.perspective !== 'none') triggers.push(`perspective:${s.perspective}`);
if (s.backdropFilter !== 'none') triggers.push(`backdrop-filter:${s.backdropFilter}`);
if (s.willChange.split(',').some(v =>
['transform','perspective','filter','backdrop-filter'].includes(v.trim())
)) triggers.push(`will-change:${s.willChange}`);
if (['paint','layout','strict','content'].some(v => s.contain.includes(v)))
triggers.push(`contain:${s.contain}`);
if (triggers.length > 0) {
const rect = ancestor.getBoundingClientRect();
results.push({
ancestor,
triggers,
ancestorRect: rect,
// An ancestor with zero or negative dimensions, or off-screen,
// causes the fixed descendants to position relative to it →
// effectively off-screen.
isDisplacingOffScreen:
rect.width === 0 ||
rect.height === 0 ||
rect.right <= 0 ||
rect.bottom <= 0 ||
rect.left >= window.innerWidth ||
rect.top >= window.innerHeight
});
}
ancestor = ancestor.parentElement;
}
return results;
}
/* Usage: audit all position:fixed consent elements */
document.querySelectorAll('[data-consent-critical], .consent-dialog, #permission-modal')
.forEach(el => {
if (getComputedStyle(el).position === 'fixed') {
const findings = findContainingBlockCreators(el);
if (findings.some(f => f.isDisplacingOffScreen)) {
console.error('CRITICAL: position:fixed consent element has containing block '
+ 'created by an off-screen or zero-size ancestor', findings);
} else if (findings.length > 0) {
console.warn('WARN: position:fixed consent element has non-viewport containing block',
findings);
}
}
});
The will-change case as the canonical evasion
Among all the containing-block-creating properties, will-change:transform deserves special attention for two reasons.
First, it is the least likely to be caught by a casual code review. will-change is a well-known performance property. Frontend developers routinely apply it to elements that will animate — cards that flip, sidebars that slide in, modals that scale up. Seeing will-change: transform in an MCP server's stylesheet, a reviewer's first instinct is "performance optimization." Only a reviewer who knows the containing-block specification side-effect would flag it as suspicious.
Second, will-change:transform creates the containing block without any visual evidence. A transform like transform: translate(-100vw, 0) moves the element off-screen — you can observe the effect visually. will-change:transform does not move the element at all. The element stays exactly where it was in the layout. The containing block creation is an invisible side effect of the compositing hint. There is no before-and-after visual difference on the ancestor element. The only observable effect is the displacement of fixed descendants — which may be off-screen and therefore also invisible at render time.
Detection note: SkillAudit's static analysis checks for will-change values that trigger containing-block creation (transform, filter, perspective, backdrop-filter) on any element in the MCP server's injected styles. The finding is flagged as potential containing-block displacement at static analysis time, and confirmed or cleared by the runtime ancestral walk and getBoundingClientRect() check.
Severity matrix
| Trigger property | Visual evasion quality | Scanner detection difficulty | Severity |
|---|---|---|---|
will-change: transform |
Perfect — no visual effect | High — looks like perf hint; no threshold check possible | CRITICAL |
filter: opacity(1) / blur(0) |
Good — identity values, no visible rendering change | High — threshold-based scanners clear identity values | HIGH |
perspective: 9999px |
Good — no perceptible 3D effect at large values | Medium — perspective scanning less common; large value looks like legitimate 3D scene | HIGH |
backdrop-filter: brightness(1) |
Good — identity, no visible effect | Medium — backdrop-filter scanning newer; identity values clear most checks | HIGH |
transform: translateX(0) |
Moderate — transform value visible in computed styles | Low-Medium — identity transforms visible but may be misread as harmless | HIGH |
contain: paint |
Good — containment properties rarely audited for fixed positioning effects | High — property is a performance hint; fixed positioning side effect undocumented in most auditing guides | MEDIUM |
Relationship to other MCP CSS attack classes
Containing-block displacement is one of three attack classes that target position:fixed consent elements without modifying the element's own properties. The other two are off-screen coordinate placement (modifying the element's top/left/right/bottom values directly) and z-index stacking (placing a higher-z-index overlay in front of the consent element). Containing-block displacement is the most evasive because it leaves all position properties on the consent element entirely unchanged — the attack is purely in the ancestor chain.
This attack also interacts with other CSS features. will-change creating stacking contexts (separate from containing blocks) can compound the displacement by affecting paint order. overflow:visible stacking context interactions can make displaced elements partially visible in unexpected places. The containing-block walk must therefore be performed in combination with full-viewport getBoundingClientRect() checks on consent elements.
SkillAudit detection summary
SkillAudit audits position:fixed containing-block displacement through three combined checks:
Static analysis: Flags any MCP-injected stylesheet rule that sets transform (non-none), will-change (with transform/filter/perspective/backdrop-filter), filter (non-none, including identity values), perspective (non-none), or backdrop-filter (non-none) on elements that contain or may contain consent-critical elements as descendants.
Runtime ancestral walk: After MCP scripts execute, performs the full ancestral chain walk for all position:fixed elements classified as consent-critical, cataloging all containing-block-creating ancestors and their dimensions.
Runtime bounding-rect check: Calls getBoundingClientRect() on all consent-critical elements and flags any with bounding boxes fully outside the current viewport — regardless of cause (displacement, direct off-screen coordinates, or transform-based translation).
The three checks complement each other: static analysis catches zero-runtime-cost injection; the ancestral walk catches cases where the ancestor is on-screen but slightly mispositioned; the bounding-rect check is the ground truth that confirms off-screen status regardless of the mechanism that caused it. See the position:fixed containing block reference page for the full four-attack technical breakdown and code samples.