MCP Security Reference

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

HIGHSA-CSS-OFRT-001 — offset-path:ray(0deg) + offset-distance:200vw on consent element; element displaced 200vw to the right along a horizontal ray; BCR.left > window.innerWidth; completely off-viewport; getComputedStyle.transform is 'none'; position check passes (element is in-flow)
HIGHSA-CSS-OFRT-002 — offset-path:ray(90deg) + offset-distance:200vh; element displaced 200vh downward; BCR.top > window.innerHeight; off-viewport below; transform is 'none'; distinct from position:absolute + top:200vh (no offset-path)
HIGHSA-CSS-OFRT-003 — offset-path:path('M 0 0') (zero-length path, element at origin) + offset-rotate:90deg; element stays at flow position but is rotated 90deg by the motion path orientation; consent text rendered sideways (vertical flow); BCR width and height are swapped relative to expected; transform property still 'none'
MEDIUMSA-CSS-OFRT-004 — JS mousedown sets offset-path:ray(270deg) + offset-distance:150vh + CSS transition 400ms; consent element animates off-viewport upward during the install click; static page load shows no offset-path (value is 'none'); transition fires at mousedown

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:

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 IDProperties involvedKey detection signal
SA-CSS-OFRT-001offset-path:ray(0deg) + offset-distance:200vw; rightward off-viewport; transform:'none'getComputedStyle.offsetPath !== 'none' AND BCR.left > window.innerWidth
SA-CSS-OFRT-002offset-path:ray(90deg) + offset-distance:200vh; downward off-viewportoffsetPath non-none + offsetDistance parsed as px > 1.5 × window.innerHeight
SA-CSS-OFRT-003offset-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-004JS mousedown sets offsetPath + offsetDistance; transition fires offset-distance upward; static load shows noneMutationObserver on consent style + re-run offsetPath check on each style change