MCP server CSS will-change GPU compositing consent security
Four SA-CSS-WCG consent attack patterns that exploit will-change GPU layer promotion as a timing mechanism. The browser promotes a consent element to a hardware compositing layer early — before any hiding occurs — so static audit snapshots see fully normal styles. At click time, a JavaScript callback applies opacity:0 or transform:scale(0) to the already-promoted layer; the collapse is instantaneous and occurs within the same animation frame as the click. Unlike naive style injection, the attack exploits the architectural gap between the audit checkpoint and the user interaction frame.
The CSS will-change property instructs the browser to allocate a dedicated GPU compositing layer for an element ahead of any visible animation or transition. Its intended use is performance optimisation: declaring will-change: transform before a JS-driven animation prevents the jank of late layer promotion during the first animation frame. Malicious consent UIs exploit this same mechanism to create a race between the audit tool (which snapshots styles at page-load time or before user interaction) and the JS event listener (which collapses the consent text exactly when the user clicks). Because the collapsing style is applied in the same microtask queue flush as the click handler, no intermediate frame is rendered with the hidden state visible — the user simply finds the consent text gone at the moment they are trying to confirm it.
GPU compositing layer promotion is itself invisible to getComputedStyle(): the property will-change is reported by getComputedStyle().willChange but the fact that the layer is promoted is a browser-internal rendering implementation detail not exposed to the DOM. Detection therefore requires cross-referencing the declared will-change value with event listener registrations on the same element.
SA-CSS-WCG-001: will-change:opacity + opacity:0 timing attack
The consent element is declared with will-change: opacity in the stylesheet. At page load the browser immediately allocates a compositing layer for the element with opacity: 1. An event listener on the Allow button (or on the consent element itself) sets el.style.opacity = '0' synchronously inside the click handler before the rest of the click processing logic runs. Because the element is already on its own compositing layer, the opacity change is applied without a re-layout or re-paint — it is a pure compositor-side operation that takes effect in the same frame as the click. A static audit run before the click event fires sees opacity: 1 (correct) and will-change: opacity (performance hint — not inherently suspicious). The attack flag is the presence of a JS event listener that writes to the opacity style property on the same element that carries will-change: opacity.
/* Attack CSS — in stylesheet or <style> block */
.consent-text {
will-change: opacity; /* pre-promote to GPU compositing layer */
opacity: 1; /* visible at page load and at audit checkpoint */
transition: none; /* no transition — change is instantaneous */
}
/* Attack JS — in click handler on Allow button */
allowBtn.addEventListener('click', function(e) {
/* Step 1: collapse consent text on GPU compositing layer — no re-layout */
consentTextEl.style.opacity = '0';
/* Step 2: submit the form / proceed with the action */
submitConsentForm();
/* Timeline:
Frame N (audit checkpoint): opacity:1 visible — no attack detected
Frame N+K (user click): opacity:0 applied synchronously in click handler
— text invisible before click visual feedback
Frame N+K+1: form submitted; dialog dismissed
User never sees consent text gone — it vanishes in same frame as click */
});
/* ---- Detection algorithm ---- */
function detectWCG001(consentEl) {
const cs = getComputedStyle(consentEl);
const wc = cs.willChange;
/* Flag 1: will-change includes 'opacity' */
if (!wc.includes('opacity')) return null;
/* Flag 2: element has an event listener that sets opacity
Note: getEventListeners() is DevTools API only.
In automated audit use MutationObserver + click simulation: */
const obs = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const newOpacity = getComputedStyle(consentEl).opacity;
if (parseFloat(newOpacity) < 0.5) {
reportFinding('SA-CSS-WCG-001', 'CRITICAL',
'will-change:opacity + opacity collapse in click handler');
}
}
}
});
obs.observe(consentEl, { attributes: true, attributeFilter: ['style'] });
/* Simulate click on allow button and observe mutation */
allowBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
return wc.includes('opacity') ? 'REVIEW: will-change:opacity on consent element' : null;
}
Audit timing gap: Static snapshot auditors that run getComputedStyle() at page-load time or in a headless browser before simulating user interaction will always see opacity: 1 on this element. The attack only becomes visible when the auditor also observes style mutations triggered by a simulated click. SkillAudit's dynamic interaction engine performs a supervised click simulation and monitors for style mutations on consent elements during and after the click event.
will-change: opacity pre-promotes consent element; JS click handler sets opacity: 0 synchronously; audit snapshot sees opacity: 1. Detection: willChange includes 'opacity' + MutationObserver on style attribute fires opacity collapse during simulated click → CRITICAL.SA-CSS-WCG-002: will-change:transform + transform:scale(0) via requestAnimationFrame
The consent element carries will-change: transform in its stylesheet rules, promoting it to a GPU compositing layer before any interaction. The click handler on the Allow button calls requestAnimationFrame(() => { consentEl.style.transform = 'scale(0)'; }) — scheduling the collapse for the very next animation frame rather than the current synchronous call stack. This is a deliberate evasion of the simplest dynamic auditors: if the auditor simulates a click and checks styles immediately after (still in the same synchronous task), the requestAnimationFrame callback has not yet fired and the transform is still none. The element collapses one frame later, which in a 60 fps display is approximately 16 ms after the click. The user has not yet had time to react; they observe the consent text disappear precisely as they release the mouse button.
/* Attack CSS */
.consent-body {
will-change: transform; /* GPU compositing layer allocation */
transform: none; /* no transform at audit time */
transform-origin: center center;
}
/* Attack JS */
allowBtn.addEventListener('click', function(e) {
/* Schedule collapse on next animation frame — evades synchronous auditors */
requestAnimationFrame(() => {
consentBodyEl.style.transform = 'scale(0)';
/* scale(0): element geometry collapses to zero — invisible AND takes no space */
/* GPU compositor handles the transform: no re-layout, no re-paint
frame timeline for the user:
click mousedown → nothing changed
click mouseup → next rAF fires → scale(0) applied
click complete → consent text gone before dialog dismiss animation */
});
submitConsentForm();
});
/* ---- Detection algorithm ---- */
async function detectWCG002(consentEl, allowBtn) {
const cs = getComputedStyle(consentEl);
/* Flag 1: will-change includes 'transform' */
if (!cs.willChange.includes('transform')) return null;
/* Flag 2: check for rAF-deferred transform collapse */
let collapseDetected = false;
const obs = new MutationObserver(() => {
const t = getComputedStyle(consentEl).transform;
/* scale(0) gives matrix(0,0,0,0,0,0) or matrix3d with zeroes */
if (t !== 'none' && t.includes('matrix')) {
const vals = t.match(/-?[\d.]+/g).map(Number);
if (Math.abs(vals[0]) < 0.01) {
collapseDetected = true;
reportFinding('SA-CSS-WCG-002', 'CRITICAL',
'will-change:transform + rAF-deferred scale(0) on consent element');
}
}
});
obs.observe(consentEl, { attributes: true, attributeFilter: ['style'] });
allowBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
/* Wait two animation frames to catch rAF-deferred mutations */
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
obs.disconnect();
return collapseDetected ? 'CRITICAL' : 'OK';
}
setTimeout(0) variant: Some implementations use setTimeout(() => { consentEl.style.transform = 'scale(0)'; }, 0) instead of requestAnimationFrame. The zero-delay setTimeout fires after the current task and any microtasks complete but before the next paint — producing the same visual result. Detection must wait at least one event-loop turn past the click simulation to catch setTimeout(0) deferred mutations as well as rAF-deferred ones.
will-change: transform pre-promotes; JS click handler schedules transform: scale(0) via requestAnimationFrame or setTimeout(0); synchronous auditors miss the collapse. Detection: willChange includes 'transform' + observed transform collapse within two rAF frames of simulated click → CRITICAL.SA-CSS-WCG-003: isolation:isolate stacking context — backdrop-filter penetration defeat
isolation: isolate creates a new stacking context on the consent container without touching its opacity, transform, or z-index values. This is architecturally different from the timing attacks in WCG-001 and WCG-002: the consent text itself may be perfectly visible. The attack targets the broader consent UI layout rather than the consent text directly. A sibling element positioned behind the consent container uses backdrop-filter: blur(100px) to blur the entire region behind the consent dialog. Without isolation: isolate, backdrop-filter on a sibling behind the consent container would blur the consent container's background but would not penetrate into the consent container's own painted pixels — the consent text would remain legible. With isolation: isolate on the consent container, the stacking context boundary is explicit, and browsers treat the isolated element as an atomic flat composited image for the purposes of backdrop-filter compositing on elements that are behind it in z-order. A consent-hiding overlay behind the isolated element can apply extreme blur or color manipulation to the entire visible region at the consent container's screen coordinates without any CSS property on the consent element itself appearing suspicious.
/* Attack structure */
<div class="page-bg-layer" style="
position: fixed;
inset: 0;
/* backdrop-filter applies to everything behind THIS element in z-order */
/* With isolation:isolate on consent-dialog, the isolated stacking context
is composited as a flat texture — backdrop-filter sees that texture */
backdrop-filter: blur(40px) brightness(3) contrast(0.1);
/* extreme brightness + low contrast → text completely washed out */
z-index: 1;
pointer-events: none;
"></div>
<div class="consent-dialog" style="
isolation: isolate; /* creates stacking context boundary */
position: relative;
z-index: 2; /* above the backdrop layer */
background: rgba(255,255,255,0.05); /* near-transparent: backdrop bleeds through */
">
<p class="consent-text">By clicking Allow you authorize permanent file access.</p>
<button>Allow</button>
<button>Deny</button>
</div>
/* On the consent element:
- isolation: isolate (the attack vector)
- background: rgba with very low alpha → backdrop-filter behind bleeds through
- getComputedStyle(consentTextEl).color: correct text color
- getComputedStyle(consentDialogEl).isolation: 'isolate'
The consent text IS in the DOM with correct styles.
The visual attack is:
backdrop-filter:blur(40px) + brightness(3) + contrast(0.1) on the layer BEHIND
→ consent text washed to near-white despite correct color value
Detection:
1. Check consent container: getComputedStyle(el).isolation === 'isolate'
2. Enumerate siblings and ancestors with lower z-index (behind this element)
3. Check if any has backdrop-filter with blur() > 5px OR brightness > 1.5 OR contrast < 0.5
4. Also check consent container background alpha: if < 0.3, backdrop bleeds through */
function detectWCG003(consentEl) {
const cs = getComputedStyle(consentEl);
if (cs.isolation !== 'isolate') return null;
/* Check consent background alpha */
const bg = cs.backgroundColor; /* e.g. "rgba(255, 255, 255, 0.05)" */
const alphaMatch = bg.match(/rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
const alpha = alphaMatch ? parseFloat(alphaMatch[1]) : 1;
/* Walk siblings behind this element in z-order */
const parent = consentEl.parentElement;
const consentZ = parseInt(cs.zIndex) || 0;
for (const sibling of parent.children) {
if (sibling === consentEl) continue;
const scs = getComputedStyle(sibling);
const sibZ = parseInt(scs.zIndex) || 0;
if (sibZ < consentZ && scs.backdropFilter && scs.backdropFilter !== 'none') {
if (alpha < 0.3) {
reportFinding('SA-CSS-WCG-003', 'HIGH',
'isolation:isolate + low-alpha bg + backdrop-filter behind consent');
}
}
}
}
Why isolation:isolate is used here: Without isolation: isolate, the consent container would not necessarily form a stacking context, and the backdrop-filter on the behind-layer would only blur the page background behind the consent container, not the consent container's own painted content. The attacker adds isolation: isolate precisely to make the consent container a well-defined atomic composited image that the behind-layer's backdrop-filter can then blur. The consent element's own properties — color, font, z-index — all appear normal in getComputedStyle().
isolation: isolate on consent container + near-transparent background + sibling backdrop-filter with extreme values behind it: consent text washed out visually; consent element's own styles appear normal. Detection: isolation === 'isolate' + background alpha < 0.3 + sibling or ancestor with backdrop-filter containing large blur or extreme brightness/contrast → HIGH.SA-CSS-WCG-004: will-change:contents — ancestor collapse via clip-path or opacity
will-change: contents on a parent element signals to the browser that the element's contents will change, which causes the browser to promote the children to their own compositing layers. When combined with an ancestor element (above the will-change: contents element in the DOM tree) that applies clip-path: inset(100%) or opacity: 0 after the audit checkpoint, the consent element itself appears completely normal at audit time — it is inside a will-change: contents container, its own styles are correct, and its dimensions are non-zero. The ancestor collapse is applied by a JS event listener that fires at click time. Because the ancestor is higher in the DOM and may be unrelated to the consent UI semantically (e.g., a shared layout container), auditors focused on the consent element and its immediate parent may miss the attack entirely.
/* DOM structure */
<div id="layout-wrapper"> <!-- ancestor: clip-path collapse target -->
<div class="consent-container"
style="will-change: contents"> <!-- promotes children to compositing layers -->
<p class="consent-text">
By clicking Allow you authorize permanent file access.
</p>
</div>
<div class="btn-wrapper"> <!-- NOT inside consent-container -->
<button id="allow-btn">Allow</button>
</div>
</div>
/* Attack CSS */
#layout-wrapper {
/* No suspicious properties at audit time */
position: relative;
clip-path: none; /* normal at audit checkpoint */
}
.consent-container {
will-change: contents; /* children promoted to compositing layers */
}
/* Attack JS */
document.getElementById('allow-btn').addEventListener('click', function() {
/* Collapse ancestor — affects consent-container and its children */
document.getElementById('layout-wrapper').style.clipPath = 'inset(100%)';
/* clip-path:inset(100%) clips to a zero-area region — element invisible
but still present in DOM with getBoundingClientRect() returning non-zero dims
getComputedStyle(consentTextEl).color still returns correct value
The audit window has already passed — collapse fires at interaction time */
submitForm();
});
/* Audit at page load:
- consentTextEl: color correct, display:block, no suspicious properties
- consent-container: will-change:contents (performance hint — not suspicious alone)
- layout-wrapper: clip-path:none
→ No findings from static snapshot
Audit after simulated click (dynamic):
- layout-wrapper: clip-path:inset(100%) → SUSPICIOUS on consent ancestor */
/* ---- Detection algorithm ---- */
function detectWCG004(consentEl) {
/* Walk up DOM tree to find will-change:contents container */
let el = consentEl.parentElement;
while (el) {
const cs = getComputedStyle(el);
if (cs.willChange.includes('contents')) {
/* Found will-change:contents ancestor — now check higher ancestors */
let ancestor = el.parentElement;
while (ancestor) {
const acs = getComputedStyle(ancestor);
/* Check for clip-path or opacity set inline (could be JS-modified) */
if (ancestor.style.clipPath && ancestor.style.clipPath !== 'none') {
reportFinding('SA-CSS-WCG-004', 'CRITICAL',
'will-change:contents container + ancestor clip-path manipulation');
}
if (ancestor.style.opacity !== '' && parseFloat(ancestor.style.opacity) < 0.1) {
reportFinding('SA-CSS-WCG-004', 'CRITICAL',
'will-change:contents container + ancestor opacity:0 manipulation');
}
ancestor = ancestor.parentElement;
}
}
el = el.parentElement;
}
/* Also: run detection AFTER simulated click to catch JS-deferred collapses */
}
Widening the audit scope: This attack pattern requires auditors to walk the full ancestor chain of the consent element, not just its immediate parent and siblings. Any ancestor with an inline style attribute that includes clip-path or opacity set by JavaScript — even if that ancestor is a generic layout wrapper — is a finding when a will-change: contents container is present in the consent subtree. SkillAudit's tree-walker audit mode inspects the entire ancestor chain for deferred style mutations after click simulation.
will-change: contents on consent parent promotes children to compositing layers; ancestor JS event listener applies clip-path: inset(100%) or opacity: 0 at click time; static audit sees clean styles. Detection: willChange includes 'contents' + ancestor has inline clip-path or opacity modified after simulated click → CRITICAL.Summary of SA-CSS-WCG findings
| ID | Severity | Key properties | Hiding mechanism | Detection method |
|---|---|---|---|---|
| SA-CSS-WCG-001 | CRITICAL | will-change:opacity + JS opacity:0 | Synchronous opacity collapse in click handler; GPU layer already promoted | willChange includes 'opacity' + MutationObserver fires opacity collapse during simulated click |
| SA-CSS-WCG-002 | CRITICAL | will-change:transform + rAF/setTimeout(0) scale(0) | Transform collapse deferred one frame past synchronous audit window | willChange includes 'transform' + transform matrix with near-zero scale observed within two rAF frames of simulated click |
| SA-CSS-WCG-003 | HIGH | isolation:isolate + near-transparent bg + sibling backdrop-filter | Extreme backdrop-filter behind isolated consent container washes out text visually | isolation === 'isolate' + background alpha < 0.3 + sibling backdrop-filter with blur > 5px or extreme brightness/contrast |
| SA-CSS-WCG-004 | CRITICAL | will-change:contents on parent + ancestor clip-path/opacity via JS | Ancestor DOM collapse at click time; consent element's own styles clean at audit time | willChange includes 'contents' in ancestor + higher ancestor gains inline clip-path or opacity:0 after simulated click |