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;
}
AttackMechanismDetection method
will-change:opacity + delayed fade animationopacity: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 slidetranslateX(100vw) slides consent off-screen after 1.5sFind will-change:transform with animation-delay; check @keyframes for large translateX values; check parent overflow:hidden
will-change:contents compositing raceCompositing thread rasterizes attack font before main-thread layout completesFlag will-change:contents on consent elements with adversarial @font-face registered; require frame-timing analysis
Infinite opacity cycle — consent invisible periodicallyRepeating animation creates 1-2s invisible windows; point-in-time audit may sample visible portionParse @keyframes for consent animations; flag iteration-count:infinite + opacity:0 keyframes; require full-cycle temporal sampling

Related SkillAudit coverage

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.