MCP server CSS offset-rotate security
CSS Motion Path Level 1 defines a set of properties — offset-path, offset-distance, offset-position, and offset-rotate — that move and orient an element along an arbitrary geometric path. MCP servers exploit this subsystem to displace consent elements off-viewport via offset-path: ray() with large offset-distance values, or to render consent text perpendicular to the reading direction with offset-rotate: 90deg. Critically, CSS Motion Path positioning is not a CSS transform: getComputedStyle(el).transform returns 'none' throughout. Detection requires separately checking getComputedStyle(el).offsetPath, getComputedStyle(el).offsetDistance, and getComputedStyle(el).offsetRotate.
Attack findings
Background: CSS Motion Path and offset properties
CSS Motion Path Level 1 (shipped in Chrome 55+, Firefox 72+, Safari 15.4+) allows any element to be positioned and rotated along a geometric path. The four key properties are:
- offset-path — defines the path shape:
path()(SVG path syntax),ray()(directional ray from the element's anchor), CSS shape references (circle(),ellipse(),inset()), ornone. - offset-distance — how far along the path the element travels (percentage or length).
0%= start of path;100%= end of path. On aray()path, distance is a direct length in the ray direction. - offset-rotate — controls the element's rotation at each point on the path:
auto(face the path tangent direction),auto <angle>(tangent + offset), or an explicit fixed angle like90deg. - offset-position — the starting anchor point for the path, relative to the element's containing block.
Crucially, motion path positioning is not implemented via the CSS transform matrix. getComputedStyle(el).transform returns 'none' on an element positioned by motion path. A security detector that checks transform !== 'none' as its off-viewport displacement test will not detect motion-path-based displacement.
Detection gap: getComputedStyle(el).transform is always 'none' regardless of offset-path positioning. getBoundingClientRect() correctly reports the element's visual position (off-viewport when displaced), but detectors that check BCR only after verifying in-DOM and non-zero opacity may still miss the combination. The CSSOM properties to explicitly check are offsetPath, offsetDistance, and offsetRotate — none of which overlap with the standard transform API.
Attack 1 — horizontal off-viewport displacement via ray() (SA-CSS-OFRT-001)
A ray() path fires in a specified direction from the element's offset-position anchor. With offset-path: ray(0deg) (pointing right, 0 degrees being the CSS direction for "right" in motion path) and offset-distance: 200vw, the consent element is displaced 200 viewport-widths to the right of its anchor point. The element remains in the normal document flow (it still occupies its layout box for flow purposes), but its visual rendering is 200vw to the right — completely off-screen. getBoundingClientRect().left returns a value approximately equal to 200 × window.innerWidth. The element is in the DOM, has non-zero dimensions, non-zero opacity, and correct text content. Only the BCR left position reveals the displacement — and only if the detector also checks getComputedStyle(el).offsetPath to understand why the BCR is off-viewport (to distinguish from a developer intentionally positioning something off-screen for slide-in animation).
/* Attack: horizontal ray displacement */
.consent-text {
offset-path: ray(0deg); /* rightward ray from anchor */
offset-distance: 200vw; /* 200 viewport-widths to the right */
/* transform: none — no CSS transform */
/* visibility: visible */
/* opacity: 1 */
/* display: block */
}
/* Detection */
function checkMotionPathOffViewport(consentEl) {
const cs = getComputedStyle(consentEl);
if (cs.offsetPath === 'none') return null;
const bcr = consentEl.getBoundingClientRect();
const offRight = bcr.left > window.innerWidth;
const offBottom = bcr.top > window.innerHeight;
const offLeft = bcr.right < 0;
const offTop = bcr.bottom < 0;
if (offRight || offBottom || offLeft || offTop) {
return {
vuln: 'SA-CSS-OFRT-001',
detail: `offset-path:'${cs.offsetPath}' offset-distance:'${cs.offsetDistance}' — element off-viewport via motion path`
};
}
return null;
}
SA-CSS-OFRT-001 (High). The consent element is in-DOM, non-hidden, in-flow — all standard checks pass. The BCR off-viewport check catches the displacement, but the root cause is offsetPath + offsetDistance. Flag: getComputedStyle(el).offsetPath !== 'none' on any consent ancestor or the consent element itself, then confirm BCR position.
Attack 2 — vertical off-viewport displacement via ray(90deg) (SA-CSS-OFRT-002)
The same ray technique applied downward: offset-path: ray(90deg) (pointing down, since CSS Motion Path uses clockwise angles from "right") + offset-distance: 200vh pushes the consent element 200 viewport-heights below its anchor. The element is below the viewport bottom. getBoundingClientRect().top returns approximately 200 × window.innerHeight. Scrolling would reach the logical element position, but the dialog typically has overflow: hidden preventing scroll. This is distinct from ray(0deg) and distinct from position: absolute; top: 200vh — both the property name and the mechanism differ.
/* Attack: downward ray displacement */
.consent-text {
offset-path: ray(90deg); /* downward ray */
offset-distance: 200vh; /* 200vh below anchor */
}
/* Detection: check offsetPath for ray() with large offsetDistance */
function checkRayDistance(consentEl) {
const cs = getComputedStyle(consentEl);
const path = cs.offsetPath;
if (!path || path === 'none') return null;
const distance = cs.offsetDistance;
if (!distance) return null;
const numericPx = parseFloat(distance);
const vhThreshold = window.innerHeight * 1.5;
const vwThreshold = window.innerWidth * 1.5;
if (numericPx > vhThreshold || numericPx > vwThreshold) {
return { vuln: 'SA-CSS-OFRT-002', detail: `offset-path ${path} offset-distance ${distance}` };
}
return null;
}
Attack 3 — 90deg rotation via offset-rotate at static path position (SA-CSS-OFRT-003)
An element can be rotated by offset-rotate even with a zero-length or minimal path. With offset-path: path('M 0 0') (a degenerate path at the origin), the element stays at its flow position — but the path establishes an orientation context. offset-rotate: 90deg rotates the element 90 degrees clockwise. The consent text is now rendered vertically, with each character appearing rotated — readable in portrait orientation but not in standard landscape horizontal reading. The element's getBoundingClientRect() width and height are effectively swapped: a 300px wide × 50px tall consent block becomes visually 50px wide × 300px tall. The original layout dimensions (offsetWidth, offsetHeight) still report pre-rotation values. getComputedStyle.transform remains 'none'. Only getComputedStyle.offsetRotate reveals the 90-degree rotation.
/* Attack: 90deg rotation via offset-rotate without displacement */
.consent-text {
offset-path: path('M 0 0'); /* degenerate path — no displacement */
offset-rotate: 90deg; /* rotates element at path start */
/* getComputedStyle.transform: 'none' */
/* BCR: width/height swapped relative to layout dimensions */
}
/* Detection */
function checkOffsetRotation(consentEl) {
const cs = getComputedStyle(consentEl);
if (cs.offsetPath === 'none') return null;
const rotStr = cs.offsetRotate || '';
const fixedAngleMatch = rotStr.match(/(?:^|\s)(-?[\d.]+)deg/);
if (fixedAngleMatch) {
const angle = Math.abs(parseFloat(fixedAngleMatch[1]));
if (angle > 30) {
return {
vuln: 'SA-CSS-OFRT-003',
detail: `offset-rotate:${rotStr} — consent text rotated ${angle}deg via motion path; transform is 'none'`
};
}
}
/* also check: auto (element faces path tangent — may be sideways) */
if (rotStr.startsWith('auto')) {
const bcr = consentEl.getBoundingClientRect();
const expectedWider = consentEl.offsetWidth > consentEl.offsetHeight;
const visuallyTaller = bcr.height > bcr.width;
if (expectedWider && visuallyTaller) {
return { vuln: 'SA-CSS-OFRT-003', detail: 'offset-rotate:auto — element oriented perpendicular to path tangent; text sideways' };
}
}
return null;
}
Attack 4 — JS mousedown motion path injection (SA-CSS-OFRT-004)
At page load, the consent element has no motion path properties: offset-path: none. A CSS transition on offset-distance is present in the stylesheet (looks like a standard slide animation declaration). At mousedown, JS sets offset-path: ray(270deg) (pointing upward) and offset-distance: 150vh. The consent element animates upward, off-viewport above the top of the screen, over 400ms — the duration of the install click gesture. When the click event fires, the consent is above the viewport. A static audit at page load finds no motion path — the attack is runtime-only. MutationObserver on the consent element's style attribute catches the injection.
/* Attack: runtime motion path injection at mousedown */
/* In stylesheet: */
.consent-text {
offset-distance: 0;
transition: offset-distance 400ms ease-in; /* looks like animation setup */
}
/* At mousedown: */
installBtn.addEventListener('mousedown', () => {
consentEl.style.offsetPath = 'ray(270deg)'; /* upward ray */
consentEl.style.offsetDistance = '150vh'; /* 150vh above anchor */
});
/* Detection: MutationObserver on consent style attribute */
new MutationObserver(() => {
const cs = getComputedStyle(consentEl);
if (cs.offsetPath !== 'none') {
const finding = checkMotionPathOffViewport(consentEl);
if (finding) {
flagTampering('SA-CSS-OFRT-004');
installBtn.disabled = true;
}
}
}).observe(consentEl, { attributes: true, attributeFilter: ['style'] });
SkillAudit detection: SkillAudit checks getComputedStyle(el).offsetPath on consent elements and their ancestors. If a non-none value is found, it checks offsetDistance for large values (> 1.5× viewport dimensions), offsetRotate for angles above 30 degrees, and confirms the consent element's BCR position is within the viewport. It also monitors for runtime offsetPath injection via MutationObserver during the simulated install click. Run a free audit →
Detection summary
| Attack ID | Properties involved | Key detection signal |
|---|---|---|
| SA-CSS-OFRT-001 | offset-path:ray(0deg) + offset-distance:200vw; rightward off-viewport; transform:'none' | getComputedStyle.offsetPath !== 'none' AND BCR.left > window.innerWidth |
| SA-CSS-OFRT-002 | offset-path:ray(90deg) + offset-distance:200vh; downward off-viewport | offsetPath non-none + offsetDistance parsed as px > 1.5 × window.innerHeight |
| SA-CSS-OFRT-003 | offset-path:path('M 0 0') + offset-rotate:90deg; no displacement but 90deg rotation; transform still 'none' | parse offsetRotate for fixed angle; Math.abs(angle) > 30 OR BCR width/height swap vs layout dimensions |
| SA-CSS-OFRT-004 | JS mousedown sets offsetPath + offsetDistance; transition fires offset-distance upward; static load shows none | MutationObserver on consent style + re-run offsetPath check on each style change |