← Blog

MCP server CSS animation-play-state consent security

Four SA-CSS-APS consent attack patterns using animation-play-state — the CSS property that pauses or resumes a running animation. When combined with a negative animation-delay, paused freezes the animation at a specific mid-progress opacity. JavaScript can pause the animation precisely at a near-invisible frame, and CSS :hover selectors can trigger the pause when the user moves the cursor to the confirm button.

By SkillAudit  |  29 Oct 2026

animation-play-state: paused freezes the animation at whatever the current animation time is. Combined with a negative animation-delay, the animation is both started mid-progress and frozen there from the first frame: a static low-opacity rendering that looks like a CSS property assignment but is actually an animation mechanism. Detectors that check animation-play-state === 'running' miss the paused variant entirely.

SA-CSS-APS-001: paused + negative delay — frozen at arbitrary low opacity

Setting animation-play-state: paused and animation-delay: -900ms on a 1000ms opacity fade produces a frozen state at 90% animation progress: opacity≈0.127. The animation never plays; the element simply renders at this computed opacity indefinitely. Unlike a CSS opacity: 0.127 declaration, this state is achieved via an animation mechanism and el.style.opacity returns an empty string.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  animation-delay: -900ms;            /* 90% progress at load time */
  animation-play-state: paused;       /* freeze at 90% progress: opacity≈0.127 */
  animation-timing-function: linear;
  animation-fill-mode: forwards;
}

/* Computed state at page load (animation-delay = -900ms, paused):
   Animation time = |delay| = 900ms
   Progress = 900ms / 1000ms = 0.9
   opacity = 1 + 0.9 × (0.03 - 1) = 1 - 0.873 = 0.127

   WCAG: perceived_L = 0.127 × 0.004 + 0.873 × 1.0 ≈ 0.874
   Contrast: 1.05 / (0.874 + 0.05) ≈ 1.14:1 — FAIL

   el.style.opacity === ""   (not in inline style)
   getComputedStyle(el).animationPlayState === "paused"
   getComputedStyle(el).opacity === "0.127"  (resolves animated paused value)

   Evades: animation-play-state=running check
   Detection:
   if (cs.animationPlayState === 'paused') {
     const pausedOpacity = parseFloat(getComputedStyle(el).opacity);
     if (pausedOpacity < 0.15) → CRITICAL SA-CSS-APS-001
   } */
CRITICAL
SA-CSS-APS-001 — animation-play-state=paused + delay=-900ms frozen at opacity≈0.127 (WCAG 1.14:1). Static low-opacity; el.style.opacity empty. Evades play-state=running checks. Detection: paused state + getComputedStyle opacity < 0.15.

SA-CSS-APS-002: JS setTimeout pause at low-opacity frame

The animation starts running normally (opacity:1→0.03 over 1000ms). JavaScript schedules a setTimeout to set animation-play-state: paused at exactly 500ms, when the animation is at 50% progress (opacity≈0.515). The consent text is then frozen at 50% opacity for the duration of the interaction.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  animation-timing-function: linear;
}

/* On dialog open: */
setTimeout(() => {
  document.querySelector('.consent-text').style.animationPlayState = 'paused';
}, 500);  /* pause at t=500ms → opacity≈0.515 */

/* At t=500ms: opacity = 1 + 0.5 × (0.03-1) = 0.515
   WCAG: perceived_L = 0.515 × 0.215 + 0.485 × 1.0 ≈ 0.596
   Contrast: 1.05 / (0.596 + 0.05) ≈ 1.63:1 — FAIL (needs 4.5:1)

   The pause is triggered via JS style mutation: el.style.animationPlayState is set.
   MutationObserver on style attribute detects the pause injection.

   For more aggressive attack: pause at t=950ms (opacity≈0.081):
   setTimeout(() => el.style.animationPlayState = 'paused', 950);
   opacity = 1 + 0.95 × (0.03-1) = 0.081; WCAG ≈ 1.08:1 */
HIGH
SA-CSS-APS-002 — JS setTimeout pauses animation at t=500ms → opacity≈0.515 (WCAG 1.63:1). More aggressive: pause at t=950ms → opacity≈0.081 (WCAG 1.08:1). MutationObserver on consent element style attribute detects animation-play-state=paused injection.

SA-CSS-APS-003: JS mousedown pause-run-pause — freeze at invisible on click

The consent text is initially paused at opacity:1 (animation time = 0ms, paused). At mousedown, JavaScript sets animation-play-state: running, starting the fade. A second JavaScript timer (or a second requestAnimationFrame) pauses the animation again exactly when it reaches opacity:0.03, freezing the consent text invisible at the precise moment of confirmation.

.consent-text {
  animation-name: consent-fade;    /* keyframes: 1→0.03 */
  animation-duration: 100ms;
  animation-play-state: paused;    /* initially paused at opacity:1 */
  animation-fill-mode: forwards;
}

document.getElementById('confirm-btn').addEventListener('mousedown', () => {
  const el = document.querySelector('.consent-text');
  /* Step 1: start the animation running toward 0.03 */
  el.style.animationPlayState = 'running';
  /* Step 2: 100ms later, animation is complete at opacity:0.03;
     fill-mode=forwards freezes it even without re-pausing */
});

/* TOCTOU timeline:
   Before mousedown: opacity:1 (paused at t=0)
   At mousedown:     animation starts running (opacity begins 1→0.03)
   At mouseup (t≥50ms): animation ≥50% complete → opacity≤0.515
   After 100ms:      animation complete; frozen at opacity:0.03 (fill-mode=forwards)

   Combination with SA-CSS-AFM-004: fill-mode=forwards eliminates need to re-pause.
   The play-state=running injection IS the attack trigger here; play-state=paused
   is the initial state that keeps text legible until mousedown.

   MutationObserver: watch animation-play-state transitions on consent elements.
   paused → running at mousedown → CRITICAL (SA-CSS-APS-003 with fill-mode=forwards) */
CRITICAL
SA-CSS-APS-003 — Paused at opacity:1 initially. JS mousedown triggers running; fill-mode=forwards freezes at 0.03. Consent legible at rest, invisible at mouseup. MutationObserver: watch paused→running transition on consent elements at interaction time.

SA-CSS-APS-004: CSS :hover pauses animation — cursor-position TOCTOU

Pure CSS: the consent text has a running fade animation. A CSS :hover selector on the confirm button applies animation-play-state: paused to the consent text via a sibling or descendant selector. When the user moves their cursor to the confirm button to click it, the consent animation is paused at the current (possibly low-opacity) frame.

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

.consent-text {
  animation-name: consent-slow-fade;
  animation-duration: 3000ms;   /* slow fade over 3 seconds */
}

/* Pure CSS: hovering confirm button pauses the animation */
.consent-dialog:has(#confirm-btn:hover) .consent-text {
  animation-play-state: paused;
}

/* Attack scenario:
   Dialog opens at t=0 (consent text animating: opacity 1 → 0.03 over 3s).
   At t=2000ms: user moves cursor to confirm button to click.
   :hover triggers animation-play-state: paused.
   Animation frozen at t=2000ms progress: opacity = 1 + (2/3)*(0.03-1) ≈ 0.353.
   WCAG: 1.05 / (0.353×0.353 + 0.05) ≈ 1.05 / 0.175 ≈ 6.0:1... wait.

   More precise:
   opacity=0.353 text color rgb(17,17,17): perceived L of text over white:
   text_L = 0.004 (for #111111 ≈ dark)
   blend: L_visible = 0.353 × 0.004 + (1-0.353) × 1.0 = 0.001 + 0.647 = 0.648
   Contrast: (1.05) / (0.648 + 0.05) = 1.05 / 0.698 ≈ 1.50:1 — FAIL.

   At t=2800ms (user hovers at 93% of fade): opacity≈0.093.
   blend_L = 0.093×0.004 + 0.907×1.0 ≈ 0.908
   Contrast: 1.05/0.958 ≈ 1.10:1 — FAIL.

   No JavaScript; pure CSS + user interaction timing.
   Detection: :has() selectors that apply animation-play-state:paused to consent
   ancestors when button:hover is active → HIGH SA-CSS-APS-004 */
HIGH
SA-CSS-APS-004 — CSS :has(#confirm-btn:hover) .consent-text { animation-play-state: paused }. Hovering confirm button freezes consent animation at current opacity (WCAG 1.50:1–1.10:1 depending on timing). Pure CSS, no JavaScript. Detection: :has() selectors pausing animation-play-state on consent elements.

Summary of SA-CSS-APS findings

IDSeverityTriggerMechanismWCAG
SA-CSS-APS-001 CRITICAL Static CSS paused + delay=-900ms; frozen at opacity:0.127 from load 1.14:1 indefinitely
SA-CSS-APS-002 HIGH JS setTimeout Pause at t=500ms mid-fade; opacity≈0.515 (or 0.081 at t=950ms) 1.63:1 (1.08:1)
SA-CSS-APS-003 CRITICAL JS mousedown paused→running at mousedown; fill-mode=forwards freezes at 0.03 1.03:1 at click
SA-CSS-APS-004 HIGH CSS :hover :has(button:hover) pauses consent animation; frozen at hover-time opacity 1.10–1.50:1

Related pages