← Blog

MCP server CSS animation-delay consent security

Four SA-CSS-ADS consent attack patterns using animation-delay — the CSS property that offsets when an animation begins. Negative delays start the animation mid-state at page load; positive delays combined with fill-mode: backwards produce an invisible window before the animation starts. Both approaches render consent text at below-WCAG contrast without touching the CSS opacity property.

By SkillAudit  |  29 Oct 2026

A negative animation-delay means the animation clock started in the past: animation-delay: -900ms with animation-duration: 1000ms means the element is rendered as if the animation has been running for 900ms before the page loaded. For an opacity fade from 1 to 0.03, this produces an immediately-visible fixed low-opacity state rather than a visible fade — there is no animation the user can observe, just a consent element that appears at low opacity from the first frame.

SA-CSS-ADS-001: negative animation-delay — render at mid-animation opacity

With animation-delay: -900ms and animation-duration: 1000ms on a linear opacity fade from 1 to 0.03, the page renders the consent text at 90% completion: opacity ≈ 0.127. From the user’s perspective, there is no animation at all — the consent text simply appears at low opacity. The animation-delay property is negative, which is legal per the CSS spec.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  animation-delay: -900ms;            /* start 900ms into the animation */
  animation-fill-mode: forwards;      /* freeze at to-keyframe after 100ms more */
  animation-timing-function: linear;
}

/* At page load (t=0): animation is at progress = 900/1000 = 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 (needs 4.5:1)

   After 100ms more: animation completes at opacity:0.03 (frozen via forwards).

   Detection evasion:
   - animation-duration check: 1000ms seems like a long animation (seems reasonable)
   - No visible transition: user sees a fixed low-opacity state, not an animation
   - el.style.animationDelay requires parsing negative value semantics

   Detection:
   if (parseFloat(cs.animationDelay) < 0) {
     const progress = -parseFloat(cs.animationDelay) / parseFloat(cs.animationDuration);
     const kfs = anim.effect.getKeyframes();
     /* interpolate opacity at `progress` */
   } */
CRITICAL
SA-CSS-ADS-001 — animation-delay=-900ms on 1000ms opacity:1→0.03 fade. Page loads with consent at opacity≈0.127 (WCAG 1.14:1). No visible transition. Frozen at 0.03 after 100ms. Detection: negative animation-delay on consent element; interpolate opacity at |delay|/duration progress point.

SA-CSS-ADS-002: large positive delay + fill-mode=backwards — 15-second invisible window

A large positive animation-delay combined with animation-fill-mode: backwards creates an invisible window: the from keyframe (opacity: 0.03) is applied during the delay period. The consent dialog opens with invisible text that will only become legible after 15 seconds — well past the expected user interaction window.

@keyframes consent-appear {
  from { opacity: 0.03; }   /* invisible — applied during delay via backwards fill */
  to   { opacity: 1; }
}

.consent-text {
  animation-name: consent-appear;
  animation-duration: 500ms;
  animation-delay: 15s;               /* consent invisible for 15 seconds */
  animation-fill-mode: backwards;     /* from-keyframe (0.03) during delay */
}

/* Timeline:
   t=0:       opacity:0.03 (backwards fill: from-keyframe applied during delay)
   t=15s:     animation starts; opacity:0.03→1 over 500ms
   t=15.5s:   opacity:1.0 (legible; fill-mode=backwards no longer active)

   Attack window: 15 seconds of invisible consent text.
   "I Agree" button available immediately at t=0.
   User encounters blank-looking dialog area and proceeds.

   Detection:
   const delay = parseFloat(cs.animationDelay) || 0;
   const fill  = cs.animationFillMode;
   if (delay > 1.0 && (fill === 'backwards' || fill === 'both')) {
     const fromKf = getKeyframes(anim)[0];
     if (parseFloat(fromKf.opacity ?? '1') < 0.15) → CRITICAL SA-CSS-ADS-002
   } */
CRITICAL
SA-CSS-ADS-002 — animation-delay=15s + fill-mode=backwards + from-keyframe opacity:0.03. Consent text invisible for 15 seconds. Detection: animation-delay > 1s AND fill-mode=backwards/both AND first-keyframe opacity < 0.15.

SA-CSS-ADS-003: zero delay + ultra-short duration — completes before readable

Setting animation-delay: 0 and animation-duration: 30ms means the animation completes within two rendering frames (at 60fps, one frame = 16.7ms). animation-fill-mode: forwards then freezes the element at the near-invisible final keyframe. The consent text may never render at full opacity even in the first painted frame.

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

.consent-text {
  animation-name: consent-instant-hide;
  animation-duration: 30ms;          /* completes in ~2 frames at 60fps */
  animation-delay: 0s;
  animation-fill-mode: forwards;
}

/* At 60fps: one frame every 16.7ms.
   Frame 0 (t=0):   animation just started; opacity≈1 (0ms progress)
   Frame 1 (t=17ms): animation 57% complete; opacity≈0.467
   Frame 2 (t=30ms): animation 100% complete → frozen at opacity:0.03

   In practice: the browser may batch the first paint after all scripts run.
   If the animation is set in an inline style block or early script, the
   first PAINTED frame may already show opacity:0.03 (rAF timing).

   Detection: animation-duration < 100ms on consent element → HIGH/CRITICAL. */
HIGH
SA-CSS-ADS-003 — animation-delay=0 + duration=30ms + fill-mode=forwards on opacity:1→0.03 keyframes. Completes within 2 render frames; may never render at full opacity. Detection: animation-duration < 100ms with final-keyframe opacity < 0.15 on consent element.

SA-CSS-ADS-004: JS mousedown injects negative delay — instant mid-state freeze

The consent text initially has no animation. At mousedown, JavaScript simultaneously sets both animation-delay: -50ms and animation-name, starting the animation already 50ms into its 100ms run (at 50% progress, opacity≈0.515). With fill-mode: forwards, the animation completes to opacity:0.03 within the remaining 50ms — before mouseup fires on a typical click.

.consent-text {
  animation-duration: 100ms;
  animation-fill-mode: forwards;
  animation-timing-function: linear;
}

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

document.getElementById('confirm-btn').addEventListener('mousedown', () => {
  const el = document.querySelector('.consent-text');
  el.style.animationDelay = '-50ms';     /* start animation 50% complete */
  el.style.animationName  = 'consent-lock-fast';
  /* At mousedown:  animation is at 50% → opacity≈0.515
     After 50ms:    animation complete → opacity:0.03 frozen
     mouseup fires ≥50ms after mousedown (typical click) → consent invisible */
});

/* Detection: MutationObserver on consent element style attribute;
   flag any animation-name injection with negative animation-delay
   at interaction event time. */
CRITICAL
SA-CSS-ADS-004 — JS at mousedown injects animation-name + animation-delay=-50ms. Animation starts 50% complete; frozen at opacity:0.03 by mouseup. MutationObserver on consent element style attribute; flag negative delay + animation-name injection at mousedown.

Summary of SA-CSS-ADS findings

IDSeverityanimation-delayMechanismWCAG
SA-CSS-ADS-001 CRITICAL -900ms Renders 90% into fade at page load; opacity≈0.127 1.14:1 at load
SA-CSS-ADS-002 CRITICAL +15s fill-mode=backwards; from-keyframe (0.03) for 15s pre-animation 1.03:1 for 15s
SA-CSS-ADS-003 HIGH 0s 30ms duration; completes in 2 frames; may never render legible 1.03:1 after 30ms
SA-CSS-ADS-004 CRITICAL -50ms (injected) JS mousedown injection; negative delay starts mid-state; frozen by mouseup 1.03:1 at click

Related pages