← Blog

MCP server CSS animation-direction consent security

Four SA-CSS-ADR consent attack patterns using animation-direction — the CSS property that controls whether an animation plays forward, backward, or alternates. reverse combined with fill-mode: backwards applies the to keyframe during the delay period, creating an invisible pre-delay window. alternate with infinite iterations makes consent text flicker between legible and invisible every half-cycle.

By SkillAudit  |  29 Oct 2026

The animation-direction property has four values: normal (play from→to), reverse (play to→from), alternate (odd iterations forward, even iterations backward), and alternate-reverse (odd iterations backward, even iterations forward). The security-relevant interaction: fill-mode: backwards applies the starting value of the animation during the delay period. For a reversed animation, the starting position is the to keyframe. This means direction: reverse + fill-mode: backwards + delay: 8s applies the to keyframe — whichever opacity is set there — for 8 seconds before the animation plays.

SA-CSS-ADR-001: direction=reverse + fill-mode=backwards — to-keyframe applied during delay

With animation-direction: reverse, a reversed animation starts at the to keyframe and plays toward the from keyframe. fill-mode: backwards applies the initial state of the animation during the delay — which, for a reversed animation, is the to keyframe value. Setting to { opacity: 0.03 } and animation-delay: 8s makes the consent text invisible for 8 seconds before the animation even starts playing.

@keyframes consent-dir {
  from { opacity: 1;    }   /* reversed animation ends here */
  to   { opacity: 0.03; }   /* reversed animation STARTS here — applied during delay */
}

.consent-text {
  animation-name: consent-dir;
  animation-duration: 1000ms;
  animation-delay: 8s;
  animation-direction: reverse;       /* to→from; starts at to=0.03 */
  animation-fill-mode: backwards;     /* apply starting state (to=0.03) during delay */
}

/* Timeline:
   t=0 to t=8s:   opacity:0.03 (backwards fill applies to-keyframe as starting state)
                  WCAG: 1.03:1 — FAIL — ATTACK WINDOW
   t=8s:          animation begins playing in reverse (0.03 → 1 over 1000ms)
   t=9s:          opacity:1 (animation complete; backwards fill no longer active)
   After t=9s:    opacity:1 (element's own CSS value)

   fill-mode=backwards semantics for reversed animation:
   "The starting value of a reversed animation is the `to` keyframe."
   Per CSS Animations L1 spec §4.6: when direction is reverse (or alternate on even
   iteration), fill-mode=backwards applies the 100%-position keyframe.

   Detection:
   const dir  = cs.animationDirection;     // "reverse"
   const fill = cs.animationFillMode;      // "backwards" or "both"
   const delay = parseFloat(cs.animationDelay) || 0;
   if ((dir === 'reverse') && (fill === 'backwards' || fill === 'both') && delay > 1) {
     const lastKf = getKeyframes(anim)[kfs.length - 1];   /* to-keyframe */
     if (parseFloat(lastKf.opacity ?? '1') < 0.15) → CRITICAL SA-CSS-ADR-001
   } */
CRITICAL
SA-CSS-ADR-001 — animation-direction=reverse + fill-mode=backwards + delay=8s. to-keyframe (opacity:0.03) applied during 8-second delay. WCAG 1.03:1 for 8s. Detection: direction=reverse + fill=backwards/both + delay>1s + to-keyframe opacity<0.15.

SA-CSS-ADR-002: direction=alternate + infinite — consent flicker loop

With animation-direction: alternate and animation-iteration-count: infinite, the animation plays forward (opacity:1→0.03) then backward (opacity:0.03→1) continuously. Every half-cycle (one iteration half), the consent text is at or near opacity:0.03. If the confirmation UI is designed so the button appears during an invisible phase, the user clicks without reading.

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

.consent-text {
  animation-name: consent-flicker;
  animation-duration: 600ms;          /* one full cycle = 600ms */
  animation-direction: alternate;     /* forward then backward, repeat */
  animation-iteration-count: infinite;
  animation-timing-function: linear;
}

/* Timeline per cycle:
   t=0-300ms:   opacity: 1 → 0.03 (forward half: legible → invisible)
   t=300-600ms: opacity: 0.03 → 1  (backward half: invisible → legible)
   t=600-900ms: opacity: 1 → 0.03  (second forward, etc.)
   ...

   At t=300ms (end of forward half): opacity=0.03 (WCAG 1.03:1 — FAIL).
   Attack: consent dialog opens; "I Agree" button enabled at t=300ms.
   300ms into the animation, text is at minimum opacity. Button click window
   aligned with invisible phase.

   Also: at 600ms intervals, consent text is fully unreadable for brief instants.
   Any scanner that reads opacity at a single time may see opacity:1 (legible phase).

   Detection:
   - animation-direction=alternate or alternate-reverse on consent element
   - with animation-iteration-count=infinite (or large number)
   - to-keyframe opacity < 0.15
   → HIGH: consent text reaches below-WCAG opacity during infinite animation */
HIGH
SA-CSS-ADR-002 — animation-direction=alternate + infinite iteration. Consent flickers from opacity:1 to 0.03 every 300ms. WCAG 1.03:1 at half-cycle peaks. Detection: direction=alternate/alternate-reverse + infinite iteration + to-keyframe opacity<0.15 on consent element.

SA-CSS-ADR-003: direction=alternate-reverse + fill-mode=both — invisible first frame

With animation-direction: alternate-reverse, the first iteration plays in the reverse direction: from the to keyframe toward the from keyframe. fill-mode: both applies the starting value of the first iteration during the delay and freezes the ending value after the last iteration. The starting value of the first reverse iteration is the to keyframe (opacity: 0.03), so the element renders invisible from the very first frame when combined with a non-zero delay.

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

.consent-text {
  animation-name: consent-alt-rev;
  animation-duration: 2000ms;
  animation-delay: 5s;
  animation-direction: alternate-reverse;
  animation-fill-mode: both;          /* backwards: to-keyframe during 5s delay */
  animation-iteration-count: 1;       /* one iteration (reverse), then freeze */
}

/* Timeline:
   t=0 to t=5s:   opacity:0.03 (both-fill backwards: to-keyframe during delay)
                  WCAG 1.03:1 — INVISIBLE
   t=5s:          animation plays alternate-reverse iteration 1 (reverse: 0.03→1)
   t=7s:          animation ends (opacity:1) — forwards-fill freezes at 1.0
   (Iteration 1 in alternate-reverse is reversed: starts at to=0.03, ends at from=1)

   Attack window: 5 seconds (t=0 to t=5s).
   After 7s: text is legible and frozen.
   Same attack structure as SA-CSS-ADR-001 but using alternate-reverse + both
   instead of reverse + backwards — evades direction=reverse specific detection. */
CRITICAL
SA-CSS-ADR-003 — animation-direction=alternate-reverse + fill-mode=both + delay=5s. to-keyframe (opacity:0.03) applied during 5s delay. WCAG 1.03:1 for 5s. Evades direction=reverse-specific checks. Detection: alternate-reverse + fill=both/backwards + delay>1s + to-keyframe opacity<0.15.

SA-CSS-ADR-004: JS mousedown toggles direction — immediate invisible on click

A consent element has a running normal direction animation (opacity:1 throughout). At mousedown, JavaScript changes animation-direction to reverse and resets the animation. The reversed animation starts at the to keyframe (opacity:0.03), making the consent text immediately invisible at the confirmation click.

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

/* Initial state: animation-direction=normal, effectively identity (opacity stays 1) */
.consent-text {
  animation-name: consent-hold;
  animation-duration: 10s;
  animation-direction: normal;
  animation-iteration-count: infinite;
}

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

document.getElementById('confirm-btn').addEventListener('mousedown', () => {
  const el = document.querySelector('.consent-text');
  /* Switch to a reverse animation that starts at opacity:0.03 */
  el.style.animationName      = 'consent-reverse-hide';
  el.style.animationDirection = 'reverse';     /* starts at to=0.03 */
  el.style.animationDuration  = '5000ms';      /* slow fade from 0.03→1 */
  el.style.animationDelay     = '0s';
  el.style.animationFillMode  = 'none';
  /* Force animation restart by removing and re-adding name */
  el.style.animationName = 'none';
  requestAnimationFrame(() => { el.style.animationName = 'consent-reverse-hide'; });
  /* At mousedown: consent immediately at opacity:0.03 (start of reversed animation) */
});

/* MutationObserver: watch style attribute on consent element for
   animation-direction=reverse injection at interaction time */
CRITICAL
SA-CSS-ADR-004 — JS at mousedown injects animation-direction=reverse. Reversed animation starts at to-keyframe (opacity:0.03) immediately. Consent invisible at click time. MutationObserver on consent element style attribute for animation-direction changes at interaction.

Summary of SA-CSS-ADR findings

IDSeveritydirectionMechanismWCAG
SA-CSS-ADR-001 CRITICAL reverse fill=backwards applies to-keyframe (0.03) for 8s delay 1.03:1 for 8s
SA-CSS-ADR-002 HIGH alternate Infinite flicker; opacity:0.03 every 300ms; aligned with button reveal 1.03:1 at peaks
SA-CSS-ADR-003 CRITICAL alternate-reverse fill=both applies to-keyframe (0.03) during 5s delay 1.03:1 for 5s
SA-CSS-ADR-004 CRITICAL reverse (injected) JS mousedown injects direction=reverse; starts at opacity:0.03 immediately 1.03:1 at click

Related pages