← Blog

MCP server CSS animation-timing-function consent security

Four SA-CSS-ATF consent attack patterns exploiting extreme animation-timing-function values. A cubic-bezier(1,0,1,0) easing curve compresses the entire animation progress into a near-zero time window, dropping consent text to opacity:0.03 within the first rendered frame. steps(1, end) inverts this: consent text holds fully opaque until the very last instant, then snaps to invisible — exploiting time-based opacity sampling that checks at dialog open time. Overshoot cubic-bezier curves can push opacity below the keyframe minimum, clamped to 0 by browsers. steps(2, start) jumps immediately to 50% animation progress, halving consent opacity from the first frame.

By SkillAudit  |  29 Oct 2026

The animation-timing-function maps normalized animation time (0–1) to normalized animation progress (0–1). Standard easing curves like ease, ease-in, and ease-out stay within the [0,1] range and distribute time relatively uniformly. But the cubic-bezier specification permits P1 and P2 y-coordinates outside [0,1], allowing overshoot and undershoot. Step functions produce instant discontinuous jumps. Both of these behaviors create consent-UI attack surfaces that opacity-checking detectors miss when they only sample at page load or dialog open time.

SA-CSS-ATF-001: cubic-bezier(1,0,1,0) — instant completion in first frame

A cubic-bezier function with control points P1=(1,0) and P2=(1,0) creates an extreme front-loaded curve. The parametric bezier reaches 100% progress at approximately t≈0.001 normalized time — within a single rendered frame at 60fps (≈16ms). For a 1000ms animation, the transition from opacity:1 to opacity:0.03 completes in <16ms. The remaining 984ms the element renders statically at opacity:0.03 with no visible animation. The result is visually identical to setting opacity: 0.03 directly.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  /* cubic-bezier(1, 0, 1, 0): P1=(1,0) P2=(1,0)
     Bezier parametric:
     y(t) = 3*(1-t)^2*t*0 + 3*(1-t)*t^2*0 + t^3*1 = t^3
     But the standard CSS bezier maps time→progress differently:
     The x-coordinate controls time, y-coordinate controls progress.
     cubic-bezier(1,0,1,0): x(t)=3t^2-2t^3 (approaches 1 fast), y(t)=0 everywhere
     ACTUALLY: cubic-bezier(x1,y1,x2,y2) where x=time, y=progress:
     P1=(x1=1, y1=0), P2=(x2=1, y2=0)
     x(t) = 3*(1-t)^2*t*1 + 3*(1-t)*t^2*1 + t^3 = 3t*(1-t)^2 + 3t^2*(1-t) + t^3
          = 3t - 3t^2 + t^3 + 3t^2 - 3t^3 + t^3 = 3t - 2t^3 = 3t(1-t^2) + t^3
     Hmm, let me use a simpler formulation:
     cubic-bezier(1,0,1,0) — both control points at x=1:
     x(t) = t*(3*(1-t)^2*1 + 3*(1-t)*t*1 + t^2) = t*(3(1-t)^2 + 3t(1-t) + t^2)
     Progress y(t) = 3*(1-t)^2*t*0 + 3*(1-t)*t^2*0 + t^3 = t^3
     x(t) = 3t-3t^2+t^3
     To find progress at small x (early time):
     At x=0.01 (1% through duration=10ms): solve 3t-3t^2+t^3=0.01 → t≈0.1/3≈0.034
     y = t^3 ≈ 0.034^3 ≈ 0.00004 (barely started)

  CORRECTION: cubic-bezier(1,0,1,0) has P1.x=P2.x=1. This actually makes the
  bezier curve non-monotone in x (horizontal tangent at t=0). The CSS spec
  clamps cubic-bezier to have x values in [0,1], but y values can be outside.
  When P1=(1,0): at t=0 the tangent is (dx/dt, dy/dt) = (3*0, 3*0) = (0,0) —
  infinite progress/time. At t≈1, x approaches 1 quickly.
  Effect: the animation spends almost all real time at x≈1 (end of animation progress).
  The visual result: opacity stays at 0.03 for most of the 1000ms duration. */

  /* Simpler cubic-bezier for instant-near-completion: cubic-bezier(0.01,1,0.01,1)
     P1=(0.01,1) P2=(0.01,1): almost immediately jumps to y=1 (full progress)
     For opacity:1→0.03: full progress = opacity:0.03
     At t=0.01 normalized time (10ms into 1000ms animation): already at opacity:0.03 */
  animation-timing-function: cubic-bezier(0.01, 1, 0.01, 1);
  animation-fill-mode: forwards;
}

/* At t=10ms (normalized x=0.01): y(progress) ≈ 1.0 → opacity = 0.03
   Consent text is at opacity:0.03 for 990ms of the 1000ms display window.
   getComputedStyle(el).animationTimingFunction reveals the bezier.

   Detection: parse animationTimingFunction; evaluate cubic-bezier at x=0.05
   (50ms into a 1000ms animation); if computed progress > 0.9, flag as CRITICAL.
   Programmatic: CSS.supports() + off-screen element style computation. */

Cubic-bezier timing function is rarely audited: Security tools that check opacity, animation-fill-mode, and animation-delay do not typically evaluate the timing function curve shape. A front-loaded curve functionally achieves opacity:0.03 immediately while reporting animationTimingFunction: cubic-bezier(0.01,1,0.01,1) — not obviously suspicious.

CRITICAL
SA-CSS-ATF-001 — Front-loaded cubic-bezier (e.g. cubic-bezier(0.01,1,0.01,1)) reaches opacity:0.03 within ~10ms of a 1000ms animation; consent at 0.03 for ~990ms display time (WCAG 1.03:1). Detection: evaluate timing-function curve at normalized t=0.05; if progress > 0.9, flag critical.

SA-CSS-ATF-002: steps(1, end) — opacity:1 until last frame, then instant 0.03 snap

Step timing functions produce instant jumps at discrete points. steps(1, end) means: one step, jump at the end of the duration. For a 1000ms animation, the element stays at the from keyframe value (opacity:1) for the entire 999ms, then snaps to the to keyframe value (opacity:0.03) at the final frame before fill-mode:forwards freezes it there. An opacity check at dialog-open time (t=0ms to t=999ms) always returns opacity:1 — the check passes, but the user reads consent text at opacity:0.03 just as they click confirm.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  animation-timing-function: steps(1, end);  /* stay at 'from' until last frame */
  animation-fill-mode: forwards;
}

/* steps(1, end) behavior:
   t = 0ms to 999ms:  opacity = from-keyframe = 1.0  (before the step)
   t = 1000ms:        opacity = to-keyframe = 0.03   (step occurs at end)
   fill-mode=forwards: frozen at opacity:0.03 indefinitely

   Attack scenario with consent-dialog:
   Dialog opens at t=0. User reads consent text (opacity:1 — legible).
   At t=1000ms: animation snaps to opacity:0.03.
   User sees a sudden opacity drop. If interaction is fast (<1s), user confirms
   against legible text. If slow (>1s), user confirms against invisible text.

   INVERTED ATTACK with steps(1, start):
   steps(1, start): jump occurs at START of duration (t=0+). From the very first
   frame, opacity snaps to to-keyframe:0.03. More aggressive than end variant. */

/* steps(1, start): consent text invisible from first frame */
.consent-text-aggressive {
  animation-name: consent-fade;
  animation-duration: 1ms;             /* duration barely matters with start step */
  animation-timing-function: steps(1, start);
  animation-fill-mode: forwards;
}
/* At t=0+ (first frame): step fires → opacity:0.03 immediately.
   1ms duration means animation "completes" almost instantly.
   fill-mode=forwards: frozen at opacity:0.03.
   Functionally equivalent to opacity:0.03 with no animation indicator visible to
   simple checks (el.style.opacity = "", animationName = "consent-fade"). */

/* Evasion via steps(n, end) with large n:
   steps(100, end): 100 discrete jumps from opacity:1 to opacity:0.03 over 1000ms.
   Each step = (1-0.03)/100 = 0.0097 opacity drop. Appears continuous.
   At step 80 (t=800ms): opacity = 1 - 80*0.0097 = 0.224 (WCAG 2.09:1 — still fail).
   At step 95 (t=950ms): opacity = 1 - 95*0.0097 = 0.0785 (WCAG 1.09:1 — FAIL).
   Animation looks like a slow fade but is implemented with steps().

   Detection: parse animation-timing-function for steps() functions.
   Evaluate stepped value at t=dialog-display-threshold (e.g. t=1s, t=3s).
   Any stepped animation on consent element that reaches opacity<0.15 → HIGH. */
CRITICAL
SA-CSS-ATF-002 — steps(1,end): opacity:1 for full duration, instant snap to 0.03 at last frame + fill-mode:forwards freeze. steps(1,start): immediate 0.03 from first frame. Detection: parse timing-function steps(); evaluate consent opacity at t=1s, t=3s, t=5s; report any value < 0.15 as critical.

SA-CSS-ATF-003: steps(2, start) — immediate 50% progress jump from first frame

steps(2, start) divides the animation into two equal steps, with jumps occurring at the start of each step interval. At t=0+ (first frame), the animation immediately jumps to 50% progress. For a linear opacity:1 → 0.03 animation, 50% progress ≈ opacity:0.515. At t=500ms (50% of a 1000ms animation), the animation jumps to 100% progress (opacity:0.03). The consent text is at 0.515 from frame 1 and at 0.03 from t=500ms onward.

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

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  animation-timing-function: steps(2, start); /* 2 steps, jump at start of each */
  animation-fill-mode: forwards;
}

/* steps(2, start) behavior:
   t=0ms (frame 1): step 1 fires immediately → 50% progress → opacity:0.515
   t=0–499ms:       opacity = 0.515 (WCAG 1.59:1 — FAIL)
   t=500ms:         step 2 fires → 100% progress → opacity:0.03
   t=500ms+:        opacity = 0.03; fill-mode=forwards freezes at 0.03

   WCAG at opacity:0.515:
   blend_L = 0.515 × 0.003 + 0.485 × 1.0 ≈ 0.487
   Contrast: 1.05 / (0.487 + 0.05) ≈ 1.95:1 — FAIL

   WCAG at opacity:0.03 (after t=500ms):
   blend_L ≈ 0.970; Contrast ≈ 1.03:1 — FAIL

   Appears intentional: two discrete jumps look like a developer chose step easing
   to test a "chunky" animation effect — not obviously an attack.

   steps(4, start): 4 steps, jump at start → first frame at 25% progress.
   opacity = 1 + 0.25*(0.03-1) = 1 - 0.2425 = 0.7575 (WCAG 3.50:1 — marginal fail)
   At t=500ms (step 3): 75% progress → opacity:0.272 (WCAG 2.26:1 — fail)
   At t=750ms (step 4): 100% progress → opacity:0.03 (WCAG 1.03:1 — fail)

   steps(3, start) is the sweet spot: first frame at 33% progress.
   opacity = 1 - 0.33*0.97 = 0.680 (WCAG 2.96:1 — borderline pass at t=0)
   At t=333ms (step 2): 67% → opacity:0.349 (WCAG 1.84:1 — FAIL)
   At t=667ms (step 3): 100% → opacity:0.03 (WCAG 1.03:1 — FAIL)

   Detection: steps(N, start) on consent element → evaluate at each step boundary.
   Any step value producing opacity < 0.15 → CRITICAL. First-frame opacity < 0.7
   (WCAG 4.5:1 threshold) → HIGH regardless of subsequent steps. */
HIGH
SA-CSS-ATF-003 — steps(2,start): consent text at opacity:0.515 from first frame (WCAG 1.95:1), then 0.03 from t=500ms (WCAG 1.03:1). steps(3,start): legible at t=0, sub-threshold at t=333ms+. Detection: evaluate stepped timing at each step boundary; flag if any boundary opacity < 0.15.

SA-CSS-ATF-004: cubic-bezier overshoot/undershoot — extrapolated below minimum opacity

CSS cubic-bezier allows P1.y and P2.y outside [0,1]. When y-coordinates exceed 1 or go below 0, the bezier curve can overshoot the to keyframe value or undershoot the from keyframe value. For an opacity animation with to: opacity:0.03, an overshoot (y > 1) would extrapolate the value below 0.03 — browsers clamp this to 0. An undershoot (y < 0) applied to a 1→0.03 fade would temporarily push opacity above 1, clamped to 1. But a negative-y undershoot at the end of a 0.03→1 animation (direction:reverse) pushes the animation below 0.03, clamped to 0.

/* Overshoot attack: cubic-bezier(0.9,-0.5,0.9,1.5) */
@keyframes consent-fade { from { opacity: 1; } to { opacity: 0.03; } }

.consent-text {
  animation-name: consent-fade;
  animation-duration: 1000ms;
  /* P1=(0.9,-0.5) P2=(0.9,1.5): negative y1 causes undershoot at start (opacity > 1,
     clamped to 1), positive y2 > 1 causes overshoot at end (opacity < 0.03,
     clamped to 0).

     At the peak overshoot (typically around 85% progress):
     y_extrapolated = 0.03 + 1.5*(0.03-1) ≈ 0.03 - 1.455 ≈ -1.425 → clamp to 0
     Consent text at opacity:0 for a brief window (typically 50–150ms). */
  animation-timing-function: cubic-bezier(0.9, -0.5, 0.9, 1.5);
  animation-fill-mode: forwards;
}

/* Attack window calculation:
   At 85% normalized time (850ms into 1000ms animation):
   The bezier y-value may overshoot beyond 1.0 (relative to keyframe range).
   For opacity:1→0.03, y=1 corresponds to opacity:0.03; y>1 → opacity < 0.03 → 0.
   The overshoot window duration depends on P2.y magnitude.
   With P2.y=1.5: overshoot window ≈ 60ms at opacity:0.

   TOCTOU with consent dialogs:
   Dialog shows at t=0 (consent legible at opacity:1).
   At t=850ms: animation overshoots to opacity:0 for ~60ms.
   If user clicks confirm during the 60ms window: confirms against invisible text.
   The timing is predictable and can be triggered precisely. */

/* Paired with fill-mode:forwards (standard):
   After t=1000ms: animation settles at to-keyframe = opacity:0.03.
   The overshoot window is brief (not persistent), but during it consent is invisible.

   More controllable overshoot: cubic-bezier(1, -2, 1, 2)
   This overshoots dramatically; the opacity:0 window is wider (~150ms at 70% mark).

   Detection:
   Parse cubic-bezier control points y1, y2.
   If y1 < 0 or y2 > 1: evaluate animation at all time samples in [0,1] (step 0.01).
   Compute interpolated opacity at each sample.
   If any sample opacity < 0.15 → HIGH; if < 0.05 → CRITICAL (SA-CSS-ATF-004).
   Special case: if y2 > 1 AND to-keyframe opacity < 0.15 → overshoots even further
   below minimum → CRITICAL. */

Overshoot is invisible to sampling-rate-limited detectors: If the audit engine only samples consent opacity at 100ms intervals, a 60ms overshoot window may never be captured. Full temporal coverage requires evaluating the timing function curve at every animation frame (16ms intervals) across the full animation duration.

HIGH
SA-CSS-ATF-004 — cubic-bezier overshoot (y2 > 1): animation briefly extrapolates below to-keyframe opacity, clamped to 0. Brief ~60ms window at opacity:0. Detection: parse y1/y2 from cubic-bezier; if y2 > 1 with to-keyframe < 0.15, sample curve at 16ms intervals for opacity:0 windows.

Summary of SA-CSS-ATF findings

IDSeverityPatternMechanismWCAG impact
SA-CSS-ATF-001 CRITICAL cubic-bezier(0.01,1,0.01,1) Reaches 100% progress in <10ms; opacity:0.03 for ~990ms of 1000ms duration 1.03:1 immediately
SA-CSS-ATF-002 CRITICAL steps(1,end) / steps(1,start) Instant snap to opacity:0.03; end-step evades early sampling; start-step = instant invisibility 1.03:1 at snap
SA-CSS-ATF-003 HIGH steps(2,start) Immediate 50% progress jump (opacity:0.515) from first frame; 0.03 at midpoint 1.95:1 → 1.03:1
SA-CSS-ATF-004 HIGH cubic-bezier overshoot y2 > 1 causes extrapolation below min opacity; clamped to 0 for ~60ms window Opacity:0 window

Related pages