MCP server CSS @keyframes percentage stops consent security
Four SA-CSS-WKF consent attack patterns exploiting @keyframes percentage stop placement. A keyframe stop at 1% with opacity:0.03 held at 100% leaves consent text near-invisible for 99% of the animation's active time — only the first 10ms of a 1000ms animation shows legible text. A reversed layout (0%{opacity:0.03} 99%{opacity:0.03} 100%{opacity:1}) combined with fill-mode:backwards and a positive delay makes consent invisible both during the delay period and during the first 99% of animation play. Mid-animation dips create TOCTOU windows that evade opacity checks at dialog open and close time. Combining these with iteration-count:infinite ensures consent is never simultaneously legible and static.
CSS @keyframes are defined as percentage stops along the animation timeline. The specification allows any number of stops at any percentage, including multiple stops at the same percentage and stops at 0% and 100%. Stops between 0% and 100% create segmented interpolation — the browser interpolates from the previous stop to the next stop rather than continuously from 0% to 100%. This segmented interpolation is the mechanism behind all four SA-CSS-WKF patterns: by placing stops strategically, consent text can be driven to near-invisible opacity for precisely controlled time windows.
SA-CSS-WKF-001: 0%→1% drop, held at 0.03 through 100%
Placing a keyframe stop at 1% with opacity:0.03 and a stop at 100% also at opacity:0.03 creates a rapid drop in the first 1% of animation time, followed by a flat hold at 0.03 for the remaining 99%. For a 1000ms animation, the transition from opacity:1 to opacity:0.03 completes in the first 10ms. The consent text is legible for exactly 10ms out of every 1000ms.
@keyframes consent-hide {
0% { opacity: 1; }
1% { opacity: 0.03; } /* rapid drop in first 1% of duration */
100% { opacity: 0.03; } /* hold at 0.03 for remaining 99% */
}
.consent-text {
animation-name: consent-hide;
animation-duration: 1000ms;
animation-fill-mode: forwards; /* frozen at 0.03 after completion */
animation-timing-function: linear;
}
/* Timeline:
t=0ms to t=10ms: opacity interpolates 1→0.03 (linear over 10ms — barely perceptible)
t=10ms to t=1000ms: opacity = 0.03 (flat keyframe: no interpolation between equal values)
After t=1000ms: fill-mode=forwards → frozen at opacity:0.03
A time-sampling detector checking at t=100ms would find opacity≈0.03.
A detector checking at t=0 (first paint) might find opacity≈1 if it samples
before the first animation frame — but even frame 2 (t=16ms) is past the 10ms window.
With duration=200ms: legible for 2ms. With duration=5000ms: legible for 50ms.
In all cases the consent text is at 0.03 during the interaction window (t>10ms).
Alternative: use steps(1, start) on the 0%→1% segment:
@keyframes consent-hide {
0% { opacity: 1; animation-timing-function: steps(1, start); }
1% { opacity: 0.03; }
100% { opacity: 0.03; }
}
This makes the 0%→1% transition instant (step at t=0+): opacity:0.03 from first frame.
Detection:
Parse @keyframes referenced by consent element animations.
For each keyframe sequence, check if any stop at ≤5% has opacity < 0.15.
If so: flag SA-CSS-WKF-001. The consent text is invisible for 95%+ of animation time.
Also check for long holds: if keyframe K and K+1 have identical low opacity with
percentage span ≥ 50%: flag as CRITICAL. */
Legibility window of 10ms is sub-perceptual: Human perception cannot read text that displays for less than ~100ms. A 10ms legibility window created by the 1%-stop pattern means consent text is functionally invisible from the user's perspective, even though the CSS technically includes a transition from opacity:1.
SA-CSS-WKF-002: reversed keyframes with fill-mode:backwards delay — invisible before and during animation
Inverting the keyframe layout — 0%{opacity:0.03}, 99%{opacity:0.03}, 100%{opacity:1} — means the animation starts invisible and only reaches full opacity at the very end. Combined with fill-mode: backwards and a positive animation-delay, the element applies the 0% keyframe value during the delay period. The consent text is at opacity:0.03 for the entire delay window plus the first 99% of animation play time — only becoming legible in the last 10ms of a 1000ms animation.
@keyframes consent-reveal-late {
0% { opacity: 0.03; } /* start invisible */
99% { opacity: 0.03; } /* hold invisible through 99% of animation */
100% { opacity: 1; } /* briefly legible at end */
}
.consent-text {
animation-name: consent-reveal-late;
animation-duration: 1000ms;
animation-delay: 500ms; /* positive delay: 500ms of pre-animation time */
animation-fill-mode: backwards; /* apply 0% keyframe during delay: opacity:0.03 */
animation-timing-function: linear;
}
/* Timeline:
t=-500ms to t=0ms: delay period; fill-mode=backwards → opacity:0.03 (0% keyframe)
t=0ms to t=990ms: animation at 0%→99% range; opacity = 0.03 (flat hold)
t=990ms to t=1000ms: animation at 99%→100%; opacity interpolates 0.03→1.0
t=1000ms: opacity:1 — animation ends; no fill-mode=forwards, so resets to pre-animation
WCAG:
t=-500ms to t=990ms: opacity:0.03 → WCAG 1.03:1 (FAIL) for 1490ms
t=990ms to t=1000ms: opacity:0.03→1.0 (legible for 10ms — sub-perceptual)
After t=1000ms: animation resets (no forwards fill) → returns to non-animated value
If fill-mode is 'both' (backwards+forwards): stays at opacity:1 after animation ends.
The consent text is invisible during the entire interaction window then suddenly legible
AFTER the animation — at which point the user may have already confirmed consent.
Variant with duration=200ms, delay=2000ms:
Invisible for 2198ms (delay=2000ms + 99% of 200ms = 198ms).
If the user clicks confirm within 2198ms: confirms against invisible text.
Detection:
Check for keyframes where the 0% stop has opacity < 0.15 + fill-mode=backwards.
Also check for keyframes where ≥95% of percentage range holds opacity < 0.15. */
SA-CSS-WKF-003: mid-animation dip at 50% — TOCTOU window invisible at midpoint
A keyframe stop at exactly 50% with opacity:0.03 creates a V-shaped animation: the consent text starts at opacity:1, fades to 0.03 at the midpoint (t=500ms for a 1000ms animation), then recovers back to opacity:1. The consent text is fully invisible at exactly the halfway point of the animation. If the user happens to read the consent dialog at t=500ms — which is a natural interaction timing — they see near-invisible text. Importantly, this pattern evades both first-frame checks (opacity:1 at t=0) and last-frame checks (opacity:1 at t=1000ms).
@keyframes consent-mid-dip {
0% { opacity: 1; }
50% { opacity: 0.03; } /* minimum at midpoint */
100% { opacity: 1; } /* recover to full opacity */
}
.consent-text {
animation-name: consent-mid-dip;
animation-duration: 1000ms;
animation-iteration-count: 1;
animation-timing-function: linear;
/* no fill-mode needed: starts and ends at opacity:1 */
}
/* Timeline (linear timing):
t=0ms: opacity:1 (legible)
t=250ms: opacity = 1 + 0.5*(0.03-1) = 0.515 (WCAG 1.95:1 — FAIL)
t=500ms: opacity = 0.03 (WCAG 1.03:1 — FAIL)
t=750ms: opacity = 0.03 + 0.5*(1-0.03) = 0.515 (WCAG 1.95:1 — FAIL)
t=1000ms: opacity:1 (legible)
TOCTOU: user sees legible text at t=0, begins reading.
At t=500ms (mid-dialog interaction window): consent invisible.
User clicks confirm at t=500ms against near-invisible text.
At t=1000ms: animation ends at opacity:1 — no fill-mode, resets cleanly.
Post-interaction: consent text appears legible (t>1000ms or if user replays dialog).
Extends to multiple dip points:
@keyframes consent-multi-dip {
0% { opacity: 1; }
25% { opacity: 0.03; }
50% { opacity: 1; }
75% { opacity: 0.03; }
100% { opacity: 1; }
}
Creates two dip windows (t=250ms and t=750ms for 1000ms animation).
Probability of user interacting during an invisible window ≈ 2 × window_width.
Narrower dip with slower surrounding motion (ease timing):
@keyframes consent-narrow-dip {
0% { opacity: 1; animation-timing-function: ease-in; }
50% { opacity: 0.03; animation-timing-function: ease-out; }
100% { opacity: 1; }
}
ease-in: slow start → fast approach to 0.03. More time near 0.03 at midpoint.
ease-out: fast exit from 0.03 → slow recovery. Longer time near 0.03 after midpoint.
Combined: dwell time near opacity:0.03 ≈ 300–400ms out of 1000ms.
Detection:
Parse all keyframe stops for consent element animations.
Check all intermediate stops (0% < stop < 100%) for opacity < 0.15.
Flag SA-CSS-WKF-003 if any intermediate stop is below threshold.
Note: end-point checks alone miss this pattern entirely. */
Mid-dip evades boundary checks: Security detectors that only sample opacity at t=0 (dialog open) and t=animation-end (fill-mode freeze) will both return opacity:1 for this pattern. Full temporal sampling across the animation duration is required — not just boundary conditions.
SA-CSS-WKF-004: combined two-dip infinite iteration — never simultaneously legible and static
Combining multi-stop keyframes with iteration-count: infinite creates a consent UI that cycles through two invisible windows per iteration indefinitely. At no point in the infinite cycle is the consent text simultaneously legible (opacity:1) and stable — it is always in motion toward or away from a near-invisible state. Users cannot wait for the text to become stably legible because the animation never ends.
@keyframes consent-never-stable {
0% { opacity: 1; }
20% { opacity: 0.03; } /* first invisible window peak at t=200ms */
40% { opacity: 1; } /* brief recovery */
60% { opacity: 0.03; } /* second invisible window peak at t=600ms */
80% { opacity: 1; } /* brief recovery */
100% { opacity: 1; } /* cycle seamlessly (100% == 0% for next cycle) */
}
.consent-text {
animation-name: consent-never-stable;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-direction: normal;
}
/* Per 1000ms cycle:
t=0–200ms: opacity 1→0.03 (declining: WCAG fails from ~t=100ms onward)
t=200ms: opacity:0.03 (WCAG 1.03:1) — first invisible peak
t=200–400ms: opacity 0.03→1 (recovering)
t=400–600ms: opacity 1→0.03 (declining again)
t=600ms: opacity:0.03 (WCAG 1.03:1) — second invisible peak
t=600–800ms: opacity 0.03→1 (recovering)
t=800–1000ms: opacity = 1 (stable for 200ms per cycle)
Opportunity windows below WCAG 4.5:1:
opacity:0.03 → 1.03:1: approximately t=80ms–320ms and t=480ms–720ms per cycle (linear)
Combined sub-threshold window: ~480ms per 1000ms cycle = 48% of display time.
With 3-second dialog: user encounters sub-threshold opacity during 1440ms of 3000ms.
This exceeds 50% sub-threshold for any slow-reading or accessibility-dependent user.
Variant: slow cycle (duration=3000ms) with low dip at 33% and 67%:
@keyframes consent-slow-never-stable {
0% { opacity: 1; }
33% { opacity: 0.03; }
50% { opacity: 1; }
67% { opacity: 0.03; }
100% { opacity: 1; }
}
At duration=3000ms: dip windows at t≈990ms and t≈2010ms.
Sub-threshold windows ≈ 480ms each → 960ms of 3000ms.
Detection:
Any infinite-iteration animation on consent element with ≥2 intermediate keyframe
stops below opacity:0.15 → CRITICAL SA-CSS-WKF-004.
Report the sub-threshold window duration as percentage of animation cycle. */
Summary of SA-CSS-WKF findings
| ID | Severity | Keyframe pattern | Mechanism | WCAG |
|---|---|---|---|---|
| SA-CSS-WKF-001 | CRITICAL | 0%{1} 1%{0.03} 100%{0.03} | Rapid 10ms drop; consent at 0.03 for 990ms of 1000ms duration | 1.03:1 for 99% of time |
| SA-CSS-WKF-002 | CRITICAL | 0%{0.03} 99%{0.03} 100%{1} + backwards | Invisible during delay + 99% of animation; legible only in last 10ms | 1.03:1 for 1490ms |
| SA-CSS-WKF-003 | HIGH | 0%{1} 50%{0.03} 100%{1} | V-shaped mid-dip; opacity:0.03 at midpoint; evades boundary checks | 1.03:1 at midpoint |
| SA-CSS-WKF-004 | CRITICAL | Two dip-stops + infinite iteration | Two invisible peaks per cycle; sub-threshold for 48% of cycle time | 1.03:1 × 480ms/cycle |
Related pages
- CSS animation-iteration-count infinite/fractional consent attacks (SA-CSS-AIC)
- CSS animation-timing-function cubic-bezier extreme easing consent attacks (SA-CSS-ATF)
- CSS animation-fill-mode forwards/backwards consent attacks (SA-CSS-AFM)
- CSS animation-delay negative/positive consent attacks (SA-CSS-ADS)
- CSS color:transparent consent text attacks (SA-CSS-CFA)
- All SkillAudit security research