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