Security reference · CSS injection · transition · Timing-based consent evasion

MCP server CSS transition consent security — timing-based consent evasion attacks

CSS transition smoothly animates property changes between values over a specified duration, with an optional delay before the transition begins. An attacker uses the delay parameter to schedule consent hiding: transition: opacity 0s 2s means that when JavaScript sets opacity: 0, the change is delayed by 2 seconds — consent is visible for 2 seconds (the static audit window), then instantly disappears. Point-in-time audits run at page load see the consent correctly; the user's experience includes the disappearance. Four SA-CSS-TRN patterns cover opacity delay, height animation, visibility delay, and animation-fill-mode: forwards permanent state application.

transition attack surface overview

Attack ID Transition technique Timing of consent hiding Audit blind spot
SA-CSS-TRN-001 transition: opacity 0s 2s on consent container; JS sets opacity:0 at load Consent visible for 2 seconds, then immediately disappears Audit at t=0: opacity=1, visible, passes. At t=2s: opacity=0, hidden. Audit misses post-delay state.
SA-CSS-TRN-002 transition: height 0.5s ease; JS sets height:0 at 1 second Consent smoothly collapses from full height to zero over 500ms starting 1s after load — passes screenshot audit taken at page load Smooth animation looks like intentional UI motion; screenshot/snapshot audit captures t=0 state when height is full
SA-CSS-TRN-003 transition: visibility 0s 3s; JS sets visibility:hidden at load Consent remains visible for 3 seconds after visibility:hidden is set (visibility transitions use delay timing); disappears at t=3s visibility transitions behave differently from opacity — the transitionend fires at the delay time; visibility:hidden is in computed style immediately but the visual effect is delayed. Auditors checking computed style see hidden; auditors checking visual output see visible. Conflicting signals.
SA-CSS-TRN-004 @keyframes hideConsent + animation-fill-mode: forwards Keyframe animation sets opacity:0 at 100%; animation-fill-mode:forwards retains the final state permanently after animation completes — consent disappears after animation duration Looks like a loading/reveal animation in reverse; animation-fill-mode:forwards is common in UI frameworks; auditors may not check post-animation final state

Timing attack principle: static audits capture a moment in time. If that moment is before the consent manipulation fires, the audit passes. CSS transitions and animations allow attackers to control precisely when the consent hiding occurs — scheduling it after any reasonable audit window. A 2-second delay is sufficient: no automated scanner waits 2 seconds to re-check consent visibility after checking it at page load.

Background: CSS transition model and the delay parameter

The transition shorthand accepts four values: property, duration, timing-function, and delay. The delay is the time to wait after a style change is triggered before the transition begins animating. A transition: opacity 0s 2s means: when the opacity changes, wait 2 seconds before applying it, and once the wait is over, apply it with zero duration (instant). The intermediate state during the delay period is the starting value — so opacity remains at 1 for the full 2-second delay, then jumps to 0 instantly.

This delay parameter was designed for UI interactions: hover effects that should not fire during accidental cursor passes, reveal animations that wait for a preceding element to finish, etc. The attack repurposes it to create a guaranteed visible period for static audits followed by a hidden state for the actual user.

Attack 1: opacity delay — immediate-change delayed trigger (SA-CSS-TRN-001)

/* SA-CSS-TRN-001: opacity:0s delay:2s — consent visible then instantly gone */

.consent-container {
  opacity: 1;
  transition: opacity 0s 2s;
  /* transition: property duration timing delay */
  /*             opacity  0s    step  2s    */
  /* When opacity is changed, wait 2 seconds, then apply change with 0 duration */
}

/* JavaScript at page load (or shortly after DOMContentLoaded): */
document.querySelector('.consent-container').style.opacity = '0';
/* This sets opacity:0 on the element.
   The transition says: wait 2s before applying.
   Result:
   t=0:   opacity = 1 (initial value, delay period, VISIBLE)
   t=2s:  opacity = 0 applied instantly (0s duration) → INVISIBLE

   Static audit at t=0: opacity=1 ✓ consent visible ✓
   User at t=2s: consent has disappeared

   Variation: pointer-events:none added simultaneously, so install button
   is still clickable during and after the consent disappearance */

SA-CSS-TRN-001 (High). SkillAudit detects this by checking getComputedStyle(el).transition on consent elements and parsing the delay value. Any consent element with a transition on opacity, visibility, height, or clip-path is re-evaluated after the delay period has elapsed (using a timed re-check during the consent interaction simulation). Additionally, the scanner checks for pending style.opacity = '0' or equivalent JS that has been queued but not yet fired.

Attack 2: height collapse animation (SA-CSS-TRN-002)

Height transitions produce a smooth collapse animation — the consent container shrinks from its full height to zero over the transition duration. This looks intentional: many legitimate UIs use height transitions for accordion or collapse behaviors. The tell is when a height transition is applied to a consent container with overflow:hidden and the final state is height:0.

/* SA-CSS-TRN-002: height transition collapse — consent smoothly hidden */

.consent-container {
  height: auto;              /* or a fixed height that fits the content */
  overflow: hidden;          /* content clipped when height shrinks */
  transition: height 0.5s ease-in-out;
}

/* JavaScript scheduled 1 second after page load: */
setTimeout(() => {
  const container = document.querySelector('.consent-container');
  /* Must set explicit height first for transition to work (auto→0 doesn't animate) */
  container.style.height = container.scrollHeight + 'px';
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      container.style.height = '0';
      /* transition: height 0.5s ease → takes 0.5s to collapse to zero */
    });
  });
}, 1000);

/* Timeline:
   t=0:   consent visible, full height (AUDIT WINDOW)
   t=1s:  JS fires, sets height to explicit px value
   t=1s+: animates from full height to 0 over 500ms
   t=1.5s: height=0, overflow:hidden clips content, consent INVISIBLE

   User reading speed: ~200 words per minute
   At 200 words, consent takes ~1 minute to read. The 1.5s disappearance
   ensures the consent is visible for initial page load audits but hides
   before the user finishes reading. */

Attack 3: visibility transition delay (SA-CSS-TRN-003)

The visibility property has special transition behavior: unlike opacity (which animates smoothly between values), visibility is a binary value. When transitioning from visible to hidden, the transition delay determines when the switch fires — but the visual effect applies at the delay time, not gradually. This creates a specific detection challenge: setting visibility: hidden via JavaScript while a transition: visibility 0s 3s is active means the computed style shows visibility: hidden immediately, but the rendered output shows the element as visible for 3 more seconds. An auditor checking computed style sees hidden; a screenshot shows visible. The signals conflict.

/* SA-CSS-TRN-003: visibility transition delay — computed vs. rendered state mismatch */

.consent-text {
  visibility: visible;
  transition: visibility 0s 3s;
  /* When visibility changes: wait 3 seconds, then apply (0s duration = instant) */
}

/* JS at page load: */
document.querySelector('.consent-text').style.visibility = 'hidden';

/* State timeline:
   t=0:   JS fires, sets visibility:hidden
          getComputedStyle().visibility → "hidden"  ← computed style says HIDDEN
          Rendered output: still VISIBLE (transition delay period)
   t=3s:  transition fires: visibility:hidden takes effect visually
          getComputedStyle().visibility → "hidden"  ← still hidden
          Rendered output: now HIDDEN

   This creates a 3-second window where:
   - computed style: visibility = hidden (auditor sees this and flags as hidden)
   - rendered output: visible to the user

   Then after 3 seconds: hidden to the user but the audit flag had already fired.
   A smart auditor might UNFLAG this element because computed visibility is "hidden"
   and assume it's intentionally hidden — not suspecting that it was visible a moment ago. */

Attack 4: animation-fill-mode: forwards permanent state (SA-CSS-TRN-004)

CSS keyframe animations with animation-fill-mode: forwards retain the final keyframe's styles after the animation completes. An animation that ends with opacity: 0 at 100% will leave the element permanently invisible after it finishes. This looks like an intro/outro animation — commonly used in UI frameworks for loading sequences, slide-in effects, etc. The audit-evasion use: the animation starts after a delay, runs for a short duration, and ends with consent hidden.

/* SA-CSS-TRN-004: animation-fill-mode:forwards retains opacity:0 final state */

@keyframes hideAfterReveal {
  0%   { opacity: 1; }    /* consent visible at start */
  80%  { opacity: 1; }    /* consent visible for 80% of animation */
  100% { opacity: 0; }    /* consent fades out at the end */
}

.consent-text {
  animation: hideAfterReveal 3s ease-in 1s forwards;
  /* animation: name duration timing delay fill-mode */
  /* Delay of 1s → starts 1s after page load (after any static audit) */
  /* Duration 3s → runs from t=1s to t=4s */
  /* fill-mode: forwards → retains opacity:0 after t=4s PERMANENTLY */

  /* Timeline:
     t=0:   opacity=1 (before animation starts, initial value)
     t=1s:  animation begins
     t=1s–3.4s: opacity=1 (0% through 80% of animation)
     t=3.4s–4s: opacity fades from 1 to 0 (80% through 100%)
     t=4s:  animation ends, fill-mode:forwards holds opacity:0 PERMANENTLY
     t>4s:  consent remains invisible forever */
}

/* Detection:
   Check getComputedStyle(el).animationFillMode for 'forwards' or 'both'
   on consent elements. Also parse @keyframes for animations that end at opacity:0
   or visibility:hidden or height:0 and are applied to consent elements. */

Detection algorithm

/* Detection for SA-CSS-TRN patterns */
function detectTransitionConsentAttacks() {
  const CONSENT_KEYWORDS = ['authorize', 'agree', 'terms', 'consent', 'permission', 'grant'];
  const OPACITY_HIDING_PROPS = ['opacity', 'visibility', 'height', 'clip-path', 'max-height'];
  const findings = [];

  document.querySelectorAll('*').forEach(el => {
    const text = (el.textContent || '').toLowerCase();
    if (!CONSENT_KEYWORDS.some(k => text.includes(k))) return;
    if ((el.textContent || '').trim().length < 20) return;

    const cs = getComputedStyle(el);

    // Check transition property for hiding-relevant properties with delays
    const transition = cs.transition;
    if (transition && transition !== 'all 0s ease 0s' && transition !== 'none') {
      OPACITY_HIDING_PROPS.forEach(prop => {
        if (transition.includes(prop)) {
          // Parse the delay from the transition shorthand
          const parts = transition.split(',').map(t => t.trim());
          parts.forEach(part => {
            if (!part.includes(prop)) return;
            // Match: property duration timing delay
            const delayMatch = part.match(/(\d+\.?\d*s)\s*$/);
            if (delayMatch) {
              const delayS = parseFloat(delayMatch[1]);
              if (delayS > 0.5) {
                findings.push({ id: 'SA-CSS-TRN-001', severity: 'HIGH', element: el,
                  transition: part, delay: delayS,
                  desc: `transition:${prop} with ${delayS}s delay on consent element — delayed consent hiding` });
              }
            }
          });
        }
      });
    }

    // Check animation-fill-mode for permanent state retention
    const fillMode = cs.animationFillMode;
    const animName = cs.animationName;
    if ((fillMode === 'forwards' || fillMode === 'both') && animName !== 'none') {
      findings.push({ id: 'SA-CSS-TRN-004', severity: 'HIGH', element: el,
        animName, fillMode,
        desc: `animation-fill-mode:${fillMode} on consent element with animation ${animName} — permanent hidden state after animation` });
    }
  });

  // Re-check consent elements after 3-second delay to detect timed hiding
  setTimeout(() => {
    document.querySelectorAll('*').forEach(el => {
      const text = (el.textContent || '').toLowerCase();
      if (!CONSENT_KEYWORDS.some(k => text.includes(k))) return;
      const cs = getComputedStyle(el);
      if (parseFloat(cs.opacity) < 0.1 || cs.visibility === 'hidden'
          || el.offsetHeight === 0) {
        findings.push({ id: 'SA-CSS-TRN-DELAYED', severity: 'HIGH', element: el,
          desc: 'Consent element hidden at t=3s but was visible at page load — timing-based consent hiding' });
      }
    });
  }, 3000);

  return findings;
}

SkillAudit findings for SA-CSS-TRN

HIGH SA-CSS-TRN-001: transition: opacity 0s [delay > 0.5s] on consent element with a scheduled opacity:0 change — instant consent disappearance after delay. Audit-window visible, user-window hidden.
HIGH SA-CSS-TRN-002: transition: height [duration] on consent container with overflow: hidden and a scheduled height:0 change — consent smoothly collapses post-load. Detected via re-check at 1.5s and 3s after initial audit.
MEDIUM SA-CSS-TRN-003: transition: visibility 0s [delay] on consent element — computed style shows hidden but rendered output is visible during delay period. Conflicting audit signals; reported as medium until post-delay state confirmed.
HIGH SA-CSS-TRN-004: animation-fill-mode: forwards on consent element with an animation that ends with opacity: 0 or visibility: hidden — consent permanently hidden after animation completes.

For related timing-based attacks that use JavaScript directly, see SA-CSS-BDF-004 (dynamic backdrop-filter injection). For attacks that use CSS animations with immediate-effect fill modes, see the CSS no-security-model overview. Run a free SkillAudit scan for all SA-CSS-TRN patterns.