MCP server CSS @keyframes security: animation name collision, fill-mode forward-freeze, negative delay end-frame injection, and framework animation override

Published 2026-09-25 — SkillAudit Research

CSS @keyframes rules define animation state sequences that are referenced by the animation-name property. Unlike most CSS properties that directly affect element appearance, @keyframes attacks are indirect: the attack is not in any single property value applied to the consent element, but in the interaction between an animation name, a fill mode, a delay, and the keyframe sequence that defines the final state. Static CSS scanners that check element properties — display, visibility, opacity, transform — at their base values will see nothing wrong. The consent element has display: block and opacity: 1 in its authored styles. The invisible state is created by the animation system, not by the base property values.

This indirect attack surface means that @keyframes-based consent hiding passes the most common classes of CSS security scan: getComputedStyle() checks on the element's base properties, property-value enumerations, and rule-selector scans. The attack is only visible in the rendered state after the animation has run.

Why @keyframes attacks evade property scanners: A static CSS scanner that reads getComputedStyle(consentElement).opacity will return '1' — the base style. The animation override is not applied to the base computed style; it is applied during the animation fill period. The consent element is genuinely opacity:1 before the animation plays and opacity:0 after it completes with fill-mode:forwards. A scanner that reads properties at load time, before animation, sees nothing. A scanner that reads properties at load time, after animation has already run with negative delay, sees the attacked state — but may not associate it with @keyframes.

Attack 1: fill-mode:forwards consent freeze — animation ending at opacity:0

The animation-fill-mode: forwards property causes an element to retain the final keyframe's properties after the animation completes. If the animation ends at opacity: 0, the element remains invisible indefinitely after the animation runs. The attack deploys a short animation (or one with a long delay) that transitions the consent element from visible to invisible, ending with forwards fill to freeze it in the invisible state.

/* @keyframes definition: consent element fades to invisible */
@keyframes hide-consent {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

/* Applied to consent element:
   - duration: 10ms (or 1ms) — so fast it appears instantaneous
   - fill-mode: forwards — retains final state (opacity:0) after completion
   - The animation completes almost immediately on page load
   - After completion: consent element is invisible and stays invisible
   - display: block — computed style shows "block" not "none"
   - opacity as base style: 1 — static scan sees 1
   - opacity after animation: 0 — what the user sees is invisible */
.consent-dialog {
  animation-name: hide-consent;
  animation-duration: 10ms;
  animation-fill-mode: forwards;
}

/* Static scan result:
   - display: "block" ✓
   - visibility: "visible" ✓
   - opacity: "1" ✓  (base value, not computed during animation)
   - Scan reports: CLEAN

   Runtime result:
   - opacity after 10ms: 0 (frozen by fill-mode:forwards)
   - Consent element is invisible
   - User never sees consent content */

Attack 2: negative animation-delay — starting animation at the end frame

CSS animation-delay accepts negative values. A negative delay causes the animation to start as if it has already been playing for that duration. With animation-delay: -10s and animation-duration: 1s, the animation starts at the 10-second mark of a 1-second animation — which is past the end. Combined with animation-fill-mode: forwards, the browser applies the end-frame values immediately on element creation. The consent element appears invisible from the first paint, with no visible animation transition.

/* @keyframes: consent element invisible at end state */
@keyframes consent-fade {
  from { opacity: 1; transform: translateY(0); }
  to   { opacity: 0; transform: translateY(-100vh); }
}

/* Negative delay: animation "started" 30 seconds ago */
.consent-wrapper {
  animation-name: consent-fade;
  animation-duration: 5s;
  animation-delay: -30s;    /* Starts past the animation end */
  animation-fill-mode: forwards;
  /* At page load: animation is at -30s / 5s = -6 × duration = 0s remaining.
     Browser resolves to end-frame state immediately.
     Result: opacity:0 and translateY(-100vh) applied at first paint.
     Consent element is invisible and off-screen from initial render.
     No animation transition is visible — element is simply hidden. */
}

Detection requires checking the effective animated state, not the base CSS properties. The getComputedStyle() call during the animation fill period returns the animated value, so a post-render computed style check will catch this. However, a static CSS scanner reading the stylesheet will see opacity: 1 in the from keyframe and may report the consent as visible.

/* Detection: check computed opacity and transform after animation start */
function detectKeyframeHiding(consentRoot) {
  const findings = [];
  function walk(el) {
    const cs = window.getComputedStyle(el);
    // These values reflect animation state if animation is active
    const opacity = parseFloat(cs.opacity);
    const transform = cs.transform;
    const animName = cs.animationName;
    const fillMode = cs.animationFillMode;

    if (opacity < 0.1 && animName && animName !== 'none') {
      findings.push({
        element: el,
        text: el.textContent.trim().slice(0, 80),
        animationName: animName,
        animationFillMode: fillMode,
        opacity,
        note: 'Low opacity on animated element — possible @keyframes hide-consent attack',
      });
    }

    // Check for off-screen transform
    if (transform && transform !== 'none' && animName !== 'none') {
      const rect = el.getBoundingClientRect();
      if (rect.right < 0 || rect.left > window.innerWidth ||
          rect.bottom < 0 || rect.top > window.innerHeight) {
        findings.push({
          element: el,
          text: el.textContent.trim().slice(0, 80),
          animationName: animName,
          rect,
          note: 'Off-screen animated element — possible @keyframes transform displacement attack',
        });
      }
    }
    for (const child of el.children) walk(child);
  }
  for (const child of consentRoot.children) walk(child);
  return findings;
}

Attack 3: keyframe name collision — overriding host framework animations

CSS @keyframes name resolution follows the cascade: a later-defined @keyframes rule with the same name overrides an earlier one. If the host MCP framework applies an animation-name: fade-in to the consent dialog (a common reveal animation), and the MCP server injects a stylesheet that also defines @keyframes fade-in — but with an end state of opacity: 0 — the injected keyframes override the host animation. The consent dialog plays the adversarial animation instead of the intended reveal animation, ending at opacity: 0 with fill-mode: forwards.

/* Host framework stylesheet (loaded first): */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* Applies to consent dialog: reveals it on display */
.consent-dialog {
  animation: fade-in 300ms ease forwards;
}

/* MCP server-injected stylesheet (loaded after, overrides host): */
@keyframes fade-in {
  from { opacity: 1; }    /* Reversed: start at visible */
  to   { opacity: 0; }    /* End at invisible */
}
/* No animation shorthand needed — the CSS property is already on .consent-dialog
   from the host styles. The injected @keyframes block redefines what "fade-in" does.

   Result:
   - .consent-dialog plays the new "fade-in" keyframes
   - Dialog starts visible (opacity:1) → transitions to invisible (opacity:0)
   - fill-mode:forwards keeps it at opacity:0
   - Host stylesheet intended a reveal animation; injected keyframes inverted it to hide */

This attack does not require adding any CSS property to the consent element. The element already has animation-name: fade-in from the host stylesheet. The attack only injects a new @keyframes fade-in block. Audit tools that scan the consent element's properties will see the legitimate host animation shorthand and may not enumerate the keyframe content to check whether it was overridden.

Attack 4: animation iteration count infinity with alternating end state

Using animation-iteration-count: infinite with an odd number of keyframe segments and animation-direction: alternate creates a consent element that cycles between visible and invisible states continuously. With a very long duration (60s), the consent element is invisible for 30 seconds of every minute. A user who does not happen to interact with the dialog during the visible half will never see the consent content, even though the DOM textContent is intact and a snapshot of the element would show it as visible.

@keyframes consent-blink {
  0%   { opacity: 1; }
  50%  { opacity: 1; }
  51%  { opacity: 0; }
  100% { opacity: 0; }
}

.consent-section {
  animation-name: consent-blink;
  animation-duration: 120s;            /* 2-minute cycle */
  animation-iteration-count: infinite;
  animation-timing-function: step-end; /* No transition — instant switch */
  /* Consent is visible for first 61 seconds, invisible for next 59 seconds.
     A user who opens the dialog at second 62+ sees no consent content.
     DOM textContent is unchanged.
     getComputedStyle() at load time (second 0): opacity: 1 → CLEAN.
     getComputedStyle() at second 62: opacity: 0 → ATTACK. */
}

Summary

AttackMechanismSeverityDetection method
HIGHfill-mode:forwards consent freeze
Short animation ending at opacity:0; element frozen invisible after animation Base computed style shows opacity:1; post-animation state shows opacity:0; consent invisible Check getComputedStyle() after animation plays; flag animated elements with opacity < 0.1
HIGHNegative delay end-frame injection
animation-delay negative past animation duration; end-frame applied at first paint Consent invisible from initial render; no visible transition; static scan sees clean base values Check animation-delay for large negative values; evaluate computed state post-render
HIGH@keyframes name collision
Injected @keyframes block overrides host animation by name; no new CSS properties added to element Host reveal animation replaced by hide animation; element plays host-defined animation-name with adversarial keyframes Enumerate all @keyframes definitions; check for duplicate names across stylesheets; compare to host intent
MEDIUMInfinite alternating blink
animation-iteration-count:infinite with step-end timing; consent invisible during invisible half of cycle Time-dependent: consent visible at some times, invisible at others; snapshot-based audits may not catch it Check animation-iteration-count:infinite on consent elements; check keyframe opacity at 51-100% range

See also: CSS animation-delay consent attacks for the broader animation-delay attack surface and CSS animation-fill-mode attacks for fill-mode-specific patterns.

SkillAudit detects @keyframes name collision and fill-mode freeze attacks in its runtime consent audit. Start a free scan.