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.
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.
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. */
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. */
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.
Summary of SA-CSS-ATF findings
| ID | Severity | Pattern | Mechanism | WCAG 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
- CSS animation-iteration-count infinite/fractional consent attacks (SA-CSS-AIC)
- CSS animation-fill-mode forwards/backwards consent attacks (SA-CSS-AFM)
- CSS animation-delay negative/positive consent attacks (SA-CSS-ADS)
- CSS animation-play-state paused freeze consent attacks (SA-CSS-APS)
- CSS @keyframes percentage stops consent attacks (SA-CSS-WKF)
- All SkillAudit security research