MCP server CSS offset-anchor security: motion path anchor displacement, corner-pinned off-viewport, transform-origin divergence, and JS interaction-time anchor attacks
Published 2026-08-19 — SkillAudit Research
CSS offset-anchor is part of the CSS Motion Path Module Level 1. It defines which point on an element is placed exactly on the element's offset-path. The default value is auto, which aligns to the element's transform-origin (itself defaulting to 50% 50% — the element center). By changing offset-anchor to a corner or edge, an attacker shifts which body point contacts the path — moving the element's visual bulk into off-screen or invisible territory while the path position itself remains in-viewport.
The attack surface is a refinement of the offset-path displacement attack: instead of sending the path itself off-screen, the attacker keeps the path position on-screen but uses offset-anchor to ensure the element's center — and thus its consent content — is displaced. Standard checks that test whether the element's path position is in-viewport are defeated because the path contact point is in-viewport; only the rendered body is not.
Detection gap: getBoundingClientRect() returns the element's actual rendered position (including anchor displacement), so BCR-based out-of-viewport checks do catch the final position — but only if they use BCR on the element itself. Scanners that compute visibility from the path position or from offset-distance alone, without reading BCR post-render, miss the anchor-induced displacement.
Attack 1 (SA-CSS-OANCH-001): offset-anchor:0% 0% with path endpoint places element body off-screen right
With offset-anchor: 0% 0%, the element's top-left corner sits at the path position. For a path that ends at viewport right edge (offset-path: ray(0deg); offset-distance: 100%), the path endpoint is at the right edge of the containing block — but the element's body extends from that point to the right and downward. An element 300px wide would have its center at viewport_right + 150px, fully off-screen, while the top-left corner contact point is exactly at the viewport edge (and thus registers as "in-viewport" for checks that test the path contact point):
/* MCP attack: top-left corner on path endpoint — element body displaced right */
.consent-dialog {
position: absolute;
width: 300px;
offset-path: ray(0deg);
offset-distance: calc(100% - 1px); /* path endpoint: 1px from right edge */
offset-anchor: 0% 0%; /* top-left corner contacts path — body extends right */
/* Element center is at viewport_right + 149px — off-screen
getComputedStyle(el).transform: "none" (motion path does not affect transform)
getComputedStyle(el).offsetPath: "ray(0deg)" — detectable
getBoundingClientRect().left: viewport_width - 1px — tests pass for left > 0
getBoundingClientRect().right: viewport_width - 1px + 300px = viewport_width + 299px
— right > viewport_width reveals off-screen body */
}
/* Detection: */
function detectOffAnchorDisplacement(el) {
const cs = getComputedStyle(el);
if (cs.offsetPath === 'none' && !cs.offsetAnchor) return null;
const bcr = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const offRight = bcr.right > vw + 10;
const offLeft = bcr.left < -10;
const offBottom = bcr.bottom > vh + 10;
const offTop = bcr.top < -10;
if (offRight || offLeft || offBottom || offTop) {
return {
severity: 'Critical',
finding: 'SA-CSS-OANCH-001',
offsetAnchor: cs.offsetAnchor,
offsetPath: cs.offsetPath,
offsetDistance: cs.offsetDistance,
bcr: { left: bcr.left, right: bcr.right, top: bcr.top, bottom: bcr.bottom },
reason: `offset-anchor: ${cs.offsetAnchor} displaces element body: BCR ${JSON.stringify({left:Math.round(bcr.left),right:Math.round(bcr.right),top:Math.round(bcr.top),bottom:Math.round(bcr.bottom)})} vs viewport ${vw}×${vh}. Consent content is off-screen despite path position appearing valid.`
};
}
return null;
}
Attack 2 (SA-CSS-OANCH-002): offset-anchor:100% 100% with path at origin pins element body above-left of viewport
The inverse attack places the bottom-right corner at the path origin (coordinate 0,0 of the containing block). The element body extends upward and to the left — both into negative coordinates, placing the entire element off-screen above and to the left. The path contact point is at the origin (coordinate 0,0), which a scanner testing for positive path coordinates would consider in-range:
/* MCP attack: bottom-right corner at path origin — element extends above-left */
.consent-dialog {
width: 280px;
height: 160px;
offset-path: ray(0deg);
offset-distance: 0%; /* path position: containing block origin */
offset-anchor: 100% 100%; /* bottom-right corner contacts origin */
/* Element body: x from -280px to 0, y from -160px to 0
Entire element is in negative coordinates — off-screen top-left
getBoundingClientRect(): left: -280, right: 0, top: -160, bottom: 0
— BCR is fully off-screen */
}
/* Additional vector: offset-distance at a path point that is just in-viewport,
but with 100% 100% anchor, the element extends past the viewport edges.
Example: path at 50% 50% of viewport with a 400px-wide element
and offset-anchor: 100% 100% → element spans from (50vw-400, 50vh-200)
to (50vw, 50vh) — body may partially clip out of viewport.
The clipped region may contain exactly the key permission text. */
Attack 3 (SA-CSS-OANCH-003): offset-anchor diverges from transform-origin — detection evasion compound
The auto value for offset-anchor uses the element's transform-origin. An attacker can set both to non-center values while ensuring they point to the same location — making the anchor appear "consistent with transform-origin" — while the actual non-center anchor still displaces the element body. A scanner checking whether offsetAnchor === transformOrigin as a consistency check would report clean while the element is actually displaced:
/* Compound evasion: offset-anchor matches transform-origin but both are non-center */
.consent-dialog {
transform-origin: 0% 0%; /* top-left corner */
offset-anchor: auto; /* auto → uses transform-origin → 0% 0% */
offset-path: ray(0deg);
offset-distance: 100%;
/* offsetAnchor resolves to 0% 0% (auto matches transform-origin)
A naive check: offsetAnchor === 'auto' → might assume default center
But 'auto' resolution depends on transform-origin: 0% 0% here
→ element top-left at path end → body extends off-screen
Detection: check getComputedStyle(el).offsetAnchor value when not 'auto'
AND check getComputedStyle(el).transformOrigin when offsetAnchor is 'auto'
— if transformOrigin is non-center, offsetAnchor resolves to non-center */
}
function detectOffAnchorAuto(el) {
const cs = getComputedStyle(el);
if (cs.offsetPath === 'none') return null;
const anchor = cs.offsetAnchor;
let resolvedX = 50, resolvedY = 50; // percent
if (anchor === 'auto') {
// Resolve through transform-origin
const to = cs.transformOrigin; // e.g. "0px 0px" or "150px 80px"
const w = el.offsetWidth, h = el.offsetHeight;
const parts = to.split(' ').map(parseFloat);
if (w > 0 && h > 0) {
resolvedX = (parts[0] / w) * 100;
resolvedY = (parts[1] / h) * 100;
}
} else {
const parts = anchor.replace('%','').split(' ').map(parseFloat);
resolvedX = parts[0]; resolvedY = parts[1];
}
const nonCenter = Math.abs(resolvedX - 50) > 15 || Math.abs(resolvedY - 50) > 15;
if (nonCenter) {
return {
severity: 'High',
finding: 'SA-CSS-OANCH-003',
offsetAnchor: anchor,
transformOrigin: cs.transformOrigin,
resolvedAnchor: `${resolvedX.toFixed(0)}% ${resolvedY.toFixed(0)}%`,
reason: `offset-anchor resolves to ${resolvedX.toFixed(0)}% ${resolvedY.toFixed(0)}% — significantly non-center. Element body is displaced from path contact point; consent content position is unpredictable.`
};
}
return null;
}
Attack 4 (SA-CSS-OANCH-004): JS mousedown injects offset-anchor at install time
Like other CSS Motion Path properties, offset-anchor is a live computed style that takes effect immediately when set inline. An MCP server can inject offset-anchor: 0% 0% via JS at the mousedown event on the install button — moving the consent element body off-screen at the exact moment of user commit while it appeared correctly positioned at audit/load time:
/* At load time: offset-anchor: auto (centered, normal behavior) */
.consent-dialog {
offset-path: ray(0deg);
offset-distance: 0%; /* path at origin — element is in viewport */
offset-anchor: auto; /* auto → 50% 50% → centered on path → correct */
}
/* MCP JS: inject offset-anchor at mousedown */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-dialog');
consent.style.offsetDistance = '100%'; /* move path to right edge */
consent.style.offsetAnchor = '0% 0%'; /* top-left corner at right edge → body off-right */
// Both changes happen at mousedown — before click event fires
// The consent dialog was in-viewport at audit time (distance 0%, auto anchor)
// At mousedown: body displaced 300px to the right — consent not visible
}, { capture: true });
/* Detection: simulate mousedown and compare BCR */
function detectMousedownAnchorInjection(rootEl) {
const consentEls = Array.from(rootEl.querySelectorAll(
'[class*="consent"],[class*="terms"],[class*="permission"]'
));
const btns = Array.from(rootEl.querySelectorAll(
'button[class*="install"],button[class*="confirm"],button[type="submit"]'
));
const vw = window.innerWidth, vh = window.innerHeight;
for (const btn of btns) {
const before = consentEls.map(el => {
const b = el.getBoundingClientRect();
return { inViewport: b.left >= -10 && b.right <= vw+10 && b.top >= -10 && b.bottom <= vh+10 };
});
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
const after = consentEls.map(el => {
const b = el.getBoundingClientRect();
return { inViewport: b.left >= -10 && b.right <= vw+10 && b.top >= -10 && b.bottom <= vh+10 };
});
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
btn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
for (let i = 0; i < consentEls.length; i++) {
if (before[i].inViewport && !after[i].inViewport) {
return {
severity: 'Critical',
finding: 'SA-CSS-OANCH-004',
reason: 'Consent element moved off-viewport at mousedown on install button via offset-anchor/offset-distance injection. Consent was in-viewport at audit time and off-screen at commit time.',
};
}
}
}
return null;
}
Combined detection: For all offset-anchor attacks, the reliable signal is getBoundingClientRect() on the consent element combined with checking getComputedStyle(el).offsetPath !== 'none'. If the element has a motion path AND its BCR is off-screen, flag as Critical regardless of what offset-anchor resolves to. The BCR catches the rendered displacement; the offsetPath check explains the mechanism.
Attack summary
| ID | Attack | Mechanism | Detection point | Severity |
|---|---|---|---|---|
| SA-CSS-OANCH-001 | Top-left corner on path endpoint | offset-anchor: 0% 0% — path contact at top-left; element body extends right and down into off-screen territory; BCR.right > viewport_width |
offsetPath !== 'none' + BCR.right > vw |
Critical |
| SA-CSS-OANCH-002 | Bottom-right corner at path origin | offset-anchor: 100% 100% — path contact at bottom-right; element body extends up and left into negative coordinates; entire element off-screen |
BCR.right <= 0 or BCR.bottom <= 0 |
Critical |
| SA-CSS-OANCH-003 | Auto anchor with non-center transform-origin | offset-anchor: auto inherits non-center transform-origin; resolves to corner anchor while appearing to be default; scanner checking for 'auto' misses the effective displacement |
Resolve auto through transform-origin — check effective percent |
High |
| SA-CSS-OANCH-004 | JS mousedown offset-anchor + offset-distance injection | Consent in-viewport at load; JS sets offset-anchor: 0% 0% + offset-distance: 100% at mousedown — element body displaced off-screen at commit time |
Simulate mousedown; compare BCR in-viewport before and after | Critical |
Finding blocks
offset-anchor: 0% 0% with offset-path active — path contact at top-left corner; consent element body displaced right/down; BCR.right exceeds viewport. Standard visibility checks on display/opacity/visibility all pass; transform is reported as 'none'. Check offsetPath !== 'none' AND measure BCR against viewport dimensions.
offset-anchor: 100% 100% — bottom-right corner at path contact point; element extends into negative coordinates above and left of viewport. Entire element is off-screen. BCR.right ≤ 0 or BCR.bottom ≤ 0 is the detection signal.
offset-anchor: auto resolves through transform-origin. If transform-origin is non-center (e.g. 0% 0%), the effective anchor is a corner, not the center — displacing the element body. Naive scanners checking for 'auto' miss this. Resolve the effective anchor percentage and flag if |x − 50| > 15% or |y − 50| > 15%.
offset-anchor + offset-distance at mousedown — displacing the element body off-screen at the exact moment of user commit. Simulate mousedown and compare BCR in-viewport state before vs after.
← Blog | offset-path attacks | offset-rotate attacks | Motion Path synthesis post | Security Checklist