MCP server CSS rotate3d() security: Y-axis 90deg projected width collapse, diagonal axis 2D projection flattening, near-90deg evasion, and JS mousedown rotate3d injection

Published 2026-08-07 — SkillAudit Research

The CSS transform: rotate3d(x, y, z, angle) function performs a 3D rotation about an arbitrary axis vector (x, y, z) by the specified angle. Unlike rotateX(), rotateY(), and rotateZ() (which rotate about the canonical axes), and unlike the CSS rotate individual property (which is always a Z-axis rotation), rotate3d() allows specifying any axis in 3D space. The security relevance: when the axis is aligned with one of the 3D projection axes, a 90-degree rotation collapses the element's projected dimension to zero pixels in the viewer plane — the element becomes an invisible hairline while offsetWidth and offsetHeight remain unchanged at their CSS layout values.

The attack is distinct from CSS rotateX/Y/Z attacks, from CSS scale individual property attacks, and from transform-origin lever-arm attacks. The rotate3d() function appears in a transform chain and requires a renderer that evaluates 3D matrix math to detect via geometry. The reliable detection: getBoundingClientRect() reports near-zero width or height while offsetWidth/offsetHeight are non-zero.

Detection gap: offsetWidth and offsetHeight report CSS layout dimensions, not projected rendered dimensions. A consent element with transform: rotate3d(0,1,0,90deg) has offsetWidth === 300 (its CSS width) but getBoundingClientRect().width === 0 (its projected rendered width). Scanners that check only offsetWidth miss this class of attack entirely. Use getBoundingClientRect() as the geometric ground truth.

Attack 1: rotate3d(0,1,0,90deg) — Y-axis rotation collapses projected width to 0px (SA-CSS-RT3D-001)

A Y-axis rotation (x=0, y=1, z=0) rotates the element like a door swinging around a vertical hinge. At exactly 90 degrees, the element's face is perpendicular to the viewer — the full width of the element is edge-on, projecting to zero pixels wide in the viewer plane. The element's offsetWidth is 300px (its CSS layout width); getBoundingClientRect().width is approximately 0px (the projected width at 90 degrees). The element exists in the DOM, has a non-zero textContent, and its layout dimensions are normal — only the geometric check reveals the collapse.

/* MCP attack: */
.consent-disclosure {
  width: 300px;
  height: 60px;
  transform: rotate3d(0, 1, 0, 90deg);  /* Y-axis rotation — face perpendicular to viewer */
  /* offsetWidth:  300px (CSS layout — unchanged) ← passes
     offsetHeight: 60px (CSS layout — unchanged)  ← passes
     getBoundingClientRect().width:  ~0px  ← FAILS
     getBoundingClientRect().height: ~60px (height preserved)
     textContent: full consent string ← passes
     getComputedStyle().transform: 'matrix3d(...)' — parse reveals near-zero projection */
}

// Detection:
function detectRotate3dCollapse(el) {
  const bcr = el.getBoundingClientRect();
  const offsetW = el.offsetWidth;
  const offsetH = el.offsetHeight;

  // Projected width/height near zero while layout dimension non-zero
  if (offsetW > 20 && bcr.width < 2) {
    console.error('SA-CSS-RT3D-001: rotate3d collapses projected width to near-zero', {
      el, offsetWidth: offsetW, bcrWidth: bcr.width,
      transform: window.getComputedStyle(el).transform
    });
  }
  if (offsetH > 20 && bcr.height < 2) {
    console.error('SA-CSS-RT3D-001: rotate3d collapses projected height to near-zero', {
      el, offsetHeight: offsetH, bcrHeight: bcr.height
    });
  }
}

Attack 2: rotate3d(1,1,0,60deg) — diagonal axis flattens to 2D projection (SA-CSS-RT3D-002)

A diagonal axis (x=1, y=1, z=0) produces a rotation that tilts the element simultaneously about both X and Y axes. At 60 degrees, the element is visually compressed into a 2D projection of approximately width × sin(30°) = width × 0.5 visible area. A 300px wide consent element projects to approximately 150px — but more importantly, the diagonal rotation makes the text appear as a parallelogram-shaped strip that is extremely difficult to read, even though getBoundingClientRect() reports a non-negligible width. The text is distorted into a 3D-perspective-compressed form that does not render as readable prose.

/* MCP attack: */
.consent-disclosure {
  transform: rotate3d(1, 1, 0, 60deg);  /* diagonal axis — X+Y combined tilt */
  /* Element projects as a compressed parallelogram
     getBoundingClientRect().width ≈ 150px (reduced but non-zero)
     Text rendered as perspective-distorted strip — not readable as prose
     Looks like a "card flip" transition paused mid-rotation */
}

/* More aggressive variant: */
.consent-disclosure {
  transform: rotate3d(1, 1, 0, 75deg);  /* close to edge-on — width ≈ 77px × sin(15°) ≈ 20px */
}

// Detection — check for severe aspect ratio distortion:
function detectDiagonalRotate3d(el) {
  const bcr = el.getBoundingClientRect();
  const cs = window.getComputedStyle(el);
  const transformStr = cs.transform;

  if (transformStr && transformStr !== 'none') {
    const originalRatio = el.offsetWidth / el.offsetHeight;
    const projectedRatio = bcr.width / bcr.height;
    if (originalRatio > 2 && projectedRatio < 1) {
      // Wide element that has become taller-than-wide in projection
      console.error('SA-CSS-RT3D-002: rotate3d severely distorts consent aspect ratio', {
        el, originalRatio, projectedRatio, transform: transformStr
      });
    }
  }
}

Attack 3: rotate3d(0,1,0,89deg) — near-90deg evasion of integer-degree threshold detectors (SA-CSS-RT3D-003)

Some angle-based detectors specifically check for 90-degree or 180-degree transforms as high-risk values. An MCP server uses rotate3d(0,1,0,89deg) — one degree short of the full Y-axis collapse. At 89 degrees, the projected width is cos(89°) × 300px ≈ 5.2px — still a hairline, but not exactly zero. The element passes the "not exactly 90deg" check while remaining visually unreadable. The reliable geometric detection via getBoundingClientRect() catches this: the element has a projected width of 5.2px against an offset width of 300px, which is the same signal regardless of whether the angle is 89 or 90 degrees.

/* MCP attack — near-90deg evasion: */
.consent-disclosure {
  transform: rotate3d(0, 1, 0, 89deg);  /* 1° short of full collapse */
  /* getBoundingClientRect().width: 300 × cos(89°) ≈ 5.2px — hairline */
  /* Passes "rotation not exactly 90deg" check
     Does not pass getBoundingClientRect() geometric check */
}

// Detection — correct: use geometry, not degree parsing:
function detectNearCollapseRotate3d(el) {
  const bcr = el.getBoundingClientRect();
  const w = el.offsetWidth;
  const h = el.offsetHeight;
  const COLLAPSE_RATIO = 0.05;  // projected dimension < 5% of layout dimension

  if ((w > 20 && bcr.width < w * COLLAPSE_RATIO) ||
      (h > 20 && bcr.height < h * COLLAPSE_RATIO)) {
    console.error('SA-CSS-RT3D-003: rotate3d near-collapse — projected dimension < 5% of layout', {
      el,
      layoutW: w, layoutH: h,
      projectedW: bcr.width, projectedH: bcr.height,
      transform: window.getComputedStyle(el).transform
    });
  }
}

Attack 4: JS mousedown sets rotate3d(0,1,0,90deg) at install click (SA-CSS-RT3D-004)

At page load, the consent element renders normally with no transform. Load-time audit passes. At mousedown on the install button, JS sets el.style.transform = 'rotate3d(0, 1, 0, 90deg)' on the consent element. The projected width collapses to 0px at the moment of the install click — the consent disappears as the user's mouse button is pressed. An optional CSS transition (transition: transform 0.3s ease-in) makes this look like a "card flip" animation, natural in modern UI. MutationObserver on the consent element's style attribute detects the injected transform; the rAF geometric check confirms the collapse.

/* Baseline CSS: */
.consent-disclosure {
  /* No transform — normal rendering at load time */
  transition: transform 0.3s ease-in;  /* "card flip" animation baseline */
}

// MCP JS — triggers at install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  const consent = document.querySelector('.consent-disclosure');
  if (consent) {
    consent.style.transform = 'rotate3d(0, 1, 0, 90deg)';
    /* Consent card "flips" away — looks like a UI transition
       Projected width collapses from 300px to 0px over 0.3s
       User sees a flip animation, not a consent disappearing */
  }
}, { capture: true });

// Detection:
function detectDynamicRotate3d(consentEl) {
  new MutationObserver(() => {
    requestAnimationFrame(() => {
      const bcr = consentEl.getBoundingClientRect();
      const w = consentEl.offsetWidth;
      if (w > 20 && bcr.width < w * 0.05) {
        console.error('SA-CSS-RT3D-004: JS rotate3d collapse at install click', {
          consentEl, bcrWidth: bcr.width, offsetWidth: w
        });
      }
    });
  }).observe(consentEl, { attributes: true, attributeFilter: ['style'] });

  document.querySelector('#install-btn, [data-action="install"]')
    ?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
}

Root detection method: getBoundingClientRect() is the canonical detection for all rotate3d() attacks. Check: if getBoundingClientRect().width < offsetWidth × 0.05 or getBoundingClientRect().height < offsetHeight × 0.05, and the element has non-empty textContent, flag the transform. This catches Y-axis 90deg collapse, diagonal axis projection, near-90deg evasion, and JS dynamic injection. SkillAudit applies this geometric check to every consent element detected in the rendered DOM.

Attack summary

IDTechniqueoffsetWidthBCR.widthSeverity
SA-CSS-RT3D-001rotate3d(0,1,0,90deg) — Y-axis full collapse300px~0pxHigh
SA-CSS-RT3D-002rotate3d(1,1,0,60deg) — diagonal axis projection distortion300px~150px (distorted)High
SA-CSS-RT3D-003rotate3d(0,1,0,89deg) — near-90deg evasion of degree-threshold detectors300px~5.2pxHigh
SA-CSS-RT3D-004JS mousedown sets rotate3d(0,1,0,90deg) + CSS transition "card flip"300px~0px (after)High

Consolidated findings

High SA-CSS-RT3D-001 — rotate3d(0,1,0,90deg) Y-axis rotation: projected width collapses to ~0px; offsetWidth=300px unchanged; getBoundingClientRect().width is detection signal
High SA-CSS-RT3D-002 — rotate3d(1,1,0,60deg) diagonal axis: text rendered as perspective-distorted parallelogram; projected area reduced; not readable as prose
High SA-CSS-RT3D-003 — rotate3d(0,1,0,89deg) near-90deg: projected width 5.2px hairline; evades degree-threshold checks; geometric check (BCR/offset ratio) catches it
High SA-CSS-RT3D-004 — JS mousedown sets rotate3d at install click with optional CSS transition; looks like "card flip" animation; MutationObserver + rAF BCR check detects

See also: CSS rotateX/Y/Z attacks | CSS scale individual property | CSS transform-origin lever-arm attacks | CSS individual transform properties | SkillAudit — free MCP server audit