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

IDCSS / JS techniquetransform (angle)transformOrigingetBCR().leftSeverity
SA-CSS-TORG-001transform-origin:-2000px center + rotate(1deg) (tiny)'-2000px 25px'off-screenHigh
SA-CSS-TORG-002transform-origin:0 0 + rotate(-90deg) + parent overflow:hidden-90°'0px 0px'above parentHigh
SA-CSS-TORG-003transform-origin:var(--mcp-pivot-x) resolves to -2000px (tiny)'-2000px 25px' (computed)off-screenHigh
SA-CSS-TORG-004JS sets transformOrigin='-2000px center' + transform='rotate(3deg)' at mousedown (tiny)'-2000px center' (after)off-screen (after)High

Consolidated finding blocks

High CSS transform-origin:-2000px center lever arm displaces consent off-screen with only 1° rotation: 2000px arm × sin(1°) ≈ 35px arc displacement. Consent pushed off-screen right with rotation angle that appears cosmetic. Angle-threshold detectors (flagging >30°) do not trigger. Only transformOrigin extreme-pivot check and getBoundingClientRect() geometric fallback reveal the attack.
High CSS transform-origin:0 0 corner pivot + rotate(-90deg) clips consent above overflow:hidden parent boundary: Top-left corner pivot causes the element body to swing upward and right during counter-clockwise rotation. Inside an overflow:hidden container, the consent body clips above the parent's top edge. offsetTop reports the layout position (unchanged); only getBoundingClientRect() reveals the out-of-bounds post-transform position.
High CSS custom property var(--mcp-pivot-x) resolves to -2000px — source scanner sees only var() reference: The transform-origin rule contains only a var() token pointing to a root custom property defined to -2000px. getComputedStyle(el).transformOrigin resolves the full chain, revealing the extreme pivot regardless of indirection depth.
High JS sets extreme transform-origin + small rotation at mousedown — lever-arm displacement during install click with optional CSS transition animation: Load-time audit sees default center pivot and no active transform — completely clean. At mousedown, both properties applied simultaneously before repaint. With a pre-defined CSS transition, the displacement appears as a smooth "fly out" animation. MutationObserver on style attribute detects the dynamic extreme-pivot configuration.

CSS transform security  |  CSS perspective-origin security  |  CSS translate property security  |  Security Checklist