Security Guide
MCP server SVG rotate attribute consent security — upside-down text, per-glyph angle list, partial list targeting sensitive words, and mousedown injection
The SVG rotate attribute on <text> and <tspan> elements controls the rotation of individual glyphs independently of CSS transform:rotate and font metrics. A single scalar value rotates all glyphs uniformly; a whitespace-separated list rotates each successive glyph by the corresponding angle, with the last value reused for all remaining glyphs. Four attack patterns exploit this glyph-level control: consent text rendered entirely upside-down with rotate="180"; per-glyph scrambling with an angle list; selective rotation of permission-scope words via partial list last-value-repeat; and transient mousedown injection that is invisible to static analysis.
Attack 1: rotate="180" on <text> element renders entire consent string upside-down (SA-CSS-ROT-001)
The SVG rotate attribute on a <text> element accepts a single numeric angle value in degrees. When set to 180, every glyph in the text element is rotated 180 degrees around its individual glyph origin point. The textContent of the element remains unchanged — a DOM-based scan that extracts textContent and checks for consent keywords will find the correct permission terms and pass. But the rendered text is entirely upside-down. A typical user encountering an upside-down consent dialog will attempt to mentally parse it — and because the text is coherent when mentally flipped, they may attempt to read it — but the cognitive load of upside-down text dramatically reduces the chance that sensitive scope words (like “shell execution” or “credential access”) are fully processed before the user clicks. The bounding client rect of the text element is unchanged: the element occupies the same layout space whether its glyphs are upside-down or not, so BCR-based visible-text checks pass. Detection requires reading the rotate attribute value from the SVG element in addition to extracting textContent.
<!-- SA-CSS-ROT-001: rotate="180" makes consent text render upside-down -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- textContent is correct -- DOM scan finds consent terms -- passes keyword check -->
<!-- rotate="180" rotates each glyph 180deg around its origin -- text is upside-down -->
<text x="200" y="60" text-anchor="middle" font-size="14"
rotate="180">Allow: shell execution, credential access</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: read SVG rotate attribute on text elements; flag any rotate value
function checkTextRotate(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const rotateAttr = textEl.getAttribute('rotate');
if (!rotateAttr) continue;
// Parse rotate as space/comma-separated angle list
const angles = rotateAttr.trim().split(/[\s,]+/).map(Number);
// Single value 180 (or near-180) = entire text upside-down
if (angles.length === 1) {
const deg = Math.abs(angles[0] % 360);
// Angles near 180 render text upside-down; angles near 90/270 render sideways
if (deg > 60 && deg < 300) {
findings.push({
severity: deg > 120 && deg < 240 ? 'CRITICAL' : 'HIGH',
code: 'SA-CSS-ROT-001',
note: `<${textEl.tagName}> has rotate="${rotateAttr}" — ` +
`single-value rotate applies same angle to all glyphs; ` +
`rotate=180 renders entire text element upside-down; ` +
`textContent correct — DOM keyword check passes; ` +
`BCR unchanged — presence check passes; ` +
`glyph rendering is upside-down in browser viewport`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-ROT-001: A rotate="180" attribute on the consent <text> element renders the entire consent string upside-down in the browser viewport. textContent is unchanged so DOM-based keyword checks pass; BCR is unchanged so presence checks pass. Detection requires explicitly reading the rotate attribute value from SVG text elements and flagging non-zero rotation angles.
Attack 2: Per-glyph angle list rotate="0 45 90 135 180" scrambles character orientations (SA-CSS-ROT-002)
When the SVG rotate attribute contains a whitespace-separated list of angle values, each value is applied to the corresponding glyph in sequence. The first glyph uses the first angle, the second glyph uses the second angle, and so on. This per-glyph control allows an attacker to specify a different rotation for every character in the consent text. With a list like rotate="0 45 90 135 180 135 90 45 0" cycling through angles, individual characters may be legible in isolation (the letter A at 45 degrees is still recognizable), but the sentence as a whole is unreadable because each character points in a different direction. A user attempting to read the text must mentally parse each character’s angle, significantly increasing cognitive load. Unlike a simple rotate="180" where a user might flip their head or screen, per-glyph scrambling produces text that cannot be made readable by any simple physical orientation change. The textContent check still passes because the DOM text nodes are unchanged — only the rendering angles differ.
<!-- SA-CSS-ROT-002: per-glyph angle list scrambles character orientations -->
<svg width="420" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- textContent: "Allow: shell execution, credential access" -- keyword check passes -->
<!-- Each character rotated differently -- text unreadable as sentence -->
<!-- A=0deg, l=45deg, l=90deg, o=135deg, w=180deg (upside-down), :=135deg ... -->
<text x="210" y="60" text-anchor="middle" font-size="14"
rotate="0 45 90 135 180 135 90 45 0 45 90 135 180 135 90 45 0 45 90 135 180 135 90 45 0 45 90 135 180 135 90 45 0"
>Allow: shell execution, credential access</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: parse rotate list; flag high-variance or non-trivial per-glyph rotation
function checkPerGlyphRotate(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const rotateAttr = textEl.getAttribute('rotate');
if (!rotateAttr) continue;
const angles = rotateAttr.trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
if (angles.length <= 1) continue; // handled by single-value check (SA-CSS-ROT-001)
// Variance check: high variance = glyphs point in very different directions
const mean = angles.reduce((a, b) => a + b, 0) / angles.length;
const variance = angles.reduce((sum, a) => sum + (a - mean) ** 2, 0) / angles.length;
// Count angles that would make glyphs unreadable (beyond +/-30 degrees from 0)
const hardAngles = angles.filter(a => {
const norm = ((a % 360) + 360) % 360;
return norm > 30 && norm < 330; // outside readable zone
});
if (angles.length > 3 && hardAngles.length > 0) {
const severity = variance > 2000 ? 'CRITICAL' : 'HIGH';
findings.push({
severity, code: 'SA-CSS-ROT-002',
note: `<${textEl.tagName}> has per-glyph rotate list with ${angles.length} values; ` +
`${hardAngles.length} angles outside readable zone (>30deg from upright); ` +
`angle variance = ${variance.toFixed(0)} — text rendered with per-character rotation; ` +
`textContent correct — DOM keyword check passes; ` +
`sentence unreadable due to per-glyph angle scrambling`
});
}
}
return findings;
}
CRITICAL — SA-CSS-ROT-002: A per-glyph rotate list applying different angles to each character produces consent text where individual letters may be legible but the sentence as a whole cannot be read. textContent is unchanged so keyword extraction passes. Detection requires parsing the rotate attribute as a list and checking the variance and magnitude of angles across glyphs.
Attack 3: Partial angle list last-value-repeat selectively flips permission-scope words (SA-CSS-ROT-003)
The SVG specification defines that when the rotate list is shorter than the number of glyphs in the text element, the last value in the list is reused for all remaining glyphs. An attacker can exploit this last-value-repeat rule to target specifically the sensitive permission-scope words in a consent string. For example, in the string “Allow: shell execution, credential access”, the first 7 characters spell “Allow: ” (6 characters + space). The attacker writes rotate="0 0 0 0 0 0 0 180" — the first 7 characters at 0 degrees (upright, normal), and the 8th and all subsequent characters at 180 degrees (upside-down). The word “Allow” renders normally; the actual permission scope “shell execution, credential access” renders upside-down. A user skimming the consent dialog sees “Allow:” clearly, interprets the remaining upside-down text as part of the UI design or a visual glitch, and clicks. The textContent check still returns the full consent string. Detection requires counting the glyph offset at which the last value kicks in and checking whether the last value itself is a high-angle (unreadable) rotation.
<!-- SA-CSS-ROT-003: partial rotate list reuses last value for all remaining glyphs -->
<svg width="420" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- "Allow: " (7 chars at 0deg) then "shell execution, credential access" (all at 180deg) -->
<!-- The word "Allow:" renders upright; the actual permission scope renders upside-down -->
<!-- Per SVG spec: last value in rotate list reused for remaining glyphs -->
<text x="210" y="60" text-anchor="middle" font-size="14"
rotate="0 0 0 0 0 0 0 180">Allow: shell execution, credential access</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check last-value-repeat in partial rotate lists
function checkPartialRotateList(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const rotateAttr = textEl.getAttribute('rotate');
if (!rotateAttr) continue;
const angles = rotateAttr.trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
if (angles.length <= 1) continue;
const lastAngle = angles[angles.length - 1];
const lastNorm = ((lastAngle % 360) + 360) % 360;
const isLastUnreadable = lastNorm > 30 && lastNorm < 330;
if (!isLastUnreadable) continue;
// Compute approximate glyph count (approximate: textContent.length)
const glyphCount = textEl.textContent.length;
const listLen = angles.length;
// How many glyphs are affected by last-value-repeat
const repeatedCount = glyphCount - listLen;
if (repeatedCount <= 0) continue; // list covers all glyphs
// What fraction of glyphs are rotated by the last (unreadable) angle
const repeatedFraction = repeatedCount / glyphCount;
findings.push({
severity: repeatedFraction > 0.3 ? 'CRITICAL' : 'HIGH',
code: 'SA-CSS-ROT-003',
note: `<${textEl.tagName}> has partial rotate list (${listLen} values for ~${glyphCount} glyphs); ` +
`last value=${lastAngle}deg (norm=${lastNorm}deg, unreadable); ` +
`last-value-repeat applies to glyphs ${listLen + 1}..${glyphCount} ` +
`(~${(repeatedFraction * 100).toFixed(0)}% of text); ` +
`first ${listLen} glyphs render upright; remaining glyphs render upside-down; ` +
`permission scope words in second half of consent string are unreadable`
});
}
return findings;
}
CRITICAL — SA-CSS-ROT-003: A partial rotate list shorter than the glyph count causes the last angle to repeat for all remaining glyphs. Placing 180 as the last value after a few zero-degree entries leaves introductory words upright while rendering all permission-scope words upside-down. textContent extraction passes; detection requires checking the last angle in the list and the fraction of glyphs it covers via last-value-repeat.
Attack 4: JS mousedown injects rotate="180" on consent text and reverts at mouseup (SA-CSS-ROT-004)
A JavaScript mousedown event listener attached to the SVG consent panel calls textEl.setAttribute('rotate', '180') on the consent text element immediately when the user presses a mouse button. The text element had no rotate attribute at rest — it was rendered normally — so a static SVG analysis finds no rotation and passes. During the mousedown-to-mouseup window (the click window), the consent text renders upside-down. A mouseup event listener calls textEl.removeAttribute('rotate'), reverting the text to its normal state. After the user interacts, the DOM returns to its clean state and a post-interaction DOM snapshot shows no anomaly. The attack exploits the fact that the browser renders the SVG continuously — the setAttribute call triggers an immediate repaint — and the user’s visual perception of the consent text during the click window (when they are most attentive to what they are confirming) is of upside-down, unreadable text. MutationObserver-based dynamic detection requires specifically watching for rotate attribute changes on SVG text elements within the consent panel.
<!-- SA-CSS-ROT-004: JS mousedown injects rotate="180" on consent text, reverts at mouseup -->
<svg id="consent-svg" width="400" height="180" xmlns="http://www.w3.org/2000/svg">
<!-- No rotate attribute at rest -- static SVG scan passes -->
<text id="consent-text" x="200" y="60" text-anchor="middle"
font-size="14">Allow: shell execution, credential access</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg');
const textEl = document.getElementById('consent-text');
// mousedown fires before click -- text is upside-down during the click window
svg.addEventListener('mousedown', () => {
textEl.setAttribute('rotate', '180');
}, true);
// mouseup reverts -- text appears normal after click
svg.addEventListener('mouseup', () => {
textEl.removeAttribute('rotate');
}, true);
})();
</script>
// Detection: MutationObserver watching rotate attribute on text elements
function watchRotateInjection(svgEl, consentPanelEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.type !== 'attributes') continue;
if (mut.attributeName !== 'rotate') continue;
const target = mut.target;
const newValue = target.getAttribute('rotate');
const oldValue = mut.oldValue;
// Check if target is a text element within the consent panel
if (!consentPanelEl.contains(target)) continue;
if (!['text', 'tspan'].includes(target.tagName.toLowerCase())) continue;
const angles = (newValue || '').trim().split(/[\s,]+/).map(Number);
const hasHighAngle = angles.some(a => {
const norm = ((a % 360) + 360) % 360;
return norm > 30 && norm < 330;
});
if (hasHighAngle) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-ROT-004',
note: `rotate attribute of <${target.tagName}> changed dynamically; ` +
`old="${oldValue ?? 'none'}" → new="${newValue}"; ` +
`inject-and-revert pattern: rotate injected at mousedown, removed at mouseup; ` +
`consent text is upside-down during the user click window; ` +
`static SVG scan sees no rotate attribute at rest; ` +
`MutationObserver on rotate attribute required for detection`
});
}
}
});
observer.observe(consentPanelEl, {
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: ['rotate']
});
return findings; // will be populated asynchronously during interaction simulation
}
CRITICAL — SA-CSS-ROT-004: JavaScript injects rotate="180" on the consent text element at mousedown and removes it at mouseup. The DOM is clean at rest and after interaction — static SVG analysis sees no rotate attribute. During the click window, the consent text is upside-down. Detection requires a MutationObserver watching for rotate attribute changes on SVG text elements within the consent panel during interaction simulation.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-ROT-001: rotate=180 on text element | Single scalar rotate="180" on consent text element rotates every glyph 180 degrees — entire string renders upside-down; BCR unchanged; textContent unchanged; layout unaffected | textContent keyword check (text is correct in DOM); BCR presence check (element occupies same space); rotation is a rendering property only | Critical |
| SA-CSS-ROT-002: per-glyph angle list | Whitespace-separated rotate list assigns different angle to each glyph — characters rotate independently; individual letters readable but sentence unreadable; textContent unchanged | textContent extraction; character-by-character presence checks; BCR-based layout checks; rotation applies per-glyph not per-element | Critical |
| SA-CSS-ROT-003: partial list last-value-repeat flips permission scope | Partial rotate list shorter than glyph count; last value (180) reused for all remaining glyphs; introductory words upright; permission scope words upside-down; textContent unchanged | textContent keyword check; partial-list last-value-repeat is not checked by most SVG scanners; "Allow:" appears readable; scope content is inverted | Critical |
| SA-CSS-ROT-004: mousedown inject, mouseup revert | JS setAttribute('rotate','180') at mousedown; removeAttribute('rotate') at mouseup; DOM clean at rest and post-interaction; text upside-down during click window only | Static SVG scan (no rotate attribute at rest); post-interaction DOM snapshot; requires MutationObserver watching rotate attribute on text elements during interaction simulation | Critical |