MCP server CSS transform-origin security: extreme pivot lever-arm attack displaces consent off-screen with rotate(1deg), corner-origin rotation clips consent in overflow:hidden, CSS custom property indirection, and JS mousedown pivot swap
Published 2026-08-07 — SkillAudit Research
The CSS transform-origin property sets the point around which a CSS transform is applied. By default it is 50% 50% — the element's center. The security relevance of transform-origin arises from the lever-arm effect: a rotation of angle θ around a pivot that is distance D from the element's content area moves the content area's nearest point by approximately D × sin(θ). At the default center pivot, rotate(1deg) moves the element's edge by at most half the element's diagonal distance — a small, intra-element displacement. But when transform-origin: -2000px center places the pivot 2000px to the left of the element, rotate(1deg) swings the element through an arc of approximately 2000 × sin(1°) ≈ 35px. Chaining this with a larger off-screen starting position or a narrower container means even a 1-degree rotation is sufficient to push the consent element completely off-screen — while a rotation-angle threshold detector (flagging angles > 30°) would not trigger.
This attack is distinct from transform attacks (large rotation/skew angles applied at center pivot) and from perspective-origin attacks (which manipulate the vanishing point on a parent element). The transform-origin property is set on the same element as the transform. The lever-arm technique was historically used for CSS carousel/wheel UI components — its appearance in consent element CSS is a security signal. See also CSS individual transform properties synthesis for the broader transform detection context.
Detection gap: Angle-threshold detectors that flag rotate(>30deg) or rotateX(>60deg) do NOT catch lever-arm attacks using extreme transform-origin with small angles. The correct check combines getComputedStyle(el).transformOrigin (checking for large offset from center) with the actual getBoundingClientRect() geometric result, not just the transform angle alone.
Attack 1: transform-origin:-2000px center + rotate(1deg) — 2000px lever arm swings consent off-screen with 1° (SA-CSS-TORG-001)
The pivot point is set 2000px to the left of the element's left edge. With transform: rotate(1deg), the element rotates clockwise 1° around a point 2000px to its left. The element's center (previously at x=660px in a 1280px viewport) swings to approximately x=660 + 2000×sin(1°) ≈ x=695px in the y-direction and simultaneously drops by 2000×(1-cos(1°)) ≈ 0.3px — negligible vertical drop. However, the element's edge that was at x=560px moves right to ~595px, and its right edge that was at x=760px moves to ~795px. The net effect at 1° with a 2000px arm is an arc displacement of ~35px. If the element is already near the right edge of its container (container width = 700px, element at x=660 with width=100), the right edge moves off-screen. The 1° angle reads as a cosmetic tilt in a visual audit; the geometric displacement is the actual attack.
/* MCP attack: */
.consent-disclosure {
transform-origin: -2000px center;
transform: rotate(1deg);
/* Pivot is 2000px left of element's left edge
rotate(1deg) around this remote pivot:
element's content swings ~35px along the arc
If consent is near container right edge: right portion swings off-screen
Angle 1° appears cosmetic in visual audit
getBoundingClientRect() reveals off-screen displacement */
}
// Detection:
function detectLeverArmAttack(el) {
const cs = window.getComputedStyle(el);
const to = cs.transformOrigin; // e.g., "-2000px 25px"
if (to) {
const parts = to.trim().split(/\s+/);
const xPx = parseFloat(parts[0]);
const yPx = parseFloat(parts[1]);
const elW = el.offsetWidth;
const elH = el.offsetHeight;
// Flag if x origin is > 5× element width outside element bounds
// or y origin is > 5× element height outside element bounds
if (Math.abs(xPx - elW / 2) > elW * 5 || Math.abs(yPx - elH / 2) > elH * 5) {
if (cs.transform && cs.transform !== 'none') {
console.error('SA-CSS-TORG-001: extreme transform-origin lever arm with active transform', {
el, transformOrigin: to, transform: cs.transform
});
}
}
}
// Geometric check regardless of origin
const rect = el.getBoundingClientRect();
if ((rect.right < 0 || rect.left > window.innerWidth ||
rect.bottom < 0 || rect.top > window.innerHeight) &&
el.textContent.trim().length > 0) {
console.error('SA-CSS-TORG-001: consent element off-screen after transform', { el, rect });
}
}
Attack 2: transform-origin:0 0 (top-left corner) + rotate(-90deg) — consent rotates behind overflow:hidden parent (SA-CSS-TORG-002)
transform-origin: 0 0 places the pivot at the element's top-left corner. transform: rotate(-90deg) (90° counter-clockwise) rotates the element so its text runs vertically, with the bottom of the element pivoting to the right and the right side of the element pivoting upward. Because the pivot is at the top-left corner (not the center), the entire element moves up and to the right during the rotation: the center moves from (x + width/2, y + height/2) to approximately (x, y - width/2). With overflow: hidden on a parent set to start at y=0, the element rotates its body upward into the parent's clipped area. The element's bounding rect shifts above the parent's top edge. offsetTop reports the element's original layout position; getBoundingClientRect().top may be negative (above viewport) or above the parent's top edge.
/* MCP attack: */
.mcp-dialog {
overflow: hidden;
position: relative;
}
.consent-disclosure {
transform-origin: 0 0; /* top-left corner pivot */
transform: rotate(-90deg); /* CCW 90° — text runs vertically */
/* Element body swings upward and to the right from pivot
Center displacement: element body moves above parent top edge
Inside overflow:hidden parent: body is clipped above parent boundary
offsetTop: reports original position (unchanged by transform)
getBoundingClientRect().top: may be < parent.getBoundingClientRect().top */
}
/* Variant: bottom-right corner + rotate(90deg) */
.consent-disclosure {
transform-origin: 100% 100%; /* bottom-right corner */
transform: rotate(90deg); /* CW 90° — swings body up and to the left */
}
// Detection:
function detectCornerOriginRotation(el) {
const cs = window.getComputedStyle(el);
const to = cs.transformOrigin;
const t = cs.transform;
if (t && t !== 'none' && to) {
const parts = to.trim().split(/\s+/);
const xPx = parseFloat(parts[0]);
const yPx = parseFloat(parts[1]);
const isCorner = (
(Math.abs(xPx) < 5 && Math.abs(yPx) < 5) || // top-left
(Math.abs(xPx - el.offsetWidth) < 5 && Math.abs(yPx) < 5) // top-right
);
if (isCorner) {
const rect = el.getBoundingClientRect();
const parentRect = el.parentElement?.getBoundingClientRect();
if (parentRect && rect.top < parentRect.top - 5) {
console.error('SA-CSS-TORG-002: corner-origin rotation clips consent above parent boundary', {
el, transformOrigin: to, transform: t
});
}
}
}
}
Attack 3: CSS custom property transform-origin — var(--mcp-pivot) resolves to extreme offset (SA-CSS-TORG-003)
The consent element's transform-origin is set to var(--mcp-pivot-x) var(--mcp-pivot-y). The custom properties on :root define --mcp-pivot-x: -2000px and --mcp-pivot-y: center — they may appear to be animation configuration tokens (e.g., a CSS carousel component's "spin origin" variables). A source scanner reading the consent element's rule sees only transform-origin: var(--mcp-pivot-x) var(--mcp-pivot-y) without an obviously suspicious pixel value. getComputedStyle(el).transformOrigin resolves the entire custom property chain and returns the actual computed pixel values: '-2000px 25px'. This makes var() indirection transparent to runtime computed-value inspection, which is why SkillAudit always checks computed properties, not source CSS rules.
/* MCP attack: */
:root {
--mcp-pivot-x: -2000px; /* "carousel component spin origin" — sounds plausible */
--mcp-pivot-y: center;
}
.consent-disclosure {
transform-origin: var(--mcp-pivot-x) var(--mcp-pivot-y);
transform: rotate(2deg);
/* Source scanner sees: var() references — no suspicious large value on this rule
getComputedStyle().transformOrigin: '-2000px 25px' — exposes the lever arm */
}
/* Double-indirection variant: */
:root {
--base-pivot: -1500px;
--mcp-pivot-x: var(--base-pivot); /* chain depth 2 */
}
// Detection: computed value resolves the full var() chain
function detectVarTransformOrigin(el) {
const cs = window.getComputedStyle(el);
const to = cs.transformOrigin; // fully resolved
const t = cs.transform;
if (t && t !== 'none' && to) {
const parts = to.trim().split(/\s+/);
const xPx = parseFloat(parts[0]);
const elW = el.offsetWidth;
if (Math.abs(xPx - elW / 2) > elW * 5) {
console.error('SA-CSS-TORG-003: computed transform-origin reveals extreme lever-arm pivot', {
el, transformOrigin: to, transform: t
});
}
}
}
Attack 4: JS mousedown sets extreme transform-origin + rotation — lever-arm displacement at install click (SA-CSS-TORG-004)
At page load, the consent element has the default transform-origin: 50% 50% and either no transform or a cosmetic rotate(0deg). The consent renders normally; audit passes. At mousedown on the install button, JS sets both el.style.transformOrigin = '-2000px center' and el.style.transform = 'rotate(3deg)' simultaneously. Because both properties are applied before the browser repaints, the consent element immediately displaces via the lever-arm effect and is off-screen by the time the click event fires. If a CSS transition is pre-defined on transform, the displacement animates smoothly — appearing like a "slide away" UI animation. MutationObserver on the consent element's style attribute detects both property changes.
/* Baseline CSS — loads normally: */
.consent-disclosure {
/* No transform-origin or transform — default center pivot */
transition: transform 0.2s ease-out; /* pre-defined for smooth animation */
}
// MCP JS — triggers at install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const el = document.querySelector('.consent-disclosure');
if (el) {
el.style.transformOrigin = '-2000px center';
el.style.transform = 'rotate(3deg)';
/* 2000px lever arm × sin(3°) ≈ 105px arc displacement
With transition: smooth swing animation — looks like "fly out"
Consent moves off-screen right during 200ms transition
At audit time: no transform, no unusual origin — completely clean */
}
}, { capture: true });
// Detection:
function detectDynamicTransformOrigin() {
document.querySelectorAll('.consent-disclosure, [data-consent]').forEach(el => {
const observer = new MutationObserver(() => {
const cs = window.getComputedStyle(el);
const to = cs.transformOrigin;
const t = cs.transform;
if (t && t !== 'none' && to) {
const parts = to.trim().split(/\s+/);
const xPx = parseFloat(parts[0]);
if (Math.abs(xPx - el.offsetWidth / 2) > el.offsetWidth * 5) {
console.error('SA-CSS-TORG-004: extreme transform-origin set at interaction time', {
el, transformOrigin: to, transform: t
});
}
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
if (rect.right < 0 || rect.left > window.innerWidth) {
console.error('SA-CSS-TORG-004: consent off-screen after lever-arm rotation', { el, rect });
}
});
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
}
Root detection method for all transform-origin attacks: Check getComputedStyle(el).transformOrigin alongside getComputedStyle(el).transform. Parse the origin x and y values; flag if either axis deviates from the element's dimensions by more than 5× the element size (indicating a lever-arm configuration). Then verify with getBoundingClientRect(): if the post-transform geometric position is off-screen while offsetLeft/offsetTop report an in-bounds layout position, flag the discrepancy. Never rely solely on rotation angle thresholds — they are blind to lever-arm attacks. SkillAudit checks both transformOrigin and BCR on every consent element with an active transform.
Attack summary
| ID | CSS / JS technique | transform (angle) | transformOrigin | getBCR().left | Severity |
|---|---|---|---|---|---|
| SA-CSS-TORG-001 | transform-origin:-2000px center + rotate(1deg) | 1° (tiny) | '-2000px 25px' | off-screen | High |
| SA-CSS-TORG-002 | transform-origin:0 0 + rotate(-90deg) + parent overflow:hidden | -90° | '0px 0px' | above parent | High |
| SA-CSS-TORG-003 | transform-origin:var(--mcp-pivot-x) resolves to -2000px | 2° (tiny) | '-2000px 25px' (computed) | off-screen | High |
| SA-CSS-TORG-004 | JS sets transformOrigin='-2000px center' + transform='rotate(3deg)' at mousedown | 3° (tiny) | '-2000px center' (after) | off-screen (after) | High |
Consolidated finding blocks
transformOrigin extreme-pivot check and getBoundingClientRect() geometric fallback reveal the attack.
offsetTop reports the layout position (unchanged); only getBoundingClientRect() reveals the out-of-bounds post-transform position.
getComputedStyle(el).transformOrigin resolves the full chain, revealing the extreme pivot regardless of indirection depth.
CSS transform security | CSS perspective-origin security | CSS translate property security | Security Checklist