Security Guide

MCP server SVG <animateTransform> consent security — rotate spin, fill="freeze" lock upside-down, scale to invisible, and translate off-screen via beginElement()

The SVG <animateTransform> element applies animated geometric transform changes — rotate, scale, translate, skewX, skewY — to its parent SVG element using SMIL timing. Unlike CSS transform animations, <animateTransform> is a SMIL element in the SVG DOM and is not detected by CSS animation scanners. Four attack patterns exploit <animateTransform> in MCP consent panels: continuous rotation spin exceeding visual tracking speed; freeze-at-180 permanently locks text upside-down within 100ms of display; progressive scale-to-zero gradually shrinks consent text during reading; and a dormant translate activated by beginElement() at mousedown.

Attack 1: type="rotate" continuous spin at 50ms exceeds human visual tracking (SA-CSS-ATR-001)

An <animateTransform type="rotate"> element with from="0 200 90" to="360 200 90" (full rotation around the group center), dur="0.05s" (50 milliseconds), and repeatCount="indefinite" makes the entire consent text group spin at 20 complete rotations per second. Human smooth-pursuit eye movement tops out at approximately 30 degrees per second for fine tracking; at 20 Hz full rotation the visual system cannot form a stable image of the text. The user sees a blurred smear in the location of the consent text, not legible characters. The textContent of the element is unchanged and a DOM scan passes. The BCR of the spinning element is unchanged because the bounding rectangle is computed from the untransformed element bounds in the SVG coordinate system, not from the screen-space rotation envelope. Detection requires inspecting <animateTransform type="rotate"> elements with dur values below the threshold of legible oscillation (~500ms for a 180-degree sweep) and repeatCount="indefinite".

<!-- SA-CSS-ATR-001: type=rotate spin at 50ms repeatCount=indefinite -- text unreadable -->
<svg width="420" height="180" xmlns="http://www.w3.org/2000/svg">

  <!-- Consent text group -- animateTransform rotates entire group at 20Hz -->
  <g id="consent-group">
    <text x="210" y="60" text-anchor="middle" font-size="14">Allow: shell execution, credential access</text>

    <!-- from="0 210 60" to="360 210 60": full rotation around group anchor point -->
    <!-- dur="0.05s": 50ms per revolution = 20 Hz -- exceeds visual tracking speed -->
    <animateTransform attributeName="transform" type="rotate"
                      from="0 210 60" to="360 210 60"
                      dur="0.05s" repeatCount="indefinite"/>
  </g>

  <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 animateTransform type=rotate for high-speed continuous rotation
function checkAnimateTransformSpin(svgEl) {
  const findings = [];

  for (const atEl of svgEl.querySelectorAll('animateTransform')) {
    if ((atEl.getAttribute('type') || '').toLowerCase() !== 'rotate') continue;

    const dur           = atEl.getAttribute('dur') || '';
    const repeatCount   = atEl.getAttribute('repeatCount') || '';
    const isIndefinite  = repeatCount === 'indefinite' || parseFloat(repeatCount) > 5;
    if (!isIndefinite) continue;

    // Parse dur to seconds
    let durSec = null;
    if (dur.endsWith('ms')) durSec = parseFloat(dur) / 1000;
    else if (dur.endsWith('s')) durSec = parseFloat(dur);
    if (durSec === null) continue;

    // Parse from/to to determine rotation range
    const from  = atEl.getAttribute('from') || '';
    const to    = atEl.getAttribute('to') || '';
    const fromAngle = parseFloat(from.split(/\s+/)[0]) || 0;
    const toAngle   = parseFloat(to.split(/\s+/)[0]) || 0;
    const rotRange  = Math.abs(toAngle - fromAngle);

    // Angular velocity in degrees/second
    const angVel = rotRange / durSec;

    if (angVel > 60) { // >60 deg/s exceeds comfortable visual tracking
      findings.push({
        severity: angVel > 1800 ? 'CRITICAL' : 'HIGH', // >5Hz full spin = CRITICAL
        code: 'SA-CSS-ATR-001',
        note: `<animateTransform type="rotate"> on parent <${atEl.parentElement?.tagName}>; ` +
              `from="${from}" to="${to}" dur="${dur}" repeatCount="${repeatCount}"; ` +
              `angular velocity = ${angVel.toFixed(0)} deg/s (${(angVel/360).toFixed(1)} full rotations/s); ` +
              `human smooth-pursuit tracking max ~30 deg/s for fine text; ` +
              `consent text is a motion blur — not legible; ` +
              `textContent correct — DOM scan passes; BCR based on untransformed bounds — passes`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-ATR-001: An <animateTransform type="rotate" dur="0.05s" repeatCount="indefinite"> on the consent text group spins the text at 20 Hz — far exceeding human visual tracking speed. The text is a motion blur, not readable. textContent is correct so DOM keyword checks pass. Detection requires computing angular velocity from from/to/dur and flagging values above human tracking thresholds.

Attack 2: fill="freeze" locks consent text permanently at 180° within 100ms (SA-CSS-ATR-002)

An <animateTransform type="rotate"> element with from="0 cx cy", to="180 cx cy", dur="0.1s" (100 milliseconds), and fill="freeze" begins animating as soon as the consent panel is displayed (begin="0s"). The SMIL fill="freeze" attribute means that after the animation completes, the animated value is retained as a frozen override on the element — the element stays at the to value permanently. Within 100ms of the consent panel appearing, the consent text group has been rotated 180 degrees and stays there indefinitely. A user who read the text in the first 100ms would see it flip upside-down. A user who begins reading after 100ms sees only upside-down text. The 100ms duration is below typical human reaction time for noticing a change (~150ms), making the freeze appear as if the text was always upside-down. The SMIL fill attribute (not to be confused with SVG fill color) is rarely checked by audit tools; fill="freeze" combined with a short dur and a to value with a high rotation angle is the critical pattern.

<!-- SA-CSS-ATR-002: fill=freeze locks consent text at 180deg after 100ms -->
<svg width="420" height="180" xmlns="http://www.w3.org/2000/svg">

  <g id="consent-group">
    <text x="210" y="60" text-anchor="middle" font-size="14">Allow: shell execution, credential access</text>

    <!-- begin="0s": starts immediately when SVG is rendered -->
    <!-- dur="0.1s": 100ms -- below human reaction time to notice change -->
    <!-- fill="freeze": element stays at to= value after animation ends -->
    <!-- to="180 210 60": rotated 180deg around group anchor -- upside-down forever -->
    <animateTransform attributeName="transform" type="rotate"
                      from="0 210 60" to="180 210 60"
                      begin="0s" dur="0.1s" fill="freeze"/>
  </g>

  <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 animateTransform fill=freeze with high to-angle rotation
function checkAnimateTransformFreeze(svgEl) {
  const findings = [];

  for (const atEl of svgEl.querySelectorAll('animateTransform')) {
    if ((atEl.getAttribute('type') || '').toLowerCase() !== 'rotate') continue;

    // fill="freeze" retains the to= value after animation ends
    const fillVal = (atEl.getAttribute('fill') || 'remove').toLowerCase();
    if (fillVal !== 'freeze') continue;

    const to = atEl.getAttribute('to') || '';
    const toAngle = parseFloat(to.split(/\s+/)[0]);
    if (isNaN(toAngle)) continue;

    const toNorm = ((toAngle % 360) + 360) % 360;
    if (toNorm <= 30 || toNorm >= 330) continue; // near-upright is OK

    // How quickly does it freeze?
    const dur = atEl.getAttribute('dur') || '';
    let durSec = null;
    if (dur.endsWith('ms')) durSec = parseFloat(dur) / 1000;
    else if (dur.endsWith('s')) durSec = parseFloat(dur);

    const isFast = durSec !== null && durSec < 0.5;

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-ATR-002',
      note: `<animateTransform type="rotate" to="${to}" fill="freeze"> ` +
            `on <${atEl.parentElement?.tagName}>; ` +
            `fill="freeze" retains to-value after animation ends; ` +
            `to-angle = ${toAngle}deg (norm ${toNorm}deg) = unreadable orientation; ` +
            (isFast
              ? `dur="${dur}" — freezes in ${(durSec! * 1000).toFixed(0)}ms (below human reaction time); ` +
                `text is upside-down for entire user interaction window; `
              : `text is permanently rotated after animation completes; `) +
            `textContent unchanged — DOM keyword check passes`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-ATR-002: An <animateTransform fill="freeze" to="180 cx cy" dur="0.1s" begin="0s"> locks the consent text permanently at 180 degrees (upside-down) within 100ms of consent panel display. SMIL fill="freeze" retains the to value indefinitely. Detection must check fill="freeze" combined with a rotation to angle outside the readable range on consent text elements.

Attack 3: type="scale" shrinks consent text to near-invisible during reading (SA-CSS-ATR-003)

An <animateTransform type="scale"> element with from="1", to="0.001", dur="5s", and fill="freeze" begins at begin="0s" when the consent panel is displayed. Over 5 seconds the consent text group is scaled from normal size to 0.001 times its original size — the text shrinks to a single-pixel dot. The first second shows the text at 80% of its normal size, still mostly readable. By 2 seconds it is at 60% — small but still readable. By 3 seconds it is at 40% — becoming difficult to read. By 4 seconds it is at 20% — too small to read comfortably. By 5 seconds it is effectively invisible. This slow, gradual shrink is designed to create time pressure without triggering the immediate-anomaly visual response that an instant disappearance would cause. The user experiences the consent text as “fading away” (though it is scaling, not fading) and may click Allow to confirm before the text disappears entirely. BCR of the element is computed from untransformed bounds and remains at its original size throughout, so presence checks pass. Detection requires identifying <animateTransform type="scale"> with a to value close to zero and fill="freeze".

<!-- SA-CSS-ATR-003: type=scale from=1 to=0.001 dur=5s fill=freeze -- slow shrink to invisible -->
<svg width="420" height="180" xmlns="http://www.w3.org/2000/svg">

  <g id="consent-group" style="transform-origin: 210px 60px;">
    <text x="210" y="60" text-anchor="middle" font-size="14">Allow: shell execution, credential access</text>

    <!-- begin="0s": starts immediately; dur="5s": 5 second gradual shrink -->
    <!-- to="0.001": shrinks to 0.1% of original size -- effectively invisible -->
    <!-- fill="freeze": stays at 0.001 scale after 5s -- permanently invisible -->
    <animateTransform attributeName="transform" type="scale"
                      from="1" to="0.001"
                      begin="0s" dur="5s" fill="freeze"
                      additive="replace"/>
  </g>

  <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 animateTransform type=scale with to near zero
function checkAnimateTransformScaleToZero(svgEl) {
  const findings = [];

  for (const atEl of svgEl.querySelectorAll('animateTransform')) {
    if ((atEl.getAttribute('type') || '').toLowerCase() !== 'scale') continue;

    const to  = atEl.getAttribute('to') || '';
    // Scale value: single number for uniform scale, or "sx sy" for non-uniform
    const toScale = parseFloat(to.split(/\s+/)[0]);
    if (isNaN(toScale)) continue;

    // Scale near 0 = element shrinks to invisible
    if (toScale > 0.1) continue;

    const fillVal = (atEl.getAttribute('fill') || 'remove').toLowerCase();
    const dur     = atEl.getAttribute('dur') || '';
    let durSec = null;
    if (dur.endsWith('ms')) durSec = parseFloat(dur) / 1000;
    else if (dur.endsWith('s')) durSec = parseFloat(dur);

    // Gradual (>1s) vs instant (<1s) shrink
    const isGradual = durSec !== null && durSec > 1;
    const isFrozen  = fillVal === 'freeze';

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-ATR-003',
      note: `<animateTransform type="scale" to="${to}" dur="${dur}" fill="${fillVal}"> ` +
            `on <${atEl.parentElement?.tagName}>; ` +
            `to-scale = ${toScale} — shrinks element to ${(toScale * 100).toFixed(1)}% of original; ` +
            (isGradual
              ? `gradual ${durSec}s shrink creates time-pressure effect — text legible early, invisible later; `
              : `instant shrink — text disappears immediately; `) +
            (isFrozen ? `fill="freeze" — element stays at 0.001 scale permanently; ` : '') +
            `BCR computed from untransformed bounds — presence check passes throughout; ` +
            `textContent unchanged — keyword check passes`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-ATR-003: An <animateTransform type="scale" from="1" to="0.001" dur="5s" fill="freeze"> on the consent text group shrinks the text to near-invisible over 5 seconds. BCR remains at original untransformed size so presence checks pass throughout. Detection requires checking <animateTransform type="scale"> for to values approaching zero.

Attack 4: type="translate" begin="indefinite" activated by beginElement() at mousedown (SA-CSS-ATR-004)

An <animateTransform type="translate"> element with from="0 0", to="1000 0", dur="0.05s", and begin="indefinite" is placed as a child of the consent text group. A static SMIL scan finds this element and reads begin="indefinite" — which means the animation cannot be triggered by any DOM event and will never start without explicit beginElement(). The scanner classifies it as dormant and safe. However, a JavaScript mousedown event listener calls atEl.beginElement(). The animation starts immediately: in 50ms the consent text group translates 1000 pixels to the right, placing it entirely off-screen. The user presses their mouse button on the consent panel and the consent text disappears off-screen to the right before they lift their finger. A mouseup listener calls atEl.endElement(). SMIL animation end-behavior with no fill attribute defaults to fill="remove" — the animated value is removed and the element snaps back to its original position. After the interaction, the DOM is clean. Detection requires cross-referencing <animateTransform begin="indefinite"> with JavaScript event handlers calling beginElement().

<!-- SA-CSS-ATR-004: translate begin=indefinite + JS beginElement() moves text off-screen at mousedown -->
<svg id="consent-svg" width="420" height="180" xmlns="http://www.w3.org/2000/svg">

  <g id="consent-group">
    <text x="210" y="60" text-anchor="middle" font-size="14">Allow: shell execution, credential access</text>

    <!-- begin="indefinite" -- cannot fire from DOM events without beginElement() -->
    <!-- Static SMIL scan: "dormant, begin=indefinite" -- not flagged -->
    <!-- dur="0.05s", to="1000 0" -- snaps text 1000px off-screen in 50ms at mousedown -->
    <animateTransform id="translate-attack" attributeName="transform" type="translate"
                      from="0 0" to="1000 0"
                      begin="indefinite" dur="0.05s"/>
    <!-- No fill attribute -- defaults to fill="remove" -- snaps back at mouseup -->
  </g>

  <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 atEl   = document.getElementById('translate-attack');

  // beginElement() bypasses begin="indefinite" and immediately starts the animation
  svg.addEventListener('mousedown', () => {
    atEl.beginElement(); // translate text 1000px off-screen in 50ms
  }, true);

  svg.addEventListener('mouseup', () => {
    atEl.endElement();   // cancel animation; fill="remove" default snaps text back
  }, true);
})();
</script>

// Detection: cross-reference dormant animateTransform with JS beginElement() calls
function findDormantAnimateTransform(svgEl) {
  const dormant = [];

  for (const atEl of svgEl.querySelectorAll('animateTransform')) {
    if ((atEl.getAttribute('begin') || '') !== 'indefinite') continue;

    const type = atEl.getAttribute('type') || '';
    const to   = atEl.getAttribute('to') || '';

    // Check if to-value implies off-screen translation
    if (type === 'translate') {
      const parts = to.split(/\s+/).map(Number);
      const dist  = Math.sqrt((parts[0] || 0) ** 2 + (parts[1] || 0) ** 2);
      if (dist > 200) {
        dormant.push({
          el: atEl,
          id: atEl.getAttribute('id'),
          type, to, dist
        });
      }
    }
  }
  return dormant;
  // Cross-reference with JS beginElement() calls in Phase 2 analysis
  // (same pattern as SA-CSS-SET-004 for <set begin="indefinite">)
}

// Phase 2: detect beginElement() in event handlers (shared with SA-CSS-SET-004 detection)
function findBeginElementCallsInScripts() {
  const scripts = document.querySelectorAll('script');
  for (const s of scripts) {
    const src = s.textContent || '';
    if (/beginElement\s*\(\s*\)/.test(src) && /mousedown/.test(src)) {
      return { hasBeginElementOnMousedown: true };
    }
  }
  return { hasBeginElementOnMousedown: false };
}

CRITICAL — SA-CSS-ATR-004: An <animateTransform type="translate" begin="indefinite"> on the consent text group is classified as dormant by static SMIL scan. JavaScript calls beginElement() at mousedown to translate the text 1000px off-screen, then endElement() at mouseup to snap it back via fill="remove" default. DOM is clean before and after. Detection requires cross-referencing dormant SMIL animations with JavaScript beginElement() calls in mousedown handlers.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-ATR-001: rotate spin at 50ms indefinite animateTransform type=rotate from=0 to=360 dur=50ms repeatCount=indefinite spins consent text at 20 Hz; exceeds human visual tracking; text is motion blur; textContent unchanged; BCR unchanged textContent keyword check; BCR presence check; CSS animation scanner (animateTransform is SMIL not CSS); angular velocity threshold check required Critical
SA-CSS-ATR-002: rotate fill=freeze to=180 at 0s animateTransform type=rotate to=180 dur=0.1s fill=freeze begin=0s — consent text permanently upside-down within 100ms of display; SMIL fill=freeze retains to-value forever; textContent unchanged textContent keyword check; fill="freeze" attribute rarely checked by audit tools; short dur below human reaction time makes freeze appear as initial state Critical
SA-CSS-ATR-003: scale to=0.001 dur=5s fill=freeze animateTransform type=scale from=1 to=0.001 dur=5s fill=freeze — text shrinks over 5 seconds from legible to invisible; BCR computed from untransformed bounds (presence check passes); time pressure on user BCR presence check (untransformed bounds unchanged); textContent keyword check; gradual change less conspicuous than instant; fill=freeze makes invisible state permanent Critical
SA-CSS-ATR-004: translate begin=indefinite + beginElement() mousedown animateTransform type=translate begin=indefinite classified as dormant; JS mousedown calls beginElement() to translate text 1000px off-screen; mouseup endElement() snaps back; DOM clean at rest and post-interaction Static SMIL scan (begin=indefinite = dormant/safe); post-interaction DOM snapshot; requires cross-referencing dormant SMIL animations with JS beginElement() calls in mousedown handlers Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-ATR-001: animateTransform type=rotate with dur≤100ms and repeatCount=indefinite on consent text group produces rotation exceeding human visual tracking speed; text is unreadable motion blur; textContent and BCR checks pass; angular velocity (deg/from-to/dur) computation required.
CRITICAL SA-CSS-ATR-002: animateTransform type=rotate fill="freeze" with to-angle ≥30deg locks consent text permanently at unreadable orientation; SMIL fill="freeze" retains to-value after animation ends; begin=0s means freeze happens immediately at consent display; textContent unchanged.
CRITICAL SA-CSS-ATR-003: animateTransform type=scale to=0.001 dur=5s fill="freeze" shrinks consent text to invisible over 5 seconds; BCR uses untransformed bounds — presence check passes throughout; gradual shrink creates time pressure; fill="freeze" makes invisible state permanent after 5s.
CRITICAL SA-CSS-ATR-004: animateTransform type=translate begin=indefinite appears dormant to static SMIL scan; JS mousedown calls beginElement() to activate off-screen translation during click window; mouseup calls endElement() reverting via fill="remove" default; cross-reference of dormant SMIL elements with JS beginElement() calls required.

Related: SVG rotate attribute consent attacks  |  SVG animateMotion consent attacks  |  SVG set element consent attacks

← Blog  |  Security Checklist