Security reference · CSS injection · Animation timing · Consent hiding

MCP server CSS steps() timing security

CSS steps() is an animation timing function that produces discrete, instantaneous steps rather than smooth value interpolation. Combined with animation-delay and animation-fill-mode: both, it enables a precise pattern: consent is fully visible at page load and throughout any audit window, then snaps instantly to hidden at a specified delay. Unlike a smooth ease-in fade, the steps() change is a single-frame instant transition — less perceptually obvious than a gradual fade but still detected by computed-style polling. Four attack patterns: steps(1, end) with a configured delay (snap to hidden after N seconds), steps(1, jump-start) for immediate hiding from frame zero, a JS class-triggered discrete step activated at install click time, and a multi-step steps(N) obfuscation that disguises the animation as a multi-stage progress indicator.

steps() timing function fundamentals for security auditors

ExpressionBehaviorWhen consent is hiddenLoad-time audit result
steps(1, end) with 3s delayVisible 0–3s, instant hide at 3st > 3s (user interaction time)Passes (consent visible at t=0)
steps(1, jump-start)Instant hide at t=0Immediately from first frameFails (consent hidden from t=0)
steps(1, end) with 0s delayInstant hide at animation endDepends on animation durationMay pass (depends on duration)
Class-triggered steps(1, end)Instant hide when class addedAt install click timePasses (class not yet added)

steps() vs. smooth transitions for evasion: A smooth ease-in opacity:0 fade over 0.5 seconds is perceptually obvious — users see the fade and may notice consent disappearing. A steps(1, end) transition collapses consent in a single frame — approximately 8ms at 120Hz. This single-frame instant change is below the threshold of conscious visual perception in most viewing contexts. The user physically sees only "before" and "after" states, not the transition.

Attack 1: steps(1, end) with animation delay — visible at audit time, hidden at interaction time

The pattern animation: hide-consent 0.001s steps(1, end) 3s both forwards creates a consent-hiding animation that: (1) keeps consent at the first keyframe value (fully visible) for the first 3 seconds (the delay), (2) steps instantly to the last keyframe value (hidden) at t=3s, and (3) retains the hidden state permanently after the animation ends (forwards fill mode). An audit at t=0 sees consent fully visible; a user arriving at the page and clicking install at t=4s sees consent already hidden:

/* Malicious CSS — SA-CSS-STEPS-001 */
@keyframes hide-consent-step {
  0%   { opacity: 1; height: auto; overflow: visible; }
  100% { opacity: 0; height: 0;    overflow: hidden;  }
}

.mcp-consent-disclosure {
  /* steps(1, end): the animation has 1 step, taken at the END of the interval */
  /* animation-duration 0.001s: the step happens over 0.001 seconds (instantaneous) */
  /* animation-delay 3s: the animation doesn't start until 3 seconds after page load */
  /* animation-fill-mode both: before animation (0–3s), element is at 0% keyframe (visible)
                               after animation (3s+), element is at 100% keyframe (hidden) */
  animation: hide-consent-step 0.001s steps(1, end) 3s both forwards;
}

/* Timeline:
   t=0s:   opacity: 1, height: auto ← DOMContentLoaded, audit runs → PASSES
   t=1s:   opacity: 1, height: auto ← user reading page
   t=2s:   opacity: 1, height: auto ← user still reading
   t=3.001s: opacity: 0, height: 0 ← single-frame instant change (steps(1, end))
   t=4s+:  opacity: 0, height: 0 ← user clicks install → consent already hidden
   t=∞:    opacity: 0, height: 0 ← animation.fill-mode: forwards retains final state */

/* Why steps(1, end) instead of ease-in?
   ease-in: opacity fades from 1 to 0 over the duration — visible, smooth, obvious
   steps(1, end): opacity stays at 1 for the entire duration, then snaps to 0 in one frame
   The "end" position means the step is taken at the end of the 0.001s interval.
   To the user: consent was there, then it wasn't. No fade. Less perceptible.
   To the auditor at t=0: opacity is 1 via animation-fill-mode:both (before-phase = 0% keyframe). */

/* Detection: poll computed style every 100ms for 10 seconds after load */
function detectAnimationStepHiding() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
    /* Check if element has any CSS animation */
    const s = getComputedStyle(el);
    const animName = s.animationName;
    const animTimingFn = s.animationTimingFunction;
    if (animName !== 'none' && animTimingFn.includes('steps(')) {
      const delay = parseFloat(s.animationDelay) || 0;
      const duration = parseFloat(s.animationDuration) || 0;
      findings.push({ id: 'SA-CSS-STEPS-001', severity: 'critical',
        message: `Consent-content element has a steps() CSS animation: name="${animName}", timing="${animTimingFn}", delay=${delay}s, duration=${duration}s. Animation with steps() and fill-mode may hide consent after the delay passes. Must poll computed opacity and height for ${Math.ceil(delay + duration + 1)}s to detect.` });
    }
  }
  return findings;
}

Attack 2: steps(1, jump-start) — immediate hiding from t=0

The jump-start position takes the step at the beginning of the interval, immediately applying the end keyframe value. steps(1, jump-start) with animation-fill-mode: both makes consent hidden from the very first frame — before the animation technically starts and before any audit can observe the before-state. This is a static attack (consent is never visible), but it uses the steps() function rather than a direct CSS property hiding rule — evading scanners that specifically look for direct hiding rules:

/* Malicious CSS — SA-CSS-STEPS-002 */
@keyframes hide-immediately {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

.mcp-consent-text {
  /* steps(1, jump-start): the single step is taken at the start of the interval */
  /* animation-fill-mode: both → before-phase uses 0% (opacity:1) BUT
     jump-start means the step is immediately at the end position:
     → opacity: 0 from t=0 */
  animation: hide-immediately 0.001s steps(1, jump-start) 0s both;
}

/* Timeline:
   t=0: jump-start applies the step immediately → opacity: 0 from first frame
   t=∞: opacity: 0 — permanently hidden

   Why not just use "opacity: 0" directly?
   Direct "opacity: 0" is easily caught by opacity-scanning auditors.
   Hiding via animation + steps() is less obvious in a stylesheet review.
   The animation name "hide-immediately" is suspicious — but an attacker would name it
   something benign: "mcp-dialog-mount", "install-ui-ready", "consent-panel-init". */

/* More subtle version with innocuous animation name: */
@keyframes mcp-consent-panel-init {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

.mcp-consent-text-v2 {
  animation: mcp-consent-panel-init 0.001s steps(1, jump-start) 0s both;
  /* Looks like an "initialization" animation for the consent panel */
  /* Hides consent from t=0 via the jump-start step position */
}

Attack 3: JS class-triggered discrete step — interaction-time hiding

No CSS animation applies to the consent element at page load. MCP JavaScript adds a class to the consent element when the install flow begins (at click time), triggering a steps(1, end) animation. The single discrete step collapses consent instantly at the moment of class addition — indistinguishable in timing from the JS class assignment itself:

/* Malicious CSS — SA-CSS-STEPS-003 */
@keyframes consent-collapse {
  0%   { opacity: 1; height: auto; overflow: visible; }
  100% { opacity: 0; height: 0;    overflow: hidden;  }
}

/* No animation on consent element by default */
.mcp-consent-disclosure { opacity: 1; }

/* Class addition triggers the animation */
.mcp-consent-disclosure.collapsing {
  animation: consent-collapse 0.001s steps(1, end) 0s both forwards;
  /* steps(1, end): instant step at end of 0.001s interval → opacity:0, height:0 */
}

/* JS: */
document.querySelector('.mcp-install-btn').addEventListener('click', () => {
  document.querySelector('.mcp-consent-disclosure').classList.add('collapsing');
  /* Consent collapses in one frame at the click moment */
  initiateInstall(); /* runs after consent is hidden */
});

/* At audit time (page load): no "collapsing" class → no animation → opacity: 1 → PASSES */
/* At click time: "collapsing" class added → animation activates → instant step to 0 → HIDDEN */

/* MutationObserver on the consent element's class attribute catches this:
   observer detects "collapsing" class addition and immediately re-checks computed state */

Attack 4: steps(N) multi-step obfuscation — disguised as progress animation

Using a larger step count (e.g., steps(10)) over a longer duration produces a discrete progress-bar-style animation that reduces opacity in 10 equal steps over the animation duration. The consent element at 100% opacity for the first step interval looks fully visible; after 5 steps it is at 50% opacity (borderline readable); after 10 steps it is at 0% (invisible). This pattern is disguised as a "progress indicator" or "loading animation" rather than a deliberate hiding sequence:

/* Malicious CSS — SA-CSS-STEPS-004 */
@keyframes consent-progress-fade {
  0%   { opacity: 1.0; }
  /* Steps function handles intermediate values: with steps(10), values are sampled
     at 0%, 10%, 20%, ..., 90% of the keyframe range */
  100% { opacity: 0.0; }
}

.mcp-consent-disclosure {
  /* steps(10): 10 equal intervals, each lasting 0.3s */
  /* Total animation: 3s */
  /* Opacity progression:
     0.0–0.3s: opacity 1.0 (step 1)   ← audit at load time
     0.3–0.6s: opacity 0.9 (step 2)
     0.6–0.9s: opacity 0.8 (step 3)
     0.9–1.2s: opacity 0.7 (step 4)
     1.2–1.5s: opacity 0.6 (step 5)   ← borderline readable
     1.5–1.8s: opacity 0.5 (step 6)
     1.8–2.1s: opacity 0.4 (step 7)   ← practically unreadable
     2.1–2.4s: opacity 0.3 (step 8)
     2.4–2.7s: opacity 0.2 (step 9)
     2.7–3.0s: opacity 0.1 (step 10)
     3.0s+:    opacity 0.0 (animation-fill-mode:forwards retains final state) */
  animation: consent-progress-fade 3s steps(10) 0s both forwards;
  /* Appears to be a legitimate "fade out" loading animation */
}

/* To an auditor: this looks like a consent element that fades out over 3 seconds.
   They might assume the consent text is replaced by a confirmation state.
   In reality, the install completes at t=2.1s when opacity=0.4 — consent already
   below the practical readability threshold. */

/* Detection: check computed opacity and height repeatedly — catch any dip below 0.5 */
function detectMultiStepOpacity() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
    const s = getComputedStyle(el);
    /* Check for any steps() animation with opacity target */
    if (s.animationName !== 'none' && s.animationTimingFunction.includes('steps(')) {
      /* Poll opacity over the next animationDuration + delay seconds */
      const totalMs = (parseFloat(s.animationDuration) + parseFloat(s.animationDelay)) * 1000 + 500;
      let pollMs = 0;
      const intervalId = setInterval(() => {
        const opacity = parseFloat(getComputedStyle(el).opacity);
        if (opacity < 0.5) {
          findings.push({ id: 'SA-CSS-STEPS-004', severity: 'high',
            message: `Consent-content element opacity dropped to ${opacity.toFixed(2)} at t=${pollMs}ms via steps() animation "${s.animationName}". Multi-step opacity reduction in progress — consent will be unreadable before animation completes.` });
          clearInterval(intervalId);
        }
        pollMs += 100;
        if (pollMs > totalMs) clearInterval(intervalId);
      }, 100);
    }
  }
  return findings;
}

animation-fill-mode:both is the key mechanism: Without animation-fill-mode: both (or forwards), a CSS animation's hiding effect would be temporary — consent would return to its non-animated state when the animation ends. fill-mode: both means the element maintains the pre-animation style during the delay (keeping consent visible) AND the post-animation style after completion (keeping consent hidden permanently). Always check for animation-fill-mode on consent elements — any value other than none or backwards can permanently lock consent to a hidden post-animation state.

SkillAudit findings for CSS steps() consent attacks

CriticalSA-CSS-STEPS-001 — Consent-content element has a steps(1, end) CSS animation with animation-delay > 0 and animation-fill-mode: both/forwards. Consent is visible at load time (during delay) and hidden permanently after the delay. Audit must poll computed state for the full delay duration to detect.
CriticalSA-CSS-STEPS-002 — Consent-content element has steps(1, jump-start) CSS animation. Consent is hidden from t=0 (jump-start applies the step immediately). Hides consent using animation machinery rather than a direct CSS hiding property — evades direct-property scanners.
CriticalSA-CSS-STEPS-003 — Consent-content element gains a steps() animation via class addition at install click time. Consent is visible at load time; the class triggers instant hiding at click. MutationObserver on the class attribute detects the activation; load-time audits miss it entirely.
HighSA-CSS-STEPS-004 — Consent-content element has a multi-step steps(N) animation that reduces opacity from 1 to 0 over N discrete steps. Consent becomes practically unreadable before the animation completes. Appears as a "progress" or "fade" animation. Opacity polling at 100ms intervals detects the progressive reduction.

Related MCP consent attack research

SkillAudit's consent audit runs a 10-second polling window after page load, checking consent element computed opacity, height, and visibility at 100ms intervals. This catches steps() animation delay attacks that appear safe at t=0 but activate within the first few seconds. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-STEPS timing findings.