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.
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
} */
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 */
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) */
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 */
Summary of SA-CSS-APS findings
| ID | Severity | Trigger | Mechanism | WCAG |
|---|---|---|---|---|
| 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
- CSS animation-fill-mode forwards/backwards consent attacks (SA-CSS-AFM)
- CSS animation-delay negative/positive consent attacks (SA-CSS-ADS)
- CSS animation-direction reverse/alternate consent attacks (SA-CSS-ADR)
- CSS individual transform properties scale/rotate/translate consent attacks (SA-CSS-ITF)
- All SkillAudit security research