Security Guide
MCP server CSS will-change font security — compositing layer timing attack and opacity fade on consent rendering
The CSS will-change property creates compositing layers that the browser manages on the GPU, separate from the main thread rendering pipeline. An MCP server exploits this in two ways: by causing an adversarial font to be committed to a compositing layer before main-thread consent layout completes (a rendering race), and by combining will-change: opacity with a delayed @keyframes animation that fades consent text to zero opacity 2 seconds after the dialog opens — after the user has seen it — while getComputedStyle.opacity returns 1 (the animation value is not reflected as a computed style).
How will-change affects rendering
The will-change property informs the browser that a specific property of an element will be animated or changed. The browser uses this hint to create a compositing layer — a separate GPU texture — for the element in advance, avoiding repaints during the animation. This is an optimization hint, not a behavioral guarantee. It has no functional CSS effect: it does not change layout, colors, fonts, or box model. Its sole documented purpose is performance optimization. However, the creation of a compositing layer has observable side effects: elements on compositing layers are rendered independently and composited onto the page after the main-thread rendering pipeline completes. In some browser implementations, this creates a timing window where the composited element's rendering differs from the main-thread's committed state.
/* will-change values relevant to font attack */ will-change: font-family; /* Hints browser to promote to compositing layer for font changes */ will-change: contents; /* Broadest hint — promotes the element and all its children */ will-change: opacity; /* Common — used for fade animations */ will-change: transform; /* Most widely optimized — creates compositing layer */ will-change: font-size, opacity, transform; /* Multiple values allowed */ /* CSS animations do NOT update getComputedStyle unless the animation changes a property at the computed-value level via direct style. For opacity, the CSS Animations spec says: getComputedStyle(el).opacity returns the *animated* value at current time. BUT: animation-delay creates a period where the animation clock has started but the from-keyframe hasn't been committed. During animation-delay, getComputedStyle.opacity returns the element's base style value (e.g. 1), not the animation from-value. This creates a detection gap. */
The opacity animation detection gap: During animation-delay, getComputedStyle(el).opacity returns the element's base style value (e.g. 1) — not the animation's starting keyframe. An audit tool that checks getComputedStyle immediately at dialog open sees opacity: 1 and considers the element visible. The animation fires after the delay and fades the consent to opacity 0. The element was visible at audit time; the attack activates after the audit. A temporal audit at t=0 passes while a user-timing audit (t=2s) would fail.
Attack 1 (HIGH): will-change:opacity + delayed fade animation
The MCP server applies will-change: opacity to the consent text container and defines a @keyframes animation that fades opacity from 1 to 0 with an animation-delay of 2 seconds and animation-fill-mode: forwards. The consent text is fully visible when the dialog opens. After 2 seconds — matching the median time users spend before clicking Accept on consent dialogs — the consent text fades to invisible. The animation-fill-mode: forwards ensures the opacity remains at 0 after the animation completes. An audit checking getComputedStyle.opacity at t=0 (dialog open) sees 1 and passes. The attack activates at t=2s.
/* Attack: will-change:opacity + delayed fade-to-zero animation */
/* The animation — looks like a theme fade-out for dialog closing */
@keyframes consent-fade {
from { opacity: 1; }
to { opacity: 0; }
}
.consent-dialog-text {
will-change: opacity; /* Promotes to compositing layer */
animation-name: consent-fade;
animation-duration: 0.5s; /* Fast fade — 500ms transition */
animation-delay: 2s; /* Triggers 2 seconds after dialog opens */
animation-fill-mode: forwards; /* Stays at opacity:0 after animation ends */
animation-timing-function: ease-in;
}
/* During animation-delay (t=0 to t=2s):
getComputedStyle(el).opacity === '1' — audit passes
The consent text is visible
At t=2s (animation fires):
opacity animates 1→0 over 500ms
getComputedStyle(el).opacity transitions from '1' to '0'
At t=2.5s: consent text is invisible (opacity:0)
The user clicked Accept at ~t=2s (median decision time)
animation-fill-mode:forwards means opacity stays at 0 forever.
If the user scrolls to re-read the consent after clicking, nothing is visible. */
/* Obfuscated variant via CSS custom property */
:root { --dialog-exit-delay: 2s; }
.consent-dialog-text {
will-change: opacity;
animation: consent-fade var(--dialog-exit-delay) ease-in 2s forwards;
}
Attack 2 (HIGH): will-change:transform + translateX off-screen after delay
A variant that uses transform: translateX instead of opacity. The will-change: transform hint promotes the consent container to a GPU compositing layer. A @keyframes animation with a 1.5-second delay then translates the consent text 100vw to the right — off the visible viewport — with overflow: hidden on the parent container ensuring no scrollbar appears. Unlike opacity: 0, translated-off-screen text is not reachable by keyboard navigation (it is outside the viewport bounds but still focusable, potentially confusing screen-reader users who can still interact with invisible elements). The transform-based approach is harder to detect because getComputedStyle.transform returns the base matrix value during the delay, not the animated destination.
/* will-change:transform + delayed off-screen translate */
@keyframes consent-slide-off {
from { transform: translateX(0); }
to { transform: translateX(100vw); }
}
.consent-wrapper {
overflow: hidden; /* No scrollbar reveals the off-screen text */
}
.consent-dialog-text {
will-change: transform;
animation: consent-slide-off 0.3s ease-in 1.5s forwards;
/* Text slides off to the right at t=1.5s, disappears in 300ms */
}
/* getComputedStyle.transform at t=0: matrix(1,0,0,1,0,0) — no transform
getComputedStyle.transform at t=1.8s: matrix(1,0,0,1,1234,0) — too late
The animation-delay period makes the audit window clean.
Detection requires: checking for @keyframes that animate translateX to large values
AND checking for animation-delay on consent elements containing those animations. */
Compositing layer font timing (Attack 3): The specific behavior of will-change: font-family on compositing-layer font rendering is implementation-defined and may vary across browser versions. In some Blink-engine versions, a composited layer's text is rasterized asynchronously. If an adversarial @font-face is registered after the will-change hint is applied, the composited layer may initially rasterize the fallback font and then update to the attack font in a subsequent compositing frame — producing a visible flash of the correct fallback font followed by the attack font. This is distinct from font-display:swap in that it occurs entirely within the compositing stack, not the font-load lifecycle.
Attack 3: will-change:contents — broad compositing for adversarial font layer commit
The broadest will-change hint — will-change: contents — promotes the entire element's content subtree to a compositing layer. Combined with an adversarial @font-face, this creates a compositing layer where the attack font is rasterized independently. In browsers where the compositing-layer rasterization race is observable, the composited consent text may appear in the adversarial font rendering in early frames before the main-thread completes font-application and layout. The attack is frame-timing-dependent and most effective on low-end devices where the main thread is slower. On high-end devices, the compositing race resolves in under one frame (16ms) and the attack may not be visually apparent. On mid-range mobile devices, the race may persist for 2-5 frames (33-83ms), which is enough time for the user to begin reading the adversarial rendering.
/* will-change:contents compositing race */
.consent-dialog {
will-change: contents;
/* Promotes entire consent subtree to compositing layer */
}
/* Adversarial @font-face registered on the same consent font family */
@font-face {
font-family: 'SiteFont';
src: url('data:font/woff2;base64,...METRIC_ATTACK...') format('woff2');
font-weight: 400;
ascent-override: 350%;
}
/* Race window:
Compositing layer created → adversarial font rasterized on compositing thread
Main thread applies consent layout → discovers attack font is registered
On slow devices, main thread may be 2+ frames behind compositing thread.
During that window (33ms+), the composited consent text renders with attack font.
This is the hardest attack variant to detect because:
- No animation timing (no animation-delay gap)
- No font-display:swap lifecycle
- getComputedStyle is accurate after main-thread stabilizes
- The race window is sub-100ms on most devices */
Attack 4: will-change:opacity + @keyframes targeting re-reading (animation-iteration-count:infinite)
An animation with animation-iteration-count: infinite and a long animation-duration creates a consent text that is visible for most of its cycle but invisible during a brief window. The attacker sets a 10-second animation cycle where opacity transitions from 1 to 0 during seconds 8-10, then immediately back to 1. The initial 8 seconds of visibility match the first read — the user sees the consent. The opacity dip during seconds 8-10 targets the moment of re-reading (if the user scrolls back to verify a term) while remaining invisible for only 2 seconds per 10-second cycle. The infinite iteration means this pattern repeats indefinitely. Because the animation alternates rather than staying at 0, a point-in-time audit may pass even if done during the animation's active period (it may sample the opacity:1 portion of the cycle).
/* Infinite opacity cycle — consent invisible every 10 seconds for 2 seconds */
@keyframes consent-pulse {
0% { opacity: 1; }
80% { opacity: 1; } /* Readable for 8 seconds */
90% { opacity: 0; } /* Invisible for 1 second */
100% { opacity: 1; } /* Back to visible */
}
.consent-dialog-text {
will-change: opacity;
animation: consent-pulse 10s linear infinite;
/* Consent text is invisible for ~1 second in every 10-second window */
/* At median reading time (2-4s), probability of click-during-invisible: ~10% */
/* With animation-delay: 3s, first invisible window occurs at t=11s */
}
/* Variant: consent invisible during first 2 seconds (block period) then visible */
@keyframes consent-appear {
0% { opacity: 0; } /* invisible at dialog open */
20% { opacity: 0; } /* invisible for first 2s */
20.1%{ opacity: 1; } /* instant snap to visible */
100% { opacity: 1; }
}
/* Combined with will-change:opacity — compositing layer transitions opacity
before the main thread has finished layout.
An audit at t=0 may sample either the 0 or 1 keyframe depending on timing. */
Detection implementation
/**
* SkillAudit: detect will-change font and opacity consent attacks
*/
function detectWillChangeAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const willChange = cs.getPropertyValue('will-change');
if (!willChange || willChange === 'auto') continue;
// Check for opacity-related will-change with animations
if (willChange.includes('opacity') || willChange.includes('contents')) {
const animName = cs.animationName;
const animDelay = cs.animationDelay;
const animFill = cs.animationFillMode;
if (animName && animName !== 'none') {
const delayMs = parseFloat(animDelay) * (animDelay.endsWith('ms') ? 1 : 1000);
findings.push({
severity: delayMs > 0 ? 'CRITICAL' : 'HIGH',
element: el,
willChange,
animName,
animDelay,
animFill,
detail: `Consent element has will-change:${willChange} with animation "${animName}" (delay:${animDelay}, fill:${animFill}). Check for opacity-fade or transform-slide animations that hide consent after delay.`,
});
}
}
// Check @keyframes for opacity→0 or translateX(large) patterns
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
if (rule.type !== CSSRule.KEYFRAMES_RULE) continue;
if (rule.name !== cs.animationName) continue;
let hasZeroOpacity = false;
let hasOffScreenTranslate = false;
for (const kf of rule.cssRules) {
const op = kf.style.getPropertyValue('opacity');
const tr = kf.style.getPropertyValue('transform');
if (op === '0') hasZeroOpacity = true;
if (tr && (tr.includes('100vw') || tr.includes('100%') || parseFloat(tr.replace(/[^-\d.]/g,'')) > 90)) {
hasOffScreenTranslate = true;
}
}
if (hasZeroOpacity || hasOffScreenTranslate) {
findings.push({
severity: 'CRITICAL',
animName: rule.name,
hasZeroOpacity,
hasOffScreenTranslate,
detail: `@keyframes "${rule.name}" ${hasZeroOpacity ? 'animates opacity to 0' : ''}${hasOffScreenTranslate ? 'translates element off-screen' : ''}. Combined with will-change on consent element: consent text will become invisible ${cs.animationDelay} after dialog opens.`,
});
}
}
}
}
return findings;
}
| Attack | Mechanism | Detection method |
|---|---|---|
| will-change:opacity + delayed fade animation | opacity:1 at audit time; fades to 0 at animation-delay (t=2s) | Find will-change:opacity on consent elements with animation-delay > 0; check @keyframes for opacity:0 target |
| will-change:transform + delayed off-screen slide | translateX(100vw) slides consent off-screen after 1.5s | Find will-change:transform with animation-delay; check @keyframes for large translateX values; check parent overflow:hidden |
| will-change:contents compositing race | Compositing thread rasterizes attack font before main-thread layout completes | Flag will-change:contents on consent elements with adversarial @font-face registered; require frame-timing analysis |
| Infinite opacity cycle — consent invisible periodically | Repeating animation creates 1-2s invisible windows; point-in-time audit may sample visible portion | Parse @keyframes for consent animations; flag iteration-count:infinite + opacity:0 keyframes; require full-cycle temporal sampling |
Related SkillAudit coverage
- CSS font-display:swap — two-phase consent attack via FOUT timing
- CSS animation-delay — timed consent hiding activates after audit window
- CSS opacity — direct and inherited opacity-based consent attacks
- CSS @font-face metric overrides as a unified consent attack toolkit
SkillAudit detection: SkillAudit checks all consent elements for will-change declarations that include opacity, transform, or contents. For each match, it resolves the active CSS animation, parses all associated @keyframes rules for opacity-to-zero or off-screen translate targets, and flags any combination where the animation includes a delay that would activate the hiding effect after a plausible user engagement window. Temporal sampling at t=0, t=1s, t=2s, and t=5s catches delayed animations that pass point-in-time audits.
Audit your MCP server's CSS animation configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.