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
| Expression | Behavior | When consent is hidden | Load-time audit result |
|---|---|---|---|
steps(1, end) with 3s delay | Visible 0–3s, instant hide at 3s | t > 3s (user interaction time) | Passes (consent visible at t=0) |
steps(1, jump-start) | Instant hide at t=0 | Immediately from first frame | Fails (consent hidden from t=0) |
steps(1, end) with 0s delay | Instant hide at animation end | Depends on animation duration | May pass (depends on duration) |
Class-triggered steps(1, end) | Instant hide when class added | At install click time | Passes (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
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.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.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.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
- CSS timing attack synthesis — mousedown, animation delay, deferred rAF, and class-toggle hiding
- CSS transition attacks — smooth transition-based consent collapse at class toggle
- CSS linear() easing — custom easing function for consent opacity curves
- CSS opacity attacks — direct opacity:0 hiding and JS-deferred variants
- CSS animation — general @keyframes animation consent hiding patterns
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.