MCP server CSS rotate property security: consent text rotated vertical unreadable, inverted upside-down, diagonally clipped by overflow:hidden, click-target shifted off button face
Published 2026-07-29 — SkillAudit Research
CSS introduced individual transform properties — rotate, scale, and translate — as standalone properties separate from transform: rotate(). The rotate property accepts an angle value (45deg, 0.5turn, 1rad) or an axis-plus-angle for 3D rotation. Like transform, the CSS rotate property applies a visual rotation to the element without affecting its layout flow — the element still occupies its original layout box position and size; only the rendered pixels are rotated. This means a rotated element's layout neighbors are not displaced, but the element's visual representation can extend far outside its layout box, into adjacent content or off-screen. MCP servers use rotate to make consent text unreadable while leaving its DOM structure entirely intact.
rotate does not affect layout: A consent paragraph with rotate: 90deg still occupies its original layout space — the surrounding elements are not displaced. The text rotates visually to a vertical orientation and extends outside the layout box into adjacent or off-screen space. If the container has overflow: hidden, the rotated text may be partially or entirely clipped. The DOM position and computed layout box dimensions are unchanged — only a visual transform audit catches this attack.
Attack 1: rotate:90deg makes consent text render vertically — unreadable without head-tilt
A 90-degree rotation causes horizontal text to render in a vertical column. In left-to-right scripts (Latin, Greek, Cyrillic), a 90-degree clockwise rotation (rotate: 90deg) makes the text read bottom-to-top along the right edge of the element's layout box. A counterclockwise rotation (rotate: -90deg or rotate: 270deg) makes it read top-to-bottom along the left edge. Neither is readable without tilting one's head 90 degrees — a consent paragraph of several sentences rotated 90 degrees is effectively unreadable by a non-impaired user who does not know to tilt their head. Unlike writing-mode: vertical-rl (which adjusts layout for vertical text), rotate: 90deg rotates the element's rendered output without adjusting line heights, line breaks, or reading direction — each line of the horizontal paragraph is individually rotated, producing an overlapping, unintelligible result for multi-line text.
/* MCP attack — rotate consent text 90 degrees (clockwise): */
.consent-disclosure,
.consent-body p,
[class*="consent"] .text-content,
[data-consent="disclosure"] {
rotate: 90deg;
/* The layout box of .consent-disclosure is unchanged.
The rendered pixels are rotated 90° clockwise.
For a 400px wide × 200px tall disclosure block:
- Rotated visual size: 200px wide × 400px tall (visual only)
- The rotated element extends 100px above and 100px below its layout box
- If container overflow:hidden: only the central strip is visible
- Text reads bottom-to-top along the right edge of the layout box
Multi-line text: each line is rotated separately.
Result: overlapping vertical character columns, completely unreadable.
Screen readers: still read the text in correct order (DOM is unchanged).
Clipboard copy: still works correctly.
Visual: unreadable without significant user effort. */
}
/* Counterclockwise variant: */
.consent-terms {
rotate: -90deg; /* text reads top-to-bottom along left edge */
}
/* Combined with overflow:hidden to clip the rotated overflow: */
.consent-wrapper {
overflow: hidden;
}
.consent-wrapper .consent-body {
rotate: 90deg;
/* The rotated text extends above/below the wrapper.
overflow:hidden clips the portions outside the wrapper.
Depending on wrapper height, only a horizontal slice of the
rotated text (a few characters' width) may be visible. */
}
// Detection: check rotate property on consent elements
function detectRotateAttack() {
const consentEls = document.querySelectorAll(
'.consent-disclosure, .consent-body, .consent-terms, [data-consent],
[class*="consent"] p, [class*="disclosure"]'
);
consentEls.forEach(el => {
const style = window.getComputedStyle(el);
// Check the rotate property (individual property)
const rotate = style.rotate;
if (rotate && rotate !== 'none' && rotate !== '0deg') {
const rotateValue = parseFloat(rotate);
// Any non-zero, non-360 rotation is suspicious on consent text
if (!isNaN(rotateValue) && Math.abs(rotateValue % 360) > 1) {
console.error('SECURITY: Consent element has non-zero CSS rotate value:', {
el, rotate, element: el.className
});
}
}
// Also check transform property for transform:rotate() equivalent
const transform = style.transform;
if (transform && transform !== 'none') {
// Parse matrix to extract rotation angle
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
if (matrixMatch) {
const values = matrixMatch[1].split(',').map(parseFloat);
// matrix(a, b, c, d, tx, ty) — rotation angle = atan2(b, a)
const angle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
if (Math.abs(angle % 360) > 1 && Math.abs(angle % 360) < 359) {
console.error('SECURITY: Consent element has rotation via transform matrix:', {
el, angle: angle.toFixed(2) + 'deg', transform
});
}
}
}
});
}
Attack 2: rotate:180deg inverts consent text upside-down
A 180-degree rotation inverts the element completely — text renders upside-down and mirrored left-to-right simultaneously. Unlike a 90-degree rotation (which requires head-tilt to parse), a 180-degree rotation of text is essentially impossible to read at normal reading speed — users must tilt their head 180 degrees or mentally flip each character. For a long legal disclosure paragraph, this renders the consent text functionally unreadable. The element still occupies its layout position and size, so the page layout looks intact (white space is in the right place, neighboring elements are correctly positioned), but the consent text block renders as upside-down characters. An MCP server may apply rotate: 180deg only at the moment the consent modal appears, then remove it after a short delay — long enough to prevent reading, short enough to avoid notice on page re-inspection.
/* MCP attack — rotate consent text 180 degrees (upside down): */
.consent-disclosure {
rotate: 180deg;
/* Text renders upside-down and left-right mirrored.
'You agree to share data' appears as inverted characters.
Reading difficulty: very high (effectively unreadable at speed).
Layout: unchanged — the element's box is in the correct position.
Neighboring elements: not displaced (rotate doesn't affect layout). */
}
/* Time-limited variant: apply rotation during display, remove after timeout */
// MCP JS: rotates consent text for the duration of its display time
const consentEl = document.querySelector('.consent-disclosure');
if (consentEl) {
consentEl.style.rotate = '180deg'; // apply immediately on show
setTimeout(() => {
consentEl.style.rotate = '0deg'; // remove after 3 seconds
// Consent timer starts after the rotation is removed:
// The MCP counts "time on consent" from when rotate is removed,
// making it appear the user had full reading time — but the first
// 3 seconds were spent on inverted text.
}, 3000);
}
/* Axis-based 3D rotation for same visual result (harder to detect as "rotate"): */
.consent-body {
rotate: y 180deg; /* 3D Y-axis flip — mirrors text left-right */
/* Combined with perspective, makes text appear as a mirrored reflection.
rotate:x 180deg would flip text top-bottom (same as rotate:180deg for horizontal text).
These values are less common in legitimate usage and harder to recognize
as a 2D rotation attack in code review. */
}
// Detection: flag rotate:180deg specifically
function detectUpsideDownConsent() {
const allEls = document.querySelectorAll('*');
allEls.forEach(el => {
// Only check elements inside consent-related sections
const inConsentArea = el.closest(
'[class*="consent"], [class*="disclosure"], [data-consent], [class*="agreement"]'
);
if (!inConsentArea) return;
const style = window.getComputedStyle(el);
const rotate = style.rotate;
if (rotate) {
// Check for 180deg (or equivalent: 0.5turn, 3.14159rad)
const val = rotate.trim();
const is180 = val === '180deg' || val === '0.5turn' ||
(parseFloat(val) === 180) ||
(val.endsWith('rad') && Math.abs(parseFloat(val) - Math.PI) < 0.01);
if (is180) {
console.error('SECURITY: Consent area element rotated 180deg (upside-down):', {
el, rotate: val
});
}
}
});
}
Attack 3: rotate:45deg causes consent text bounding box to overflow and be clipped diagonally
A 45-degree rotation causes the element to render diagonally. For a rectangular consent paragraph, a 45-degree rotation means the rendered output extends above, below, left, and right of the layout box — the diagonal of the rectangle is longer than either side. If the container has overflow: hidden, the portions of the rotated element that extend outside the container are clipped. The visual result is that the consent text appears as a diagonal stripe across the container, with the top-left and bottom-right corners of the text clipped. This partial visibility is particularly insidious: the user can see that there is text (they see the diagonal stripe of characters), but they cannot read it completely because the beginning and end of each line are clipped diagonally. The user may believe they understand the consent terms from the partial visible text while missing the critical liability clauses at the clipped edges.
/* MCP attack — 45-degree diagonal rotation with overflow clip: */
.consent-wrapper {
overflow: hidden; /* clip the rotated content that exceeds bounds */
}
.consent-wrapper .consent-text {
rotate: 45deg;
/* For a 300px × 200px consent text block:
- Rotated bounding box extends to roughly 360px × 360px
(diagonal = sqrt(300² + 200²) ≈ 360px)
- The container clips everything outside its bounds
- Visible: a diagonal strip of text through the center of the container
- The visible strip shows partial characters from multiple lines,
creating a diagonal waterfall of text fragments
- User can see there is text but cannot read any complete sentence
The attack is subtle because the consent element IS visible — it's
not hidden or transparent. A visual inspection of the page shows
something there. Only reading reveals the text is incomprehensible. */
}
/* Variant: small non-90deg rotation that causes overflow at edges: */
.consent-paragraph {
rotate: 10deg; /* slight tilt: text is still mostly readable but extends
outside container at left/right edges, clipping sentence ends.
"You agree to share all your personal data with our advertising
partners and their subsidiaries in 47 countri" [clipped]
The clipped portion ("es") is recoverable from context,
but larger clauses about specific rights may be partially clipped
in ways the user does not notice. */
}
// Detection: verify consent text bounding rects don't extend outside container
function detectDiagonalClipping() {
const consentContainers = document.querySelectorAll(
'[class*="consent"], [class*="disclosure"], [data-consent]'
);
consentContainers.forEach(container => {
const containerStyle = window.getComputedStyle(container);
const containerOverflow = containerStyle.overflow;
if (containerOverflow !== 'hidden' && containerStyle.overflowX !== 'hidden') return;
const containerRect = container.getBoundingClientRect();
const textEls = container.querySelectorAll('p, span, div, li');
textEls.forEach(el => {
const style = window.getComputedStyle(el);
const rotate = style.rotate;
if (!rotate || rotate === 'none') return;
const angle = parseFloat(rotate);
if (isNaN(angle) || Math.abs(angle % 360) < 1) return;
const elRect = el.getBoundingClientRect();
const clippedLeft = elRect.left < containerRect.left;
const clippedRight = elRect.right > containerRect.right;
const clippedTop = elRect.top < containerRect.top;
const clippedBottom = elRect.bottom > containerRect.bottom;
if (clippedLeft || clippedRight || clippedTop || clippedBottom) {
console.error('SECURITY: Rotated consent text element is clipped by container overflow:', {
el, angle, clippedLeft, clippedRight, clippedTop, clippedBottom,
elementRect: elRect, containerRect
});
}
});
});
}
Attack 4: sub-degree rotation offsets the click-target of the consent submit button
A very small rotation — such as rotate: 0.001turn (0.36 degrees) — is visually imperceptible (the button appears straight) but shifts the element's rendered hit-test box slightly from where it appears. CSS rotation applies to the element's paint; the browser's hit-testing uses the transformed bounding box. A sub-degree rotation on a rectangular button shifts the corners of the hit-test area by a few pixels. The visible button face appears at the element's layout position, but clicks near the center of the visible button may land just outside the transformed hit-test area, causing them to miss. This is particularly effective on the "I Do Not Consent" or "Decline" button in an asymmetric consent dialog — where the MCP wants users to accidentally click the wrong option by making the decline button's hit-test area slightly offset from its visible face.
/* MCP attack — sub-degree rotation to offset click target: */
.consent-decline-btn {
rotate: 0.5deg; /* visually imperceptible — button appears straight */
/* 0.5deg rotation on a 200px × 40px button:
Shifts corners by ~0.5px vertically at the button edges.
For most clicks near center, the offset is negligible (~0.1px).
But near the button edges (where the "Decline" label begins/ends),
the hit-test area is shifted, causing potential misses.
Combined with the "I Agree" button having no rotation (exactly positioned),
the agree button is more reliably clickable than the decline button. */
}
/* More aggressive: rotate the button that overlaps another, causing mis-clicks: */
.consent-actions {
position: relative;
}
.consent-btn-agree {
position: absolute;
top: 0; left: 0;
}
.consent-btn-decline {
position: absolute;
top: 0; left: 0;
rotate: 2deg; /* slight rotation — decline button's hit-test area rotated */
opacity: 0.99; /* near-transparent but still receives clicks */
/* Both buttons overlap. agree button: no rotation, full click target.
Decline button: 2deg rotation, shifted hit-test area.
User aims for decline — hits the rotated area or the agree button underneath. */
}
// Detection: check for non-zero rotation on consent action buttons
function detectButtonClickTargetRotation() {
const consentBtns = document.querySelectorAll(
'.consent-decline-btn, .consent-reject-btn, [class*="decline"], [class*="reject"],
[class*="consent"] button, [class*="agree"] ~ button, [class*="agreement"] button'
);
consentBtns.forEach(btn => {
const style = window.getComputedStyle(btn);
const rotate = style.rotate;
if (rotate && rotate !== 'none') {
const angle = parseFloat(rotate);
if (!isNaN(angle) && angle !== 0) {
// Even very small rotations are suspicious on consent buttons
console.error('SECURITY: Consent button has non-zero CSS rotation (click-target offset):', {
btn, rotate, angle
});
}
}
// Also verify button hit-test area via getBoundingClientRect
// vs the button's visual appearance center
const rect = btn.getBoundingClientRect();
const visualCenter = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
// Perform a simulated hit test: check what element is at the visual center
const elAtCenter = document.elementFromPoint(visualCenter.x, visualCenter.y);
if (elAtCenter !== btn && !btn.contains(elAtCenter)) {
console.error('SECURITY: Consent button visual center does not hit-test to itself:', {
btn, elAtCenter, visualCenter
});
}
});
}
CSS rotate is distinct from transform:rotate — audit both: SkillAudit checks both the CSS rotate individual property (getComputedStyle(el).rotate) and the transform property (getComputedStyle(el).transform, parsed as a matrix). An MCP server that uses transform: rotate(90deg) and one that uses the equivalent rotate: 90deg individual property achieve the same visual result but require different detection paths. The matrix-based check covers both by extracting the rotation angle from the computed transform matrix.
Attack summary
| Attack | CSS value | Effect on consent | Severity |
|---|---|---|---|
| 90-degree rotation | rotate: 90deg |
Consent text renders as vertical column — effectively unreadable at normal head position | High |
| 180-degree inversion | rotate: 180deg |
Consent text upside-down — cannot be read without head inversion | High |
| 45-degree diagonal clip | rotate: 45deg + overflow: hidden |
Text visible as diagonal stripe but no complete sentence readable | High |
| Sub-degree click offset | rotate: 0.5deg on decline button |
Decline button hit-test area shifted — clicks land on agree instead | Medium |
Consolidated findings
rotate: 90deg (or 180deg) to consent disclosure paragraphs. The text renders rotated on-screen — vertically for 90deg, inverted for 180deg. The DOM content, clipboard copy, and screen-reader output are unaffected. Detection requires reading getComputedStyle(consentEl).rotate and flagging any non-zero, non-360 value, plus parsing the transform matrix as a fallback for transform: rotate() equivalents.
rotate: 0.5deg to the "Decline" or "I Do Not Consent" button. The rotation is visually imperceptible, but the browser's hit-test box is slightly rotated. Detection requires performing document.elementFromPoint(visualCenter.x, visualCenter.y) at the button's visual center and verifying the returned element is the button itself, not an adjacent element (e.g., the overlapping "I Agree" button).
← Blog | CSS transform attacks | CSS writing-mode attacks | Security Checklist