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.
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
} */
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 */
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. */
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 */
Summary of SA-CSS-ADR findings
| ID | Severity | direction | Mechanism | WCAG |
|---|---|---|---|---|
| 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 |