CSS Motion Path Attacks as MCP Consent Bypass: offset-path, offset-distance, offset-position, offset-rotate
CSS Motion Path Level 1 and Level 2 introduce four properties — offset-path, offset-distance, offset-position, and offset-rotate — that position elements along SVG paths, ray angles, and shape references. The critical security gap: none of these displacements appear in getComputedStyle(el).transform. A consent dialog positioned 200vw to the right with offset-path: ray(0deg); offset-distance: 200vw has transform: none. Standard transform-based detectors are completely blind to it.
Contents
- The transform blind spot
- All four motion path properties
- Attack 1: ray() off-viewport horizontal
- Attack 2: ray() off-viewport vertical
- Attack 3: path() SVG coordinate displacement
- Attack 4: offset-rotate sideways text
- Attack 5: animated offset-distance (consent "falls off" the path)
- Attack 6: JS mousedown offset-path injection with transition
- Detection gaps table
- Unified ConsentMotionPathAudit
The transform blind spot that makes motion path dangerous
CSS transforms (transform: translateX(), transform: rotate(), etc.) are detected by reading getComputedStyle(el).transform. A non-identity matrix there is a well-known signal for off-screen positioning attacks. The CSS Motion Path specification defines a separate positioning mechanism that is not part of the CSS Transform system. Elements positioned via motion path properties are laid out by the browser using a completely different rendering pipeline — one that consults offset-path, offset-distance, offset-position, and offset-rotate as a group, separate from the transform property stack.
The result is a hard detection gap: a consent dialog moved to offset-path: ray(0deg); offset-distance: 200vw has getComputedStyle(el).transform === 'none'. Every transform-aware scanner passes it. The element's getBoundingClientRect() reports the actual rendered position — off-screen — but a scanner that checks BCR only after confirming the transform is identity will not reach the BCR check for elements using motion path positioning.
Critical detection gap. getComputedStyle(el).transform always returns 'none' for elements positioned exclusively via CSS Motion Path. The correct detection signal is getComputedStyle(el).offsetPath !== 'none'. Check offsetPath, offsetDistance, offsetPosition, and offsetRotate as a separate check axis from the transform property. Any scanner that only checks transform is blind to the entire motion path attack surface.
All four CSS Motion Path properties
offset-path
Defines the curve, ray, or shape the element travels along. Values: ray(), path(), url(#svgPath), shape references (circle(), ellipse(), inset(), polygon()), none.
offset-distance
How far along the path the element is placed, as a length or percentage. 0% = path start. 100% = path end. Lengths beyond the path extend along the tangent direction for ray() paths.
offset-position
CSS Motion Path Level 2. Sets the initial position on the element's containing block from which the path starts. Equivalent to setting the transform-origin for motion path. Values: keyword pairs or explicit coordinates.
offset-rotate
Controls how the element is rotated as it travels along the path. auto = element faces the path tangent. A fixed angle (e.g., 90deg) rotates the element to that angle regardless of path direction. Does NOT appear in transform.
Motion path positioning is additive with CSS transforms — if both are applied, both contribute to the element's rendered position. However, the motion path contribution is always invisible to getComputedStyle(el).transform. Only the CSS transform contribution appears there.
ray() off-viewport horizontal — consent dialog pushed 200vw right
offset-path: ray(0deg) with offset-distance: 200vw places the element 200 viewport-widths to the right. transform: 'none'. BCR.left ≫ window.innerWidth.
A ray path in CSS Motion Path points from the element's initial position in the direction specified by the angle. ray(0deg) points directly to the right (CSS angles use the same convention as clock hands: 0deg = up, 90deg = right — but ray() follows the SVG coordinate convention: 0deg = right). With offset-distance: 200vw, the element is placed 200 viewport-widths along the rightward ray — completely off-screen in any realistic viewport.
Attack code
.consent-dialog {
/* positions the dialog along a rightward ray, 200vw from its normal position */
offset-path: ray(0deg);
offset-distance: 200vw;
/* getComputedStyle(el).transform returns 'none' */
/* getComputedStyle(el).offsetPath returns 'ray(0deg)' */
/* getBoundingClientRect().left > window.innerWidth * 200 */
}
The element is fully outside the viewport but remains in the DOM with display:block, visibility:visible, opacity:1, and non-empty textContent. Its getBoundingClientRect() reports a left value far exceeding window.innerWidth. Every check except BCR position passes. A detector that gates BCR checks on a transform === 'none' pre-condition incorrectly assumes no positional attack is active and skips the BCR check.
Detection
function checkMotionPathOffViewport(el) {
const cs = getComputedStyle(el);
const offsetPath = cs.offsetPath || cs.motionPath;
if (!offsetPath || offsetPath === 'none') return null;
// motion path is active — always check BCR regardless of transform
const bcr = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (bcr.right < 0 || bcr.left > vw || bcr.bottom < 0 || bcr.top > vh) {
return {
vuln: 'SA-CSS-MOTP-001',
detail: `offset-path active; BCR off-viewport: left=${bcr.left}, top=${bcr.top}`
};
}
return null;
}
SA-CSS-MOTP-001 (High). offset-path: ray(0deg); offset-distance: 200vw — element 200vw right of viewport. getComputedStyle(el).transform returns 'none'. Detection requires checking offsetPath !== 'none' first, then always performing a BCR bounds check unconditionally of transform state.
ray() off-viewport vertical — consent dialog pushed 200vh below
offset-path: ray(180deg) with offset-distance: 200vh places the element 200 viewport-heights below. transform: 'none'. BCR.top ≫ window.innerHeight.
ray(180deg) in the SVG coordinate system points downward (180deg = south). With offset-distance: 200vh, the element is placed 200 viewport-heights below its containing block's initial position. Like the horizontal variant, getComputedStyle(el).transform returns 'none'. The BCR top value exceeds window.innerHeight × 200. This variant is particularly hard to catch in scroll-position based detectors — the element is far below, which might appear similar to an element that simply requires scrolling. The key difference: in a modal overlay or fixed-position consent dialog, vertical off-screen positioning via motion path is never legitimate.
.consent-text {
offset-path: ray(180deg); /* SVG 180deg = downward */
offset-distance: 200vh; /* 200 viewport-heights below origin */
/* transform: 'none' — standard transform check passes */
/* BCR.top ≈ window.innerHeight * 200 */
}
/* if consent is in a fixed-position dialog, it will never scroll into view */
For fixed-position consent containers (common in modal overlays), the viewport-relative BCR is the definitive check. A consent element with position: fixed on its ancestor and a BCR top value greater than window.innerHeight is definitively off-screen and inaccessible, regardless of scrolling.
SA-CSS-MOTP-002 (High). offset-path: ray(180deg); offset-distance: 200vh — consent 200vh below viewport. For position: fixed consent containers, vertical off-screen placement via motion path is never a legitimate layout pattern. BCR check must not gate on transform state.
path() SVG coordinate displacement — element placed at far SVG coordinate
offset-path: path('M 0 0 L 5000 0') with offset-distance: 100% places the element at SVG coordinate (5000, 0). transform: 'none'.
The path() function in offset-path accepts an SVG path data string. The element travels along that path, with offset-distance: 100% meaning the element is at the path's end point. An SVG path M 0 0 L 5000 0 ends at coordinate (5000, 0) — 5000 CSS pixels to the right. The coordinate system is relative to the element's containing block, so if the containing block is at viewport origin, the consent element ends up 5000px off-screen to the right.
.consent-text {
/* SVG path ending 5000px right of containing block origin */
offset-path: path('M 0 0 L 5000 0');
offset-distance: 100%; /* element placed at path end point (5000, 0) */
/* getComputedStyle(el).transform: 'none' */
}
The path() function's argument string appears in getComputedStyle(el).offsetPath. A detector that checks for large coordinate values in the path data catches this variant. The simplest heuristic: if offsetPath contains path(, extract the path data string and scan for coordinate values (numbers following M, L, C, Q, A commands) greater than max(window.innerWidth, window.innerHeight) × 2.
Shape reference variant
CSS Motion Path Level 2 also allows shape references in offset-path: circle(100% at center), ellipse(200vw 200vh at center). At offset-distance: 100%, a circle(200vw at center) path places the element 200vw to the right of center along the circle perimeter — again, off-screen with transform: 'none'.
.consent-text {
/* circle 200vw radius; at 100% distance, element is 200vw right of center */
offset-path: circle(200vw at center);
offset-distance: 25%; /* 0% = top, 25% = right, 50% = bottom, 75% = left */
/* BCR.left ≈ window.innerWidth * 200 */
/* transform: 'none' */
}
SA-CSS-MOTP-003 (High). path() with large end coordinates or shape references (circle(), ellipse()) with radii exceeding viewport dimensions. Both produce off-screen placement with transform: 'none'. Detection: parse offsetPath string for large coordinate values or large radius lengths in shape functions.
offset-rotate sideways text — element rotated 90° without touching transform
offset-path: path('M 0 0') with offset-rotate: 90deg stays at flow position but rotates text sideways. BCR width/height are swapped. transform: 'none'.
offset-rotate controls the rotational orientation of an element as it travels along its path. With offset-path: path('M 0 0') (a degenerate path with zero length), the element stays at its normal flow position — but with a fixed offset-rotate: 90deg, the element is rotated 90 degrees clockwise. The consent text appears as a column of sideways characters. Critically, this rotation is applied by the motion path system, not by the CSS transform system. getComputedStyle(el).transform returns 'none'.
The BCR reveals the rotation indirectly: the reported width and height are swapped relative to the element's intrinsic layout dimensions. A 400px-wide × 80px-tall consent box appears as 80px-wide × 400px-tall in BCR after 90° rotation. This width/height inversion is a reliable detection signal for 90° motion path rotation attacks.
.consent-text {
/* zero-length path: element stays at flow position */
offset-path: path('M 0 0');
/* 90deg rotation — text is sideways, unreadable */
offset-rotate: 90deg;
/* getComputedStyle(el).transform: 'none' */
/* getComputedStyle(el).offsetRotate: '90deg' (or '90.00deg') */
}
/* Detection: check offsetRotate for fixed angles beyond readable range */
function checkMotionPathRotation(el) {
const cs = getComputedStyle(el);
if (!cs.offsetPath || cs.offsetPath === 'none') return null;
const rotStr = cs.offsetRotate || '';
if (rotStr === 'auto' || rotStr === 'auto reverse') return null;
// parse angle value from string like '90deg' or '45.5deg'
const angleMatch = rotStr.match(/([-\d.]+)deg/);
if (!angleMatch) return null;
const angle = Math.abs(parseFloat(angleMatch[1]) % 360);
const normalizedAngle = angle > 180 ? 360 - angle : angle;
if (normalizedAngle > 30 && normalizedAngle < 330) {
return {
vuln: 'SA-CSS-MOTP-004',
detail: `offset-rotate: ${rotStr} — text rotated ${normalizedAngle}° by motion path system; transform: 'none'`
};
}
return null;
}
SA-CSS-MOTP-004 (High). offset-path: path('M 0 0'); offset-rotate: 90deg — consent text rotated 90° sideways while remaining at flow position. transform: 'none'. Detection: parse getComputedStyle(el).offsetRotate for fixed angles; flag |angle| > 30°.
Animated offset-distance — consent "falls off" the path during click gesture
A CSS animation targeting offset-distance increases it from 0% to 100% over a scroll-timeline or fixed duration, moving consent off-screen as the user interacts.
CSS Motion Path Level 2 integrates with scroll-timeline and other CSS animation features. An animation targeting offset-distance can smoothly displace an element along its path. An attacker preloads a path starting at the consent dialog's position and ending 200vw off-screen, then triggers an animation that runs over 500ms. If this animation starts at the same time as a mousedown event — either via a CSS animation with animation-play-state: paused that is toggled to running on mousedown, or via JS setting the animation directly — the consent dialog begins to slide off-screen in the click frame. By the time the click event fires, the consent is partially or fully off-screen.
@keyframes consent-slide-off {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}
.consent-text {
offset-path: ray(0deg); /* rightward ray */
offset-distance: 0%; /* starts at normal position */
/* paused at load — transform: 'none' at static audit time */
animation: consent-slide-off 500ms ease-in forwards;
animation-play-state: paused;
}
/* at mousedown: */
document.getElementById('install-btn').addEventListener('mousedown', () => {
document.querySelector('.consent-text').style.animationPlayState = 'running';
/* consent slides 200vw right over 500ms; click fires before animation completes */
});
/* Detection: check for paused animations on offsetDistance */
function checkMotionPathAnimation(el) {
const cs = getComputedStyle(el);
const animName = cs.animationName;
const animDuration = cs.animationDuration;
const playState = cs.animationPlayState;
if (cs.offsetPath && cs.offsetPath !== 'none') {
if (playState === 'paused' && animDuration !== '0s') {
return {
vuln: 'SA-CSS-MOTP-005',
detail: `motion path active with paused animation '${animName}' — may fire on mousedown`
};
}
}
return null;
}
SA-CSS-MOTP-005 (Medium). Motion path with paused CSS animation targeting offset-distance. The animation plays state is toggled at mousedown, sliding consent off-screen. Static audit time: element appears in-viewport with transform: 'none'. Detection: check for offsetPath !== 'none' combined with animationPlayState === 'paused' and non-zero duration.
JS mousedown offset-path injection with CSS transition
At page load, offsetPath is 'none'. At mousedown, JS sets offset-path + offset-distance inline. A pre-declared CSS transition fires immediately, sliding consent off-screen in the click frame.
The most evasion-resistant form of the motion path attack combines runtime property injection with CSS transitions. At page load, the consent element has no motion path properties — a static audit finds nothing suspicious. The element is positioned normally. A transition: offset-distance 400ms ease-in is declared at load time — this looks like an animation performance optimization, which is common in UI code.
At mousedown, JS sets el.style.offsetPath = 'ray(0deg)' and el.style.offsetDistance = '200vw' inline. The browser evaluates the transition: the previous value of offset-distance was implicitly 0% (initial), and the new value is 200vw. The transition fires immediately and the element begins sliding rightward. The click event fires approximately 100ms into a 400ms transition — the consent is already 50% off-screen when the install action executes.
/* Declared at load time — looks like UI animation prep */
.consent-dialog {
transition: offset-distance 400ms ease-in;
}
/* At mousedown (JS): */
installBtn.addEventListener('mousedown', () => {
consentEl.style.offsetPath = 'ray(0deg)';
consentEl.style.offsetDistance = '200vw';
/* transition: offset-distance transitions from 0% to 200vw over 400ms */
/* click fires at ~100ms — consent is 25% off-screen already */
});
/* Static audit: no offset-path at load time. animationName: 'none'. Clean. */
/* Detection: MutationObserver watching for inline style changes on consent element */
new MutationObserver((mutations) => {
for (const m of mutations) {
const el = m.target;
const cs = getComputedStyle(el);
if (cs.offsetPath && cs.offsetPath !== 'none') {
flagTampering('SA-CSS-MOTP-006');
installBtn.disabled = true;
}
}
}).observe(consentEl, {
attributes: true,
attributeFilter: ['style']
});
The same pattern applies to CSS custom properties: a var(--offset-dist) in the offset-distance value, with JS setting the custom property value on the document root at mousedown. The MutationObserver must watch both inline style changes and document root style changes to catch this variant.
SA-CSS-MOTP-006 (High). Pre-declared transition: offset-distance + JS mousedown injection of offset-path and offset-distance. Static audit: offsetPath === 'none'. Runtime attack fires on mousedown. Detection: MutationObserver on consent element's style attribute; re-check offsetPath on every change.
Detection gaps: what every check returns
| Check | Attack 1 (ray horizontal) | Attack 3 (path SVG) | Attack 4 (offset-rotate) | Attack 6 (JS inject) |
|---|---|---|---|---|
getComputedStyle(el).display |
PASS (block) | PASS (block) | PASS (block) | PASS (block) |
getComputedStyle(el).visibility |
PASS (visible) | PASS (visible) | PASS (visible) | PASS (visible) |
getComputedStyle(el).opacity |
PASS (1) | PASS (1) | PASS (1) | PASS (1) |
getComputedStyle(el).transform |
PASS (none) | PASS (none) | PASS (none) | PASS (none) at load time |
el.textContent.trim().length |
PASS (non-zero) | PASS (non-zero) | PASS (non-zero) | PASS (non-zero) |
el.getBoundingClientRect() |
FAILS (BCR off-viewport) | FAILS (BCR off-viewport) | PASS (in-viewport, just rotated) | FAILS (after injection) |
getComputedStyle(el).offsetPath |
FAILS (ray(0deg)) | FAILS (path(...)) | FAILS (path('M 0 0')) | FAILS (after injection) |
getComputedStyle(el).offsetRotate |
0deg (not useful) | 0deg (not useful) | FAILS (90deg) | 0deg (not useful) |
The table shows the core asymmetry: transform always passes for motion path attacks, while offsetPath is the reliable detection signal. BCR fails for off-viewport attacks but passes for the rotation variant (attack 4) — making offsetRotate the necessary check for rotation-only attacks.
Unified ConsentMotionPathAudit
The following class wraps all six attack patterns into a single audit call and a runtime monitor:
class ConsentMotionPathAudit {
static auditElement(el) {
const cs = getComputedStyle(el);
const findings = [];
// 1. Is motion path active at all?
const offsetPath = cs.offsetPath || cs.motionPath || 'none';
if (offsetPath === 'none') return findings; // no motion path → no motion path attacks
// 2. Check off-viewport BCR (Attacks 1, 2, 3)
const bcr = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (bcr.right < -50 || bcr.left > vw + 50 || bcr.bottom < -50 || bcr.top > vh + 50) {
findings.push({
id: 'SA-CSS-MOTP-001',
severity: 'critical',
detail: `offset-path active; element off-viewport: BCR(${Math.round(bcr.left)}, ${Math.round(bcr.top)}, ${Math.round(bcr.right)}, ${Math.round(bcr.bottom)})`
});
}
// 3. Check offset-rotate for non-trivial rotation (Attack 4)
const rotStr = cs.offsetRotate || '';
if (rotStr !== 'auto' && rotStr !== 'auto reverse' && rotStr !== '0deg') {
const m = rotStr.match(/([-\d.]+)deg/);
if (m) {
let angle = Math.abs(parseFloat(m[1]) % 360);
if (angle > 180) angle = 360 - angle;
if (angle > 30) {
findings.push({
id: 'SA-CSS-MOTP-004',
severity: 'high',
detail: `offset-rotate: ${rotStr} — consent text rotated ${angle}° by motion path; transform: 'none'`
});
}
}
}
// 4. Check for paused animation (Attack 5)
if (cs.animationName !== 'none' && cs.animationPlayState === 'paused') {
findings.push({
id: 'SA-CSS-MOTP-005',
severity: 'medium',
detail: `motion path + paused animation '${cs.animationName}'; may fire on user interaction`
});
}
// 5. Check for transition on offset-distance (Attack 6 setup signal)
const transition = cs.transition || '';
if (transition.includes('offset-distance') || transition.includes('offset-path')) {
findings.push({
id: 'SA-CSS-MOTP-006',
severity: 'medium',
detail: `transition declared for offset-distance/offset-path; JS may inject path at mousedown`
});
}
return findings;
}
static installRuntimeMonitor(consentEl, installBtn, onTamper) {
const recheck = () => {
const findings = this.auditElement(consentEl);
if (findings.length > 0) {
onTamper(findings);
installBtn.disabled = true;
}
};
// Watch inline style changes on consent element
const mo = new MutationObserver(recheck);
mo.observe(consentEl, { attributes: true, attributeFilter: ['style', 'class'] });
// Watch document root for CSS custom property changes
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
// Watch document.head for injected