← Blog

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.

By SkillAudit  |  29 Oct 2026

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).

CRITICAL
SA-CSS-AIC-001 — iteration-count:2 + fill-mode:forwards + 200ms duration: frozen at opacity:0.03 after 400ms (WCAG 1.03:1). el.style.opacity empty. Detection: CSS animation with forwards fill where computed final opacity < 0.15 after iteration-count × duration.

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. */
CRITICAL
SA-CSS-AIC-002 — iteration-count:infinite on opacity fade: consent text pulses through opacity:0.03 on every cycle. With steps(1,end) timing, consent text stays at 0.03 for 50%+ of each 400ms cycle. Detection: infinite animation on consent element touching opacity < 0.15 at any keyframe stop.

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.

CRITICAL
SA-CSS-AIC-003 — Fractional iteration-count + fill-mode:forwards freezes consent at calculated mid-cycle opacity. iteration-count:1.9 → opacity:0.127 (WCAG 1.14:1). Precision: any target opacity achievable via (1-targetOpacity)/(1-endKeyframe). Detection: non-integer animationIterationCount + forwards fill + computed opacity < 0.15.

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 */
HIGH
SA-CSS-AIC-004 — JS mousedown changes animationIterationCount from infinite to finite + sets fill-mode:forwards at click time; animation halts at next cycle end (opacity:0.03). MutationObserver: watch animationIterationCount changes to finite values on consent elements at interaction time.

Summary of SA-CSS-AIC findings

IDSeverityTriggerMechanismWCAG
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

Related pages