MCP server CSS animation-iteration-count consent security
Four SA-CSS-AIC consent attack patterns using animation-iteration-count — the property that sets how many times a CSS animation repeats. Combined with fill-mode: forwards, a count of exactly 2 leaves consent text frozen at opacity: 0.03 after the second cycle. Infinite iteration creates a repeating pulse that visits opacity: 0.03 every 400ms. Fractional counts like 1.5 stop mid-cycle at a calculated intermediate opacity. JavaScript can also inject a new iteration count at mousedown to determine exactly where the animation halts.
animation-iteration-count is rarely considered in consent-UI security audits because it appears to control repetition, not visibility. But the iteration count directly determines where an animation ends, and animation-fill-mode: forwards freezes the element at that end state. An opacity fade that ends at 0.03 after N iterations leaves consent text permanently near-invisible if N is chosen to produce that terminus. Detectors that only check animation-fill-mode or animation-play-state miss the iteration-count vector entirely.
SA-CSS-AIC-001: iteration-count:2 + fill-mode:forwards — frozen at opacity:0.03 after second cycle
A standard opacity fade (from { opacity:1 } to { opacity:0.03 }) ends at opacity:0.03 at the 100% mark of each cycle. With fill-mode: forwards, after the final iteration the element is frozen at the 100% keyframe value. Setting iteration-count: 2 causes two complete cycles, then a freeze at opacity:0.03. The total animation time before freeze is 2 × duration; set duration to 200ms and the consent text is at 0.03 within 400ms — well within the consent dialog display window.
@keyframes consent-fade { from { opacity: 1; } to { opacity: 0.03; } }
.consent-text {
animation-name: consent-fade;
animation-duration: 200ms;
animation-iteration-count: 2; /* two complete 1→0.03 cycles */
animation-fill-mode: forwards; /* freeze at end of second cycle: opacity:0.03 */
animation-timing-function: linear;
animation-direction: normal;
}
/* Timeline:
t=0ms: opacity:1 (cycle 1 start)
t=200ms: opacity:0.03 (cycle 1 end); animation-direction=normal, so cycle 2 restarts at 1
t=400ms: opacity:0.03 (cycle 2 end); fill-mode=forwards → FROZEN at 0.03 forever
WCAG at 0.03 on white: text_L ≈ 0.003 (dark text #111)
blend_L = 0.03 × 0.003 + 0.97 × 1.0 ≈ 0.970
Contrast: 1.05 / (0.970 + 0.05) = 1.05 / 1.02 ≈ 1.03:1 — FAIL
el.style.opacity === "" (not in inline style — pure animation)
getComputedStyle(el).animationIterationCount === "2"
getComputedStyle(el).animationFillMode === "forwards"
Evades: opacity property check (returns ""); play-state check (running/finished)
Detection:
if (parseInt(cs.animationIterationCount) >= 2 && cs.animationFillMode === 'forwards') {
const finalOpacity = parseFloat(getComputedStyle(el).opacity);
if (finalOpacity < 0.15) → CRITICAL SA-CSS-AIC-001
} */
Why iteration-count:2 is stealth: With direction=normal (default), each cycle goes 1→0.03. A cycle count of 2 looks like "runs twice, stops" — not obviously an attack. The freeze behavior comes from fill-mode=forwards which may be documented as "prevents reset after animation." Auditors must check the product of (fill-mode=forwards) AND (final-keyframe opacity < 0.15).
SA-CSS-AIC-002: iteration-count:infinite — repeating pulse to opacity:0.03 every 400ms
With iteration-count: infinite and a 400ms duration, the animation cycles continuously: opacity:1 → 0.03 → 1 → 0.03 → …. In the normal direction, the second half of each cycle (200ms to 400ms) sweeps from 0.03 back to 1, but with animation-direction: alternate, the animation reverses on odd iterations, staying near-invisible for a full 200ms every 400ms. The consent text is briefly visible at opacity:1 at the peaks, but the 200ms invisible window at each trough is long enough for a user to encounter it mid-read.
@keyframes consent-fade { from { opacity: 1; } to { opacity: 0.03; } }
/* Variant A: normal direction — fast dip to 0.03 and back */
.consent-text {
animation-name: consent-fade;
animation-duration: 400ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-direction: normal;
}
/* normal, linear: at t=200ms per cycle, opacity = 0.515 (50% through fade)
at t=400ms per cycle, opacity snaps back to 1 (cycle restarts)
Minimum per-cycle opacity: 0.03 at the end of each cycle (instant, visual snap) */
/* Variant B: alternate direction — holds near 0.03 for full half-cycle */
.consent-text {
animation-name: consent-fade;
animation-duration: 800ms; /* half-cycle = 400ms at 0.03 approach/return */
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-direction: alternate; /* forward then backward: 1→0.03→1→0.03→… */
}
/* alternate: at t=400ms (peak): opacity:0.03 for 0ms (instant turn)
at t=600ms (still in reverse half): opacity≈0.515
But with ease-out timing: more time spent near 0.03 each cycle */
/* Aggressive: ease-in timing pushes dwell time at 0.03 */
.consent-text {
animation-timing-function: ease-in; /* slow start, fast end: spends more time
at low opacity near end of forward cycle */
animation-duration: 600ms;
animation-iteration-count: infinite;
animation-direction: normal;
}
/* With ease-in: at 80% progress (t=480ms), opacity ≈ 0.03 + (1-cubic(0.8))*(1-0.03)
ease-in cubic at 0.8 progress ≈ 0.51; opacity ≈ 1 - 0.51*0.97 ≈ 0.505
Not a major improvement; but stepped easing (below) concentrates dwell */
/* Step easing variant: steps(2, end) */
.consent-text {
animation-timing-function: steps(2, end); /* jumps: 1 → 0.515 at 0% → 0.03 at 50% */
animation-duration: 400ms;
animation-iteration-count: infinite;
animation-direction: normal;
}
/* steps(2, end): 0%-49%: opacity=1; 50%-99%: opacity=0.515; at 100%: cycle restart
variant with to: opacity:0.03 and steps(1,end): stays at 0.03 for entire second half */
/* Detection: infinite iteration-count on consent element = automatic HIGH risk.
Any animation that visits opacity<0.15 during the cycle is CRITICAL. */
SA-CSS-AIC-003: fractional iteration-count:1.5 — frozen mid-cycle at intermediate opacity
CSS allows non-integer animation-iteration-count values. A value of 1.5 means: play one complete cycle (0% to 100%), then play 50% of a second cycle (0% to 50%), then stop. With fill-mode: forwards, the element is frozen at the 50%-progress state of the second cycle. For a linear opacity:1 → 0.03 animation, 50% progress ≈ opacity:0.515. With a faster timing function or a different keyframe target, the frozen mid-cycle value can be pushed much lower.
@keyframes consent-fade { from { opacity: 1; } to { opacity: 0.03; } }
/* Standard 1.5: freeze at 50% of second cycle */
.consent-text {
animation-name: consent-fade;
animation-duration: 200ms;
animation-iteration-count: 1.5; /* 1 full cycle + 50% of second = frozen at 50% */
animation-fill-mode: forwards; /* freeze at current progress on end */
animation-timing-function: linear;
}
/* After 300ms total (200ms cycle 1 + 100ms into cycle 2):
50% progress on opacity:1→0.03: opacity = 1 + 0.5*(0.03-1) = 0.515
WCAG: blend_L = 0.515×0.003 + 0.485×1.0 ≈ 0.487
Contrast: 1.05/(0.487+0.05) ≈ 1.95:1 — FAIL */
/* Aggressive: iteration-count:1.9 → frozen at 90% of second cycle */
.consent-text {
animation-iteration-count: 1.9;
}
/* After 1.9 cycles (380ms at 200ms duration):
90% progress on opacity:1→0.03: opacity = 1 + 0.9*(0.03-1) = 0.127
WCAG: blend_L = 0.127×0.003 + 0.873×1.0 ≈ 0.874
Contrast: 1.05/(0.874+0.05) ≈ 1.14:1 — FAIL
Stealth: 1.9 looks like a developer "accidentally" left a non-integer value.
Unlike 2 (which looks deliberate), 1.9 may pass naive review as a typo. */
/* Fine-tuned attack: iteration-count to produce any target opacity */
/* To freeze at opacity X (linear timing):
fractional_part = (1 - X) / (1 - 0.03) = (1 - X) / 0.97
iteration-count = N + fractional_part (for any integer N)
Example: freeze at opacity:0.1:
fractional_part = 0.9/0.97 ≈ 0.928
iteration-count: 1.928 → frozen at opacity:0.1 */
/* Detection:
const aic = parseFloat(getComputedStyle(el).animationIterationCount);
const isFinite = aic !== Infinity;
if (isFinite && !Number.isInteger(aic) && cs.animationFillMode === 'forwards') {
const frozenOpacity = parseFloat(getComputedStyle(el).opacity);
if (frozenOpacity < 0.15) → CRITICAL SA-CSS-AIC-003
} */
Precision attack: The fractional iteration-count formula (1 - targetOpacity) / (1 - endKeyframeOpacity) allows a skill author to precisely tune any target frozen opacity using only the CSS animation-iteration-count property. No JavaScript, no inline styles. The only indicator is a non-integer iteration count value in the stylesheet.
SA-CSS-AIC-004: JS mousedown injects iteration-count — halt on exact cycle
The consent animation begins with iteration-count: infinite (looping). JavaScript listens for the mousedown event on the confirm button and sets animationIterationCount to a specific value that halts the animation on the next cycle end at opacity:0.03. The elapsed animation time determines which cycle the animation is currently in; setting the iteration count to (current cycle + fractional offset) freezes it at a pre-calculated low-opacity state.
@keyframes consent-fade { from { opacity: 1; } to { opacity: 0.03; } }
.consent-text {
animation-name: consent-fade;
animation-duration: 500ms;
animation-iteration-count: infinite; /* loop continuously */
animation-timing-function: linear;
}
/* JS: set iteration-count at mousedown to halt at next cycle end */
const startTime = Date.now();
document.getElementById('confirm-btn').addEventListener('mousedown', () => {
const elapsed = Date.now() - startTime; /* ms since animation started */
const duration = 500; /* matches animation-duration */
const currentCycle = Math.floor(elapsed / duration);
/* Set to currentCycle+2: animation will run to end of (currentCycle+2)th cycle */
/* At end of any cycle: opacity = keyframe 'to' value = 0.03 */
const el = document.querySelector('.consent-text');
/* Alternatively: set count to a fractional value anchored to elapsed time */
const elapsedInCycle = (elapsed % duration) / duration; /* 0–1 */
/* To freeze at 0.03: set count to next integer (will end at next cycle = 0.03) */
const freezeCount = currentCycle + 1;
el.style.animationIterationCount = freezeCount;
el.style.animationFillMode = 'forwards'; /* ensure freeze at end */
});
/* Timeline example (elapsed=750ms at mousedown, duration=500ms):
currentCycle = Math.floor(750/500) = 1
elapsedInCycle = (750 % 500) / 500 = 0.5 (halfway through cycle 2)
freezeCount = 2
CSS now: iteration-count:2 → animation will end at t=1000ms (end of cycle 2)
fill-mode:forwards → frozen at opacity:0.03
At mouseup (≈820ms into elapsed): animation still running toward cycle 2 end.
At 1000ms: animation ends at opacity:0.03; frozen there.
User has confirmed consent against near-invisible text.
Detection: MutationObserver on consent element style; watch for
animationIterationCount being set from 'infinite' to a finite value at
interaction time (mousedown/touchstart) → HIGH SA-CSS-AIC-004 */
Summary of SA-CSS-AIC findings
| ID | Severity | Trigger | Mechanism | WCAG |
|---|---|---|---|---|
| SA-CSS-AIC-001 | CRITICAL | Static CSS | iteration-count:2 + fill-mode:forwards; frozen at opacity:0.03 after 400ms | 1.03:1 |
| SA-CSS-AIC-002 | CRITICAL | Static CSS | iteration-count:infinite; pulses to 0.03 every cycle; steps(1,end) holds at 0.03 for 50% of cycle | 1.03:1 (trough) |
| SA-CSS-AIC-003 | CRITICAL | Static CSS | Fractional count (e.g. 1.9) + forwards; any target opacity via formula | 1.14:1 (at 1.9) |
| SA-CSS-AIC-004 | HIGH | JS mousedown | Infinite→finite count injection at click; halts at next cycle end (opacity:0.03) | 1.03:1 after halt |
Detection checklist for SkillAudit audit engine
- Enumerate all CSS animations on consent-dialog elements (
role=dialog, class patterns:consent,permission,confirm) - For each animation: resolve
animationIterationCountfrom computed style. Ifinfinite: check all keyframes for opacity < 0.15. If finite integer: check fill-mode and final keyframe opacity. If finite non-integer: compute frozen opacity via formula. - Check MutationObserver events for
animationIterationCountstyle mutations on consent elements at interaction time (mousedown, touchstart, pointerdown). - Run audit after 500ms of dialog display to capture delayed animation end states.
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 animation-play-state paused freeze consent attacks (SA-CSS-APS)
- CSS animation-timing-function cubic-bezier extreme easing consent attacks (SA-CSS-ATF)
- All SkillAudit security research