Security Research — 2026-10-16
SVG Transform and Motion Attack Families on MCP Consent: rotate, animateTransform, and SMIL Timing Attacks
SVG exposes two independent transformation systems — the rotate presentation attribute that controls glyph-level character orientation, and the SMIL <animateTransform> element that drives continuous motion through the browser’s compositing pipeline. Both can defeat MCP consent audits without touching textContent, bounding client rectangles, or display/visibility properties. This article unifies three related attack families across eight SkillAudit finding codes (SA-CSS-ROT-001 through SA-CSS-ATR-004) and identifies the cross-family dormant SMIL pattern that static scanners miss entirely.
Contents
- Why SVG transforms are different from CSS transforms
- Family 1: SVG
rotateglyph-level attacks (SA-CSS-ROT-001–004) - Family 2:
<animateTransform>temporal distortion attacks (SA-CSS-ATR-001–004) - Family 3: Cross-family dormant SMIL
beginElement()injection - Unified detection table
- SkillAudit finding codes
Why SVG transforms are different from CSS transforms
Most MCP consent audit tools check the CSS transform property via getComputedStyle(element).transform or element.style.transform. When an element is rotated with transform: rotate(180deg), both approaches return a non-identity matrix value and the scanner flags it. This defence is effective against CSS transforms, but SVG exposes two entirely separate transformation mechanisms that route through different browser code paths.
The first is the SVG rotate presentation attribute. This attribute applies to <text> and <tspan> elements specifically, and it controls the rotation of individual glyphs, not the element as a whole. A rotate="180" attribute on a <text> element rotates every character 180 degrees around its own glyph origin. The getComputedStyle transform property of the element itself remains the identity matrix — the rotation is not a transform on the element, it is a rendering instruction applied per-glyph during text layout. No CSS transform property reflects it.
The second is the SMIL <animateTransform> element. SMIL animations run inside the SVG compositing pipeline, not via CSS animation or the Web Animations API. An <animateTransform type="rotate"> element attached to a consent text group drives a continuous transformation through the SVG rendering engine. getComputedStyle returns the static style properties of the element; it does not reflect mid-frame SMIL-driven transform values during active animations. A static audit that reads getComputedStyle or even the element’s transform attribute at a single point in time sees nothing wrong.
Both families exploit the fundamental assumption that “transform” means the CSS transform property. In SVG consent panels, that assumption is wrong.
Family 1: SVG rotate glyph-level attacks (SA-CSS-ROT-001–004)
The SVG rotate attribute is designed to support decorative and linguistic text layouts where individual characters need to be oriented differently — vertical CJK text, spiral typography, radial text on a clock face. An attacker repurposes this per-glyph control to make consent text unreadable while keeping the DOM textContent identical to a compliant string.
Four sub-patterns have been documented. See the full SA-CSS-ROT reference page for SVG code examples and JavaScript detection snippets for each.
SA-CSS-ROT-001: Single scalar rotate="180"
A single numeric value applied to a <text> element rotates all glyphs uniformly. rotate="180" renders the entire consent string upside-down. textContent is correct; BCR is unchanged. Detection: read the rotate attribute value and flag non-trivial angles (>30°).
SA-CSS-ROT-002: Per-glyph angle list
A whitespace-separated list assigns a different rotation to each character. Individual letters may be recognizable but the sentence cannot be read. Variance across the angle list determines severity. Detection: parse the rotate list and compute angle variance.
SA-CSS-ROT-003: Partial list last-value-repeat
The SVG spec repeats the last list value for all remaining glyphs. A list ending in 180 after a few zero entries leaves “Allow:” upright while all permission-scope words render upside-down. Detection: check last angle in list and glyph offset coverage.
SA-CSS-ROT-004: mousedown inject, mouseup revert
JS setAttribute('rotate','180') fires at mousedown; removeAttribute fires at mouseup. Static DOM is clean at rest and post-interaction. Text is upside-down precisely during the click window. Detection: MutationObserver on rotate attribute of SVG text elements.
The key insight across all four rotate patterns is that the rotate attribute is entirely invisible to CSS-centric audits. It does not appear in getComputedStyle, does not affect the CSS transform property, and does not change the element’s bounding client rect. An audit that only checks CSS computed styles will rate a rotate="180" consent panel as clean.
Family 2: <animateTransform> temporal distortion attacks (SA-CSS-ATR-001–004)
SMIL’s <animateTransform> element animates the SVG transform attribute on its parent element using the browser’s own animation compositing pipeline. It is not the CSS animation system. It does not register in the Web Animations API (element.getAnimations() returns WAAPI animations, not SMIL animations, in all major browsers as of 2026). It does not appear in getComputedStyle during animation frames. Its only reliable detection surface is the SMIL element itself in the SVG DOM.
Four sub-patterns have been documented. See the full SA-CSS-ATR reference page for SVG code examples and detection pseudocode.
SA-CSS-ATR-001: Spin-to-blur at 20 Hz exceeds human smooth-pursuit tracking
An <animateTransform type="rotate" from="0" to="360" dur="50ms" repeatCount="indefinite"> element rotates the consent text group at 7,200 degrees per second — 20 complete rotations per second. Human smooth-pursuit eye tracking can follow motion up to approximately 30 degrees per second; at 7,200 degrees per second the visual cortex perceives motion blur, not text. The angular velocity formula is (to - from) / dur in degrees per second; any value above ~120°/s reliably defeats smooth-pursuit and produces blur. Detection requires computing angular velocity from the from, to, and dur attributes of <animateTransform type="rotate"> elements.
<!-- SA-CSS-ATR-001: spin at 20 Hz -- 7200 deg/s exceeds smooth-pursuit tracking -->
<g id="consent-group">
<animateTransform attributeName="transform" type="rotate"
from="0 200 90" to="360 200 90"
dur="50ms" repeatCount="indefinite"/>
<text x="200" y="60" text-anchor="middle" font-size="14">
Allow: shell execution, credential access
</text>
</g>
// Detection: angular velocity from animateTransform attributes
function checkAnimateTransformSpin(svgEl) {
const findings = [];
for (const atEl of svgEl.querySelectorAll('animateTransform[type="rotate"]')) {
const from = parseFloat((atEl.getAttribute('from') || '0').split(/\s+/)[0]);
const to = parseFloat((atEl.getAttribute('to') || '0').split(/\s+/)[0]);
const durStr = atEl.getAttribute('dur') || '1s';
const durMs = durStr.endsWith('ms') ? parseFloat(durStr) : parseFloat(durStr) * 1000;
const angVelDegPerSec = Math.abs(to - from) / (durMs / 1000);
if (angVelDegPerSec > 120) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-ATR-001',
note: `animateTransform type=rotate at ${angVelDegPerSec.toFixed(0)} deg/s; ` +
`exceeds smooth-pursuit tracking (120 deg/s threshold); text renders as blur`
});
}
}
return findings;
}
SA-CSS-ATR-002: fill="freeze" permanently locks consent text upside-down in under 100 ms
SMIL’s fill attribute (unrelated to the SVG paint fill) controls what happens after an animation ends. With fill="freeze", the browser retains the final animated value permanently. An <animateTransform type="rotate" from="0" to="180" dur="100ms" fill="freeze" begin="0s"> starts immediately when the consent panel is rendered and completes within 100 milliseconds — below average human reaction time (150–300 ms). After 100 ms, the consent text is permanently upside-down for the remainder of the session. A scanner that reads the SVG after initial render but before the 100 ms deadline sees a running animation; a scanner that reads it after the deadline sees a frozen-upside-down panel. Both views show textContent intact. Detection requires enumerating fill="freeze" animateTransform elements and checking if the frozen value produces an unreadable orientation.
SA-CSS-ATR-003: scale-to-zero with fill="freeze" creates time pressure then permanent invisibility
An <animateTransform type="scale" from="1" to="0.001" dur="5s" fill="freeze"> gradually shrinks the consent text group from full size to near-zero over five seconds. The attack combines two exploitation layers: during the 5-second animation, a user experiences time pressure to read shrinking text; after the animation completes, the text is permanently scaled to 0.001× — a 14-pixel font renders at 0.014 pixels, which is invisible. Crucially, getBoundingClientRect() of the SVG element returns the SVG viewport bounds, not the bounds of the animated child group. A BCR-based presence check passes even when the text is 0.001 pixels tall.
SA-CSS-ATR-004: Dormant begin="indefinite" animateTransform activated by beginElement()
An <animateTransform begin="indefinite"> element does nothing at rest — it exists in the SVG DOM but is in a waiting state. A static SVG scanner enumerating <animateTransform> elements will find it, but its begin="indefinite" attribute signals that it waits for programmatic activation. A JavaScript mousedown event listener calls animateTransformEl.beginElement(), triggering a 50 ms translate that moves the consent panel 1,000 pixels off-screen. A mouseup handler calls endElement(), snapping the panel back via SMIL’s default fill="remove". The panel is off-screen precisely during the click window; the click fires on the empty viewport behind it. Detection requires cross-referencing dormant begin="indefinite" SMIL elements with JavaScript event handler source code for beginElement() calls.
Family 3: Cross-family dormant SMIL beginElement() injection
Reviewing the eight finding codes across SA-CSS-ROT and SA-CSS-ATR reveals a structural cross-family pattern: the begin="indefinite" + beginElement() attack appears in both the <set> family (SA-CSS-SET-004) and the <animateTransform> family (SA-CSS-ATR-004). Both use identical JavaScript code — a mousedown handler calling smilElement.beginElement(). The SMIL element type differs (a <set> changes a property value; an <animateTransform> applies a geometric transform) but the activation mechanism and detection strategy are identical.
This matters for audit tool architecture. A scanner that treats SMIL animation types as separate categories and routes them through different detection code paths will require a separate beginElement() scan for each SMIL type. A scanner that first performs a unified dormant-SMIL pass — finding all SMIL elements with begin="indefinite" regardless of type — and then performing a single JavaScript beginElement() cross-reference scan handles all current variants with one code path. When new SMIL element types are added to the attack surface (future variants using <animate>, <animateMotion>, or <animateColor> with begin="indefinite"), the unified approach covers them automatically.
The recommended detection algorithm has two phases:
- Phase 1 — Dormant SMIL enumeration: Collect all SMIL elements (
<animate>,<set>,<animateTransform>,<animateMotion>) within the consent panel that havebegin="indefinite"or an equivalent unresolvedbeginvalue. Record each element’s type, target, and attributeName/type. - Phase 2 — JS
beginElement()cross-reference: Scan all JavaScript event handler source code (inline handlers and assignedaddEventListenercallbacks) for calls to.beginElement(). For each dormant SMIL element whose ID or reference appears in abeginElement()call, raise a finding at the severity appropriate for the SMIL element’s effect (visibility change = CRITICAL; minor transform = HIGH).
// Unified Phase 1 + Phase 2 dormant SMIL detection
function detectDormantSmilBeginElement(svgEl) {
const findings = [];
// Phase 1: collect all begin="indefinite" SMIL elements within consent panel
const dormantSmil = [];
const smilTags = ['animate', 'set', 'animateTransform', 'animateMotion', 'animateColor'];
for (const tag of smilTags) {
for (const el of svgEl.querySelectorAll(tag)) {
const begin = el.getAttribute('begin') || '';
if (begin === 'indefinite' || begin === '') {
dormantSmil.push({
el, tag, id: el.id || null,
target: el.getAttribute('attributeName') || el.getAttribute('type') || tag,
});
}
}
}
if (dormantSmil.length === 0) return findings;
// Phase 2: scan JS event handler source for .beginElement() calls
const scripts = [...document.querySelectorAll('script')].map(s => s.textContent || '');
const inlineHandlers = [...svgEl.querySelectorAll('*')]
.flatMap(el => ['onmousedown','onmouseup','onclick','onpointerdown']
.filter(h => el.getAttribute(h))
.map(h => el.getAttribute(h)));
const allJsSource = [...scripts, ...inlineHandlers].join('\n');
for (const dormant of dormantSmil) {
const idRef = dormant.id ? new RegExp(`\\b${dormant.id}\\b`) : null;
const hasBeginElement = allJsSource.includes('beginElement()') || allJsSource.includes('beginElement(');
const referencesThisEl = !idRef || idRef.test(allJsSource);
if (hasBeginElement && referencesThisEl) {
findings.push({
severity: 'CRITICAL',
code: dormant.tag === 'animateTransform' ? 'SA-CSS-ATR-004' : 'SA-CSS-SET-004',
note: `dormant <${dormant.tag} begin="indefinite"> targeting "${dormant.target}" ` +
`is activated by JS beginElement() call in event handler; ` +
`SMIL effect fires during click window; DOM clean at rest; ` +
`cross-reference Phase 1 (dormant SMIL) + Phase 2 (JS beginElement) required`
});
}
}
return findings;
}
Unified detection table
The following table maps each finding code to the detection layer that catches it. A checkmark (✓) indicates the technique is sufficient; a cross (×) indicates the technique misses this attack entirely.
| Finding | Static SVG attribute scan | CSS getComputedStyle | SMIL element enumeration | JS event handler scan | MutationObserver (interaction sim) |
|---|---|---|---|---|---|
| SA-CSS-ROT-001 rotate=180 on text |
✓ read rotate attr | × not in computed style | × not SMIL | × | × (static, not dynamic) |
| SA-CSS-ROT-002 per-glyph angle list |
✓ parse rotate list + variance | × | × | × | × |
| SA-CSS-ROT-003 partial list last-value-repeat |
✓ check last value + glyph count | × | × | × | × |
| SA-CSS-ROT-004 mousedown inject, mouseup revert |
× no attr at rest | × | × | ✓ detect setAttribute('rotate') call | ✓ MutationObserver on rotate attr during sim |
| SA-CSS-ATR-001 spin-to-blur at 20 Hz |
✓ compute angular velocity from from/to/dur | × SMIL not in computed style | ✓ enumerate animateTransform + compute velocity | × | × |
| SA-CSS-ATR-002 fill=freeze permanent upside-down |
✓ find animateTransform fill=freeze to=180 | × | ✓ enumerate + check fill + frozen value | × | × |
| SA-CSS-ATR-003 scale-to-zero time pressure + permanent |
✓ find animateTransform type=scale to≈0 fill=freeze | × | ✓ enumerate + check type=scale + to value | × | × |
| SA-CSS-ATR-004 dormant begin=indefinite + beginElement() |
× begin=indefinite looks dormant | × | ✓ Phase 1: find begin=indefinite elements | ✓ Phase 2: find beginElement() calls | × |
Key insight: SA-CSS-ROT-001 through -003 require static SVG attribute scanning on the rotate attribute specifically. SA-CSS-ROT-004 and SA-CSS-ATR-004 require JS source analysis and/or MutationObserver. SA-CSS-ATR-001 through -003 require SMIL element enumeration with mathematical computation of animation parameters. No single detection technique covers all eight patterns — a complete audit engine needs all four layers.