← Blog

MCP server CSS animation-fill-mode consent security

Four SA-CSS-AFM consent attack patterns using animation-fill-mode — the CSS property that controls whether keyframe styles persist before the animation starts (backwards) or after it ends (forwards). Applied to consent text, these attacks produce permanently-invisible or pre-delay-invisible rendering while el.style.opacity returns an empty string.

By SkillAudit  |  29 Oct 2026

CSS animations apply keyframe styles only during their active period (between start time and end time). Outside the active period, the element reverts to its own CSS property values. animation-fill-mode overrides this reversion: forwards keeps the animation’s final keyframe applied after completion; backwards applies the animation’s initial keyframe during the delay period before it starts; both combines both. These fill-mode behaviors are post-style-resolution — they are not reflected in el.style.opacity (which reads the inline style) and may not be visible to a detector that inspects the CSS opacity property at page-load time before the animation completes.

SA-CSS-AFM-001: animation-fill-mode=forwards — invisible state freeze after 100ms

A short-duration animation transitions consent text opacity from 1 (legible) to 0.03 (near-invisible) over 100 milliseconds. animation-fill-mode: forwards freezes the element at the final keyframe (opacity: 0.03) after the animation completes. The consent text becomes permanently near-invisible 100ms after the dialog opens.

@keyframes consent-fade {
  from { opacity: 1; }
  to   { opacity: 0.03; }
}

.consent-text {
  animation-name: consent-fade;
  animation-duration: 100ms;
  animation-fill-mode: forwards;      /* freeze at opacity:0.03 after 100ms */
  animation-iteration-count: 1;
  animation-timing-function: linear;
}

/* Timeline:
   t=0ms:   opacity:1.0 (legible — from keyframe)
   t=50ms:  opacity~0.515 (mid-animation)
   t=100ms: opacity:0.03 (to keyframe)
   t=101ms: opacity:0.03 (FROZEN — fill-mode:forwards applied)
   t=∞:     opacity:0.03 (permanently invisible)

   WCAG at t≥100ms: perceived_L = 0.03 × 0.004 + 0.97 × 1.0 ≈ 0.970
   Contrast vs white: (1.05)/(0.970+0.05) = 1.05/1.020 ≈ 1.03:1 — FAIL

   Detection evasion:
   el.style.opacity === ""        (inline style: empty — no inline opacity set)
   getComputedStyle(el).opacity   (resolves animated value; returns "0.03" after t≥100ms)
   A detector reading el.style.opacity sees "" → does not flag.
   A detector reading getComputedStyle MUST execute AFTER the 100ms window. */

/* Detection: */
function detectAnimationFillForwards(el) {
  const cs = getComputedStyle(el);
  const animName  = cs.animationName;
  const fillMode  = cs.animationFillMode;
  const duration  = parseFloat(cs.animationDuration) || 0;
  /* After animation completes, computed opacity reflects frozen state */
  if (fillMode === 'forwards' || fillMode === 'both') {
    /* Inspect keyframes via CSSAnimation API */
    for (const anim of el.getAnimations()) {
      if (anim.effect) {
        const kfs = anim.effect.getKeyframes();
        const lastKf = kfs[kfs.length - 1];
        if (lastKf && parseFloat(lastKf.opacity ?? '1') < 0.15)
          return { risk: 'CRITICAL', id: 'SA-CSS-AFM-001',
            detail: 'fill-mode=forwards; final opacity=' + lastKf.opacity };
      }
    }
  }
  return null;
}
CRITICAL
SA-CSS-AFM-001 — animation-fill-mode=forwards freezes consent text at opacity:0.03 (WCAG 1.03:1) 100ms after dialog open. el.style.opacity empty; getComputedStyle returns "0.03" post-animation. Detection: inspect final keyframe opacity via getAnimations() API.

SA-CSS-AFM-002: animation-fill-mode=backwards + long delay — pre-delay invisible

animation-fill-mode: backwards applies the initial keyframe (the from keyframe) to the element during the animation-delay period, before the animation starts. With animation-delay: 8s and a from keyframe of opacity: 0.03, the consent text is invisible for 8 seconds — the entire window during which a user is expected to read and confirm.

@keyframes consent-reveal {
  from { opacity: 0.03; }   /* invisible at start of animation */
  to   { opacity: 1; }      /* legible after animation completes */
}

.consent-text {
  animation-name: consent-reveal;
  animation-duration: 500ms;
  animation-delay: 8s;                /* 8-second delay before animation */
  animation-fill-mode: backwards;     /* apply from-keyframe during delay */
}

/* Timeline:
   t=0s:    opacity:0.03 — backwards fill applies from-keyframe (WCAG 1.03:1)
   t=8s:    animation starts (opacity:0.03 → 1 over 500ms)
   t=8.5s:  opacity:1.0 — animation complete, backwards fill no longer active
   t=∞:     opacity:1.0 (element returns to own CSS value)

   Attack window: 8 seconds (t=0 to t=8s).
   Confirmation UX design: "I Agree" button enabled immediately at t=0.
   User sees blank consent area and clicks button within 8-second window.
   After 8s, if user re-opens dialog, text is now visible — obscures investigation.

   Detection evasion:
   getComputedStyle(el).animationDelay === "8s"  (visible but requires inspection)
   getComputedStyle(el).opacity resolves to "0.03" during delay period
   A pre-render static detector reads the CSS declaration but may not simulate delay. */
CRITICAL
SA-CSS-AFM-002 — animation-fill-mode=backwards + delay=8s applies opacity:0.03 for 8 seconds pre-animation. WCAG 1.03:1 during entire reading window. Detection: check fill-mode=backwards or both AND first-keyframe opacity < 0.15 AND animation-delay > 1s.

SA-CSS-AFM-003: animation-fill-mode=both — invisible before and after, briefly visible

Combining backwards and forwards into both creates an attack with two invisible windows: pre-delay (initial keyframe applied) and post-animation (final keyframe frozen). With a 3-second delay and 50ms duration, the consent text is invisible for 3 seconds, legible for 50ms, then invisible again.

@keyframes consent-flash {
  from { opacity: 0.03; }   /* invisible pre-delay and during animation start */
  to   { opacity: 0.03; }   /* also invisible at end — frozen invisible post-anim */
}

/* Alternatively with standard names visible→invisible: */
@keyframes consent-hide-both {
  from { opacity: 1; }
  to   { opacity: 0.03; }
}

.consent-text {
  animation-name: consent-hide-both;
  animation-duration: 50ms;           /* barely perceptible flash of legibility */
  animation-delay: 3s;
  animation-fill-mode: both;          /* invisible during 3s delay AND after 50ms */
}

/* Timeline:
   t=0:        opacity:1 (from-keyframe via backwards fill — wait, from=1 here)
   Actually: backwards fill for "both" applies first keyframe (from=opacity:1)
   during delay. Then animation plays 1→0.03 in 50ms. Forwards freeze at 0.03.

   More effective — use both with from=0.03: */

@keyframes consent-hide-both-v2 {
  from { opacity: 0.03; }
  to   { opacity: 0.03; }
}

/* With both keyframes at 0.03: element invisible at all times.
   backwards fill: opacity:0.03 during delay.
   animation plays 0.03→0.03 (no visible change).
   forwards freeze: opacity:0.03 after animation.
   Consent text permanently invisible; animation is a structural artifact only.

   WCAG: 1.03:1 throughout.
   Evades: "has animation" checks — animation-name is set, animation is trivially present. */
CRITICAL
SA-CSS-AFM-003 — animation-fill-mode=both with both keyframes at opacity:0.03. Consent text invisible before, during, and after animation. Evades single-time-point opacity checks. Detection: getAnimations() on consent ancestor; inspect all keyframes for opacity < 0.15.

SA-CSS-AFM-004: JS mousedown activates animation — TOCTOU invisible lock

The consent text initially has no animation (fully legible). At mousedown on the confirm button, JavaScript sets animation-name on the consent element, triggering a 100ms animation that transitions opacity 1→0.03. animation-fill-mode: forwards then freezes the element permanently invisible. The confirmation click arrives at mouseup, after the 100ms animation has likely completed (mouse down–up cycle is typically 50–150ms).

.consent-text {
  /* Initially: no animation — consent text fully visible */
  animation-fill-mode: forwards;     /* pre-set fill mode, no active animation yet */
  animation-duration: 100ms;
  animation-timing-function: linear;
}

@keyframes consent-lock {
  from { opacity: 1; }
  to   { opacity: 0.03; }
}

document.getElementById('confirm-btn').addEventListener('mousedown', () => {
  /* TOCTOU: activate animation at mousedown.
     By mouseup (50-150ms later), animation is complete or near-complete.
     fill-mode=forwards freezes opacity at 0.03. */
  document.querySelector('.consent-text').style.animationName = 'consent-lock';
});

/* At mousedown:  consent text begins transition 1→0.03 (100ms)
   At mouseup:    opacity ≈ 0.03 (animation complete if ≥100ms) or partial
   After click:   opacity:0.03 frozen (fill-mode=forwards)
   Consent text was legible at read time; invisible at confirmation time.

   el.style.animationName changes at mousedown (set via JS inline style).
   MutationObserver on style attribute or inline style animationName detects this.
   Detection: watch for animation-name injection at mousedown on consent elements. */

const mo = new MutationObserver(muts => {
  for (const m of muts) {
    if (m.type === 'attributes' && m.attributeName === 'style') {
      const el = m.target;
      const fillMode = el.style.animationFillMode || getComputedStyle(el).animationFillMode;
      const animName = el.style.animationName;
      if (animName && (fillMode === 'forwards' || fillMode === 'both')) {
        /* Check final keyframe opacity */
        reportFinding('CRITICAL', 'SA-CSS-AFM-004',
          { animationName: animName, fillMode, note: 'animation injected at interaction time' });
      }
    }
  }
});
document.querySelectorAll('[class*="consent"]').forEach(el =>
  mo.observe(el, { attributes: true, attributeFilter: ['style', 'class'] }));
CRITICAL
SA-CSS-AFM-004 — JS injects animation-name at mousedown; fill-mode=forwards freezes consent invisible by mouseup. TOCTOU: legible at rest, invisible at confirmation. MutationObserver on consent element style attribute detects animation-name injection.

Summary of SA-CSS-AFM findings

IDSeverityfill-modeMechanismWCAG
SA-CSS-AFM-001 CRITICAL forwards Animate 1→0.03 in 100ms; freeze at 0.03 permanently 1.03:1 after 100ms
SA-CSS-AFM-002 CRITICAL backwards from-keyframe (0.03) applied during 8s delay window 1.03:1 for 8s pre-animation
SA-CSS-AFM-003 CRITICAL both Both keyframes at 0.03; invisible before, during, and after 1.03:1 throughout
SA-CSS-AFM-004 CRITICAL forwards JS injects animation-name at mousedown; frozen invisible by mouseup 1.03:1 at click

Related pages