Security Guide

MCP server SVG <animateMotion> element consent security — oscillating path button, off-screen keyPoints timing, additive="sum" displacement compounding, and beginElement() mousedown injection

The SVG <animateMotion> element moves an element along a motion path specified via the path attribute directly on the element (as distinct from the <mpath> child reference approach). Path-based <animateMotion> can be used to oscillate consent buttons faster than human targeting reaction time, park buttons off-screen using keyframe timing trickery, compound individually harmless displacements into an off-screen total, or inject motion programmatically during the click window without any static indication in the DOM.

Attack 1: Sub-100ms oscillation exceeds human targeting reaction time (SA-CSS-AMO-001)

An <animateMotion> child of the consent button group uses path="M0,0 L400,0 L0,0" with dur="0.05s" and repeatCount="indefinite". This moves the button 400px to the right and back to its starting position in 50 milliseconds — completing 20 full oscillation cycles per second. Human targeting reaction time, the time required to move a cursor accurately to a moving target, ranges from 200–400ms for experienced users. A button that changes horizontal position every 25ms (half-cycle) is repositioned roughly 8–16 times within the targeting window. Clicks that fire during the swing phase land on empty SVG canvas rather than the button element. The button oscillates but is never invisible and never leaves the viewport, so a naive “is the element off-screen” check passes. The attack is purely kinetic: the consent button is technically present and technically clickable at any frozen moment, but practically untargetable at normal interaction speed. The critical indicators are dur below 100ms and repeatCount="indefinite" on an <animateMotion> element that is a descendant of an interactive consent element.

<!-- SA-CSS-AMO-001: 50ms oscillation on consent button group -- 20 cycles/sec -->
<svg width="400" height="200">
  <!-- Button group that receives the motion animation -->
  <g id="consent-btn-allow">
    <rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
    <text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>

    <!-- animateMotion oscillates entire group 400px back and forth every 50ms -->
    <!-- 0.05s dur = 20Hz oscillation -- exceeds human targeting reaction time -->
    <animateMotion
      path="M0,0 L400,0 L0,0"
      dur="0.05s"
      repeatCount="indefinite"/>
  </g>

  <g id="consent-btn-deny">
    <rect x="120" y="140" width="80" height="30" rx="4" fill="#374151"/>
    <text x="160" y="160" text-anchor="middle" fill="white" font-size="13">Deny</text>
    <!-- Deny button oscillates identically -->
    <animateMotion path="M0,0 L400,0 L0,0" dur="0.05s" repeatCount="indefinite"/>
  </g>
</svg>

// Detection: enumerate all <animateMotion> children of interactive consent elements
function checkAnimateMotionOscillation(svgEl) {
  const findings = [];

  // Collect all animateMotion elements within the SVG
  const animMotions = svgEl.querySelectorAll('animateMotion');

  for (const anim of animMotions) {
    const dur = anim.getAttribute('dur') || '';
    const repeatCount = anim.getAttribute('repeatCount') || '';
    const calcMode = anim.getAttribute('calcMode') || 'paced';

    // Parse duration to seconds
    let durSec = Infinity;
    if (dur.endsWith('ms')) durSec = parseFloat(dur) / 1000;
    else if (dur.endsWith('s')) durSec = parseFloat(dur);
    else if (!isNaN(parseFloat(dur))) durSec = parseFloat(dur);

    const isIndefinite = repeatCount === 'indefinite';
    const isTooFast = durSec < 0.1; // sub-100ms

    if (isTooFast && isIndefinite) {
      // Walk up to find nearest interactive ancestor
      let ancestor = anim.parentElement;
      while (ancestor && ancestor !== svgEl) {
        const tag = ancestor.tagName.toLowerCase();
        const hasClick = ancestor.getAttribute('onclick') ||
                         ancestor.getAttribute('pointer-events') === 'all';
        if (['g', 'rect', 'circle', 'text'].includes(tag)) {
          findings.push({
            severity: 'CRITICAL', code: 'SA-CSS-AMO-001',
            note: `<animateMotion> dur="${dur}" (${(durSec*1000).toFixed(0)}ms) with ` +
                  `repeatCount="indefinite" on <${ancestor.tagName}> — ` +
                  `${(1/durSec).toFixed(0)} cycles/sec exceeds human targeting reaction time ` +
                  `(200-400ms); consent button oscillates faster than user can aim click; ` +
                  `button is never off-screen but is practically untargetable`
          });
          break;
        }
        ancestor = ancestor.parentElement;
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-AMO-001: An <animateMotion> element with dur below 100ms and repeatCount="indefinite" on or within a consent button group oscillates the button faster than human targeting reaction time. The button is technically present and never leaves the viewport, so off-screen and visibility checks pass. Detection must enumerate all <animateMotion> descendants of interactive consent elements and flag dur < 100ms combined with repeatCount="indefinite" regardless of path geometry.

Attack 2: keyPoints spends 95% of cycle at off-screen path extreme (SA-CSS-AMO-002)

An <animateMotion> uses path="M0,0 L-500,0" to define a motion path that exits the viewport (x=−500 is 500px to the left of the SVG origin, off-screen for a typical 400px-wide consent panel). Normally this would move the element continuously from its current position to x=−500 and back. But the keyPoints="0;0;0;1" and keyTimes="0;0.5;0.95;1" attributes redefine how the animation progresses along the path over time. At keyTime=0 the element is at keyPoint=0 (start of path, on-screen). At keyTime=0.5 (4 seconds into the 8-second cycle) it is still at keyPoint=0 — has not moved. At keyTime=0.95 (7.6 seconds) it is still at keyPoint=0. Only between keyTime=0.95 and keyTime=1 (the last 0.4 seconds of the 8-second cycle) does the element travel from the start to the end of the path (off-screen to on-screen or vice versa, depending on path direction). The result: the button is parked at the off-screen path extreme for 95% of the time (7.6 seconds) and rushes to its on-screen position for only 0.4 seconds per cycle. The user must time their click within a 0.4-second window every 8 seconds. Parsing only the path attribute for off-screen coordinates is insufficient; the keyPoints/keyTimes distribution must be analyzed to find the fraction of time spent at each path extreme.

<!-- SA-CSS-AMO-002: keyPoints/keyTimes parks button off-screen 95% of 8-second cycle -->
<svg width="400" height="200">
  <g id="consent-btn-allow">
    <rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
    <text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>

    <!--
      path: starts at (0,0) on-screen, ends at (-500,0) off-screen
      keyPoints: 0;0;0;1 = path progress at each keyTime
      keyTimes: 0;0.5;0.95;1 = time fractions

      Time 0.0 to 0.95 (0-7.6s): element stays at keyPoint=0 = path start
        But path="M0,0 L-500,0" begins at (-500,0), so keyPoint=0 is OFF-SCREEN
      Time 0.95 to 1.0 (7.6-8.0s): element travels from path start to end = ON-SCREEN
      User has 0.4s window every 8s to click the button
    -->
    <animateMotion
      path="M0,0 L-500,0"
      keyPoints="0;0;0;1"
      keyTimes="0;0.5;0.95;1"
      calcMode="linear"
      dur="8s"
      repeatCount="indefinite"/>
  </g>
</svg>

// Detection: parse keyPoints/keyTimes to find fraction of time at off-screen path extreme
function checkKeyPointsOffScreen(svgEl) {
  const findings = [];
  const svgBCR = svgEl.getBoundingClientRect();

  for (const anim of svgEl.querySelectorAll('animateMotion')) {
    const path = anim.getAttribute('path') || '';
    const keyPointsAttr = anim.getAttribute('keyPoints') || '';
    const keyTimesAttr = anim.getAttribute('keyTimes') || '';
    const repeatCount = anim.getAttribute('repeatCount') || '1';

    if (!path || !keyPointsAttr || !keyTimesAttr) continue;

    const keyPoints = keyPointsAttr.split(';').map(Number);
    const keyTimes = keyTimesAttr.split(';').map(Number);

    if (keyPoints.length !== keyTimes.length) continue;

    // Find path endpoint x-coordinate (simplified: parse last L command)
    const lastL = path.match(/L\s*([-\d.]+)\s*,\s*([-\d.]+)\s*$/);
    if (!lastL) continue;

    const endX = parseFloat(lastL[1]);
    const endY = parseFloat(lastL[2]);

    // Check if path extreme exits SVG viewport
    const svgVB = svgEl.viewBox?.baseVal;
    const vpWidth = svgVB?.width || svgBCR.width;
    const vpHeight = svgVB?.height || svgBCR.height;

    const endOffScreen = endX < 0 || endX > vpWidth ||
                         endY < 0 || endY > vpHeight;
    if (!endOffScreen) continue;

    // Compute time fraction spent at each extreme (keyPoint=0 or 1)
    // keyPoint=0 → path start (0,0); keyPoint=1 → path end (endX,endY)
    // Calculate time spent at path start (keyPoint=0) vs path end (keyPoint=1)
    let timeAtEndExtreme = 0;
    for (let i = 0; i < keyPoints.length - 1; i++) {
      // If both adjacent keyPoints are at the extreme (0 or 1), count that time
      if (keyPoints[i] <= 0.01 && keyPoints[i+1] <= 0.01) {
        timeAtEndExtreme += keyTimes[i+1] - keyTimes[i];
      }
      if (keyPoints[i] >= 0.99 && keyPoints[i+1] >= 0.99) {
        timeAtEndExtreme += keyTimes[i+1] - keyTimes[i];
      }
    }

    const pctOffScreen = timeAtEndExtreme * 100;

    if (pctOffScreen > 80) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-AMO-002',
        note: `<animateMotion> path ends at (${endX},${endY}) — off SVG viewport ` +
              `(viewport: ${vpWidth}x${vpHeight}); keyPoints="${keyPointsAttr}" + ` +
              `keyTimes="${keyTimesAttr}" → element spends ${pctOffScreen.toFixed(0)}% ` +
              `of animation cycle at off-screen path extreme; button visible only ` +
              `${(100-pctOffScreen).toFixed(0)}% of cycle; path-only scan misses timing attack`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-AMO-002: keyPoints and keyTimes attributes redefine how quickly an animation progresses along its path. A combination of keyPoints="0;0;0;1" and keyTimes="0;0.5;0.95;1" parks the element at the path start for 95% of the cycle. If the path start is off-screen, the button is hidden 95% of the time. Checking only whether the path exits the viewport is insufficient — the fraction of time spent at the off-screen extreme must also be computed from the keyPoints/keyTimes distribution. Flag any animation spending more than 80% of its cycle at an off-screen path extreme.

Attack 3: Multiple additive="sum" animations compound total displacement (SA-CSS-AMO-003)

The SMIL additive="sum" attribute on <animateMotion> means each animation’s displacement is added to the element’s base position rather than replacing it. When multiple additive="sum" animations run simultaneously on the same element, their displacements accumulate. An attacker uses two (or more) <animateMotion> elements on the consent button: the first defines path="M0,0 L-30,0" (30px left), the second defines path="M0,0 L-40,0" (40px left). Each individual animation looks small — 30px and 40px displacements might appear cosmetic. But with additive="sum", both run concurrently and their displacements are added: the actual button displacement is −30 + (−40) = −70px. If the button starts at x=20 inside a 80px-wide viewport section, a 70px leftward displacement moves it off-screen. An audit that evaluates each <animateMotion> element in isolation will pass both animations individually while missing that their vector sum pushes the button off-screen. Detection must collect all simultaneously active additive="sum" animations on the same target element and compute their combined displacement vector.

<!-- SA-CSS-AMO-003: two additive=sum animateMotion elements -- individually innocent, combined off-screen -->
<svg width="400" height="200" viewBox="0 0 400 200">
  <g id="consent-btn-allow">
    <rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
    <text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>

    <!-- First animation: -30px horizontal -- looks harmless in isolation -->
    <animateMotion
      path="M0,0 L-30,0"
      dur="4s"
      repeatCount="indefinite"
      additive="sum"/>

    <!-- Second animation: -40px horizontal -- also looks harmless in isolation -->
    <!-- Combined with first: -30 + -40 = -70px total leftward displacement -->
    <!-- Button starts at x=20; displaced to x=-50 (off-screen left) -->
    <animateMotion
      path="M0,0 L-40,0"
      dur="6s"
      repeatCount="indefinite"
      additive="sum"/>
  </g>
</svg>

// Detection: collect all additive=sum animations on same element; compute vector sum
function checkAdditiveMotionSum(svgEl) {
  const findings = [];
  const svgVB = svgEl.viewBox?.baseVal;
  const vpWidth = svgVB?.width || svgEl.getBoundingClientRect().width;
  const vpHeight = svgVB?.height || svgEl.getBoundingClientRect().height;

  // Group animateMotion elements by their parent (target) element
  const animsByParent = new Map();

  for (const anim of svgEl.querySelectorAll('animateMotion')) {
    const additive = anim.getAttribute('additive') || 'replace';
    if (additive !== 'sum') continue;

    const parent = anim.parentElement;
    if (!parent) continue;

    if (!animsByParent.has(parent)) animsByParent.set(parent, []);
    animsByParent.get(parent).push(anim);
  }

  for (const [targetEl, anims] of animsByParent) {
    if (anims.length < 2) continue; // Need at least 2 to compound

    // Compute combined displacement vector (sum of all path endpoints)
    let totalDx = 0;
    let totalDy = 0;

    for (const anim of anims) {
      const path = anim.getAttribute('path') || '';
      const lastL = path.match(/L\s*([-\d.]+)\s*,\s*([-\d.]+)/);
      if (lastL) {
        totalDx += parseFloat(lastL[1]);
        totalDy += parseFloat(lastL[2]);
      }
    }

    // Get element's static base position
    const targetBCR = targetEl.getBoundingClientRect();
    const svgBCR = svgEl.getBoundingClientRect();
    const baseX = targetBCR.left - svgBCR.left;
    const baseY = targetBCR.top - svgBCR.top;

    const displacedX = baseX + totalDx;
    const displacedY = baseY + totalDy;

    const goesOffScreen = displacedX < 0 || displacedX > vpWidth ||
                          displacedY < 0 || displacedY > vpHeight;

    if (goesOffScreen) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-AMO-003',
        note: `${anims.length} concurrent <animateMotion additive="sum"> on ` +
              `<${targetEl.tagName} id="${targetEl.id}"> — ` +
              `individual displacements [${anims.map(a => {
                const m = (a.getAttribute('path')||'').match(/L\s*([-\d.]+)\s*,\s*([-\d.]+)/);
                return m ? `(${m[1]},${m[2]})` : '(?)';
              }).join(', ')}] sum to (${totalDx},${totalDy}) — ` +
              `element displaced to (${displacedX.toFixed(0)},${displacedY.toFixed(0)}) ` +
              `which exits SVG viewport (${vpWidth}x${vpHeight}); ` +
              `single-animation review passes each; vector sum required`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-AMO-003: Multiple <animateMotion additive="sum"> elements on the same target element have their displacements accumulated by the SVG rendering engine. Individually small path displacements (−30px, −40px) that pass a per-animation range check can compound to a total displacement (−70px) that moves the consent button off-screen. Detection must group all additive="sum" animations by their parent element and compute the vector sum of their path endpoints to find the combined displacement.

Attack 4: begin="indefinite" with beginElement() called at mousedown (SA-CSS-AMO-004)

An <animateMotion> element on the consent button uses begin="indefinite", which means the animation does not start automatically and requires an explicit programmatic trigger via the SMIL beginElement() method. In the static DOM, begin="indefinite" is inert and appears harmless — the button is stationary when the SVG loads. A JavaScript mousedown event listener (attached with capture: true so it fires before the button’s own event handlers) calls animateMotionEl.beginElement(). The animation path "M0,0 L0,-200" moves the button 200px upward — off the top of the SVG viewport — within the first frame. The button vanishes upward during the mousedown–mouseup window, causing the click to miss. A mouseup listener calls animateMotionEl.endElement(), which stops the animation and returns the button to its base position. The complete static DOM audit shows only an inert begin="indefinite" attribute with a simple upward path. The dynamic threat requires both: scanning for beginElement() calls on animateMotion elements in event handler code, and deploying a MutationObserver on the element’s transform attribute and a click-time position check via getBoundingClientRect() during the interaction window.

<!-- SA-CSS-AMO-004: begin=indefinite; JS beginElement() fires at mousedown to move button off-screen -->
<svg width="400" height="200">
  <g id="consent-btn-allow">
    <rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
    <text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>

    <!--
      begin=indefinite: animation is inert at rest -- static scan sees no threat
      path: M0,0 L0,-200 -- moves button 200px upward (off top of SVG viewport)
      dur=0.5s fill=freeze: button snaps off-screen and stays until endElement() called
    -->
    <animateMotion
      id="btn-motion"
      begin="indefinite"
      path="M0,0 L0,-200"
      dur="0.5s"
      fill="freeze"/>
  </g>
</svg>

<script>
(function() {
  const btnMotion = document.getElementById('btn-motion');

  // capture:true ensures this fires before button click handler
  document.addEventListener('mousedown', () => {
    // beginElement() starts the animation NOW -- button moves upward immediately
    btnMotion.beginElement();
  }, true);

  document.addEventListener('mouseup', () => {
    // endElement() stops animation and fill=freeze holds position until reset
    btnMotion.endElement();
    // Reset button position by restarting to fill=remove state
    btnMotion.setAttribute('fill', 'remove');
    void btnMotion.offsetWidth; // force reflow
    btnMotion.setAttribute('fill', 'freeze');
  }, true);
})();
</script>

// Detection Part 1: scan JS for beginElement() calls on animateMotion elements
function scanForBeginElementCalls(svgEl) {
  const findings = [];

  // Collect all animateMotion elements with begin=indefinite
  const indefiniteAnims = Array.from(
    svgEl.querySelectorAll('animateMotion[begin="indefinite"]')
  );
  if (!indefiniteAnims.length) return findings;

  // Extract inline scripts and scan for beginElement() calls
  const scripts = document.querySelectorAll('script:not([src])');
  for (const script of scripts) {
    const code = script.textContent || '';
    if (code.includes('beginElement')) {
      for (const anim of indefiniteAnims) {
        const animId = anim.getAttribute('id') || '(no-id)';
        // Check if script references this animation element
        const referencesAnim = anim.id && code.includes(anim.id);

        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-AMO-004',
          note: `<animateMotion id="${animId}" begin="indefinite"> found; ` +
                `inline script contains beginElement() call ` +
                `(references this element: ${referencesAnim ? 'YES' : 'possible'}); ` +
                `static DOM shows inert begin=indefinite; ` +
                `programmatic beginElement() call at mousedown moves element during click window; ` +
                `both JS scan AND MutationObserver on element transform required`
        });
      }
    }
  }
  return findings;
}

// Detection Part 2: MutationObserver on element transform to catch runtime motion
function monitorBeginElementMotion(svgEl, onFinding) {
  for (const anim of svgEl.querySelectorAll('animateMotion[begin="indefinite"]')) {
    const target = anim.parentElement;
    if (!target) continue;

    const baseRect = target.getBoundingClientRect();
    const svgRect = svgEl.getBoundingClientRect();

    const obs = new MutationObserver(() => {
      const currentRect = target.getBoundingClientRect();
      const dx = currentRect.left - baseRect.left;
      const dy = currentRect.top - baseRect.top;
      const displaced = Math.abs(dx) > 5 || Math.abs(dy) > 5;

      if (displaced) {
        onFinding({
          severity: 'CRITICAL', code: 'SA-CSS-AMO-004',
          note: `<${target.tagName} id="${target.id}"> moved (dx=${dx.toFixed(0)}px, ` +
                `dy=${dy.toFixed(0)}px) via beginElement() on <animateMotion begin="indefinite"> ` +
                `— runtime position check detected motion; ` +
                `element now at (${(currentRect.left-svgRect.left).toFixed(0)},` +
                `${(currentRect.top-svgRect.top).toFixed(0)}) px within SVG`
        });
      }
    });

    obs.observe(target, {
      attributes: true,
      attributeFilter: ['transform'],
      subtree: false
    });
  }
}

CRITICAL — SA-CSS-AMO-004: begin="indefinite" on an <animateMotion> element is inert in the static DOM and appears harmless. A JavaScript mousedown listener that calls animateMotionEl.beginElement() starts the animation before the click fires, moving the consent button off-screen during the click window. endElement() at mouseup restores the button. Static analysis cannot detect this pattern from DOM attributes alone. Both a JavaScript scan for beginElement() calls on begin="indefinite" animation elements and a MutationObserver on the target element’s transform attribute are required.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-AMO-001: sub-100ms oscillating path <animateMotion path="M0,0 L400,0 L0,0" dur="0.05s" repeatCount="indefinite"> oscillates button at 20Hz — 8–16 repositions within human targeting window; button never leaves viewport but cannot be aimed at Off-screen detection (button stays in viewport); visibility checks (button is always rendered); requires dur < 100ms + repeatCount=indefinite flag on interactive element descendants Critical
SA-CSS-AMO-002: keyPoints off-screen 95% of cycle keyPoints="0;0;0;1" keyTimes="0;0.5;0.95;1" parks button at off-screen path extreme for 95% of 8-second cycle; on-screen for only 0.4s per cycle Path-only off-screen check (path exits viewport but time distribution not analyzed); requires computing fraction of cycle spent at off-screen extreme from keyPoints/keyTimes Critical
SA-CSS-AMO-003: additive=sum compounding Multiple animateMotion additive=sum on same element; individual paths (-30px, -40px) look innocent; vector sum (-70px) pushes button off-screen Per-animation displacement check (each individual animation passes); requires grouping all additive=sum animations by target element and computing combined displacement vector Critical
SA-CSS-AMO-004: beginElement() mousedown injection begin=indefinite is inert in static DOM; JS mousedown calls beginElement() — button moves off-screen; endElement() at mouseup restores position; static DOM shows no active animation Static DOM scan (begin=indefinite looks harmless); requires JS scan for beginElement() on indefinite animations AND MutationObserver on element transform during interaction Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-AMO-001: <animateMotion> with dur < 100ms and repeatCount="indefinite" found as descendant of interactive consent element — oscillation frequency exceeds human targeting reaction time (200–400ms); button is technically present and in-viewport but practically untargetable; flag all sub-100ms indefinite motion on interactive SVG consent elements.
CRITICAL SA-CSS-AMO-002: <animateMotion> path exits SVG viewport AND keyPoints/keyTimes distribution spends more than 80% of animation cycle at the off-screen extreme — button is inaccessible during 95% of the cycle; path-only off-screen checks miss the timing attack; keyPoints/keyTimes fraction analysis required.
CRITICAL SA-CSS-AMO-003: Two or more concurrent <animateMotion additive="sum"> elements on the same consent element — vector sum of path endpoint displacements moves element off-screen; individual displacement review passes each animation; all additive=sum animations on a target must be summed to find combined effect.
CRITICAL SA-CSS-AMO-004: <animateMotion begin="indefinite"> present on consent element AND inline script contains beginElement() call — programmatic animation trigger during mousedown moves button off-screen during click window; static DOM scan sees inert begin=indefinite attribute only; JS scan for beginElement() references and runtime MutationObserver on element transform both required.

Related: SVG mpath element consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG set element consent attacks

← Blog  |  Security Checklist