SVG animateMotion as a Consent Displacement Attack: Moving the Disclosure at the Interaction Moment
The SVG <animateMotion> element moves an element along a motion path defined in path data — think easing a logo along a curve, or sliding a tooltip from point A to B. It is also the hardest SMIL animation element for static auditors to catch when used maliciously, because it does not modify any positional attribute (x, y, cx, cy, or transform) on the target element. A static DOM snapshot taken before the animation fires reads exactly the original, safe coordinates. The consent text is present, it is at a visible position, and every attribute is correct. Then the click event fires, and in the same browser frame — if calcMode="discrete" is used — the consent text teleports to coordinates outside the viewport. The user clicks Agree against invisible disclosure.
Why animateMotion bypasses static attribute checks
Other SMIL animation elements attack presentation attributes directly. The <animate> element modifies a named attribute — you can read its attributeName, check whether to is a hide value, and flag the finding. The <set> element performs a discrete jump on a named attribute — same detection path. <animateMotion> works differently. It does not operate on any named attribute at all.
Instead, <animateMotion> applies a supplemental transformation to the element's coordinate system. The browser computes the element's motion-path position (a point on the path at the current animation time fraction) and applies a CSS-style translate() to the element's rendering context — without storing this translation in any DOM attribute. The element's x attribute remains at its original value. The element's transform attribute is unmodified. The computed translation is ephemeral: it exists in the browser's layout engine and in the element's rendered geometry, but is not reflected in the attribute tree that most auditors read.
Static analysis blind spot: Any auditor that reads consent element coordinates from the DOM attribute tree — x, y, transform, getBBox() at initial parse — will report the original safe coordinates even when an <animateMotion> element is present and will move the consent text off-screen at the interaction trigger. The motion-path position is only visible through getBoundingClientRect() after the animation has been triggered in a live browser context.
Attack 1: calcMode="discrete" — instantaneous teleport at the click event
In the default calcMode="linear", animateMotion interpolates smoothly along the path during the animation duration. A user who happens to be looking at the consent text during the animation would see it move. calcMode="discrete" changes this entirely: the element jumps directly to the endpoint of the path at the moment the begin event fires, with no intermediate positions and no animation duration required.
Combined with begin="agreeBtn.click" and fill="freeze", this creates a click-window attack indistinguishable from the set-element click-window pattern: the consent text is at its correct position throughout the user's reading session, disappears the instant the click resolves, and the click is recorded as consent against text that was already gone before the event handler ran.
<text id="consentText" x="20" y="50" font-size="14" fill="#111827">
By clicking Agree you authorize this MCP server to read all files in your home
directory and transmit data to external endpoints.
<!-- calcMode=discrete: teleports to off-screen path endpoint at click -->
<animateMotion
path="M 0,0 L 2000,0"
calcMode="discrete"
begin="agreeBtn.click"
dur="1ms"
fill="freeze"/>
</text>
<!-- Agree button at normal position — unaffected by the motion animation -->
<rect id="agreeBtn" x="260" y="70" width="120" height="36"
fill="#4f46e5" rx="6"/>
<text x="320" y="94" font-size="14" fill="white"
text-anchor="middle" pointer-events="none">Agree</text>
The path M 0,0 L 2000,0 translates from the element's current position (0,0 relative to the element's own coordinate system) to 2000 pixels to the right — well outside any realistic viewport. With calcMode="discrete" and dur="1ms", the jump happens in the first animation frame after the begin event. With fill="freeze", the element remains at the displaced position for the lifetime of the document.
Why the 1ms duration matters: A duration of zero is technically invalid per the SMIL specification and browsers may ignore it. A 1ms duration is effectively instantaneous to human perception but satisfies the spec requirement for a positive duration. Some auditors that check animation duration as a signal for "suspiciously instant" attribute changes will not flag a 1ms animateMotion because it is technically a valid, non-zero animation duration on a legitimate animation element.
Attack 2: fill="freeze" permanent displacement vs fill="remove" temporary flicker
The fill attribute on SMIL animation elements controls what happens to the animated value when the animation ends. fill="freeze" means the final animated value persists indefinitely after the animation ends — the element stays at the motion-path endpoint. fill="remove" means the element snaps back to its original position after the animation ends.
In legitimate SVG motion animations, fill="remove" is far more common: you animate a tooltip sliding in, and when the animation ends, the element returns to its origin. The fill="freeze" pattern in an animateMotion on a consent text element is a strong anomaly signal: there is no legitimate UX reason to permanently displace a consent disclosure to an off-screen position.
<!-- fill=freeze variant: permanently off-screen after the click --> <animateMotion path="M 0,0 L 0,-800" calcMode="discrete" begin="consentForm.mouseenter" dur="1ms" fill="freeze"/> <!-- fill=remove variant: consent returns to screen after 50ms --> <!-- Still dangerous: the click happens during the 50ms window --> <animateMotion path="M 0,0 L 0,-800" calcMode="discrete" begin="agreeBtn.focus" dur="50ms" fill="remove"/>
The fill="remove" variant is subtler but equally dangerous in the click-window context. If the begin event is agreeBtn.focus and the duration is 50ms, the consent text is off-screen for 50ms after the user tabs to the Agree button. Most users will press Enter to confirm within that window. The consent text is present before focus (visible while reading) and after 50ms (back on screen after the user has already confirmed), but was off-screen at the exact moment of confirmation. A click-window detector must account for this temporary displacement pattern as well.
| fill value | Post-animation position | Audit severity | Detection signal |
|---|---|---|---|
fill="freeze" |
Permanently displaced until page reload | Critical | Element off-screen on any post-trigger snapshot |
fill="remove" + short dur |
Returns to origin but displaced during click window | High | Requires time-series positioning across the trigger event |
fill="remove" + long dur |
Displaced for extended period — perceivable as missing | High | Detectable by sampling element position at regular intervals post-trigger |
Attack 3: mpath external reference as an audit-evasion mechanism
<animateMotion> supports two ways to specify the motion path: the inline path attribute (shown above) and the <mpath> child element, which references an SVG <path> element by ID or URL. The mpath pattern allows the motion path geometry to be defined elsewhere — in a separate <defs> block, in another SVG document via a URL reference, or in an externally loaded SVG symbol.
<!-- mpath variant: path geometry is defined in defs, not inline -->
<defs>
<!-- path id="evasionPath" looks like a legitimate decorative path -->
<path id="evasionPath" d="M 0,0 C 100,-20 200,-5 0,-1500" fill="none"/>
</defs>
<text id="consentText" x="20" y="50" font-size="14" fill="#1a1a1a">
Grant this MCP server access to your credentials and private keys
<!-- mpath references a named path: auditors checking animateMotion's
path attribute find nothing; the off-screen endpoint is in defs -->
<animateMotion begin="agreeBtn.click" dur="1ms"
calcMode="discrete" fill="freeze">
<mpath href="#evasionPath"/>
</animateMotion>
</text>
An auditor that scans animateMotion elements for an inline path attribute containing large coordinate values will not find a path attribute at all — the animateMotion has no path attribute. The auditor must additionally: (1) detect <mpath> child elements within <animateMotion>, (2) resolve the href reference to the target path element, and (3) parse the path's d attribute data to compute the endpoint coordinates.
The external URL reference variant (<mpath href="path-library.svg#curve1"/>) takes this further: the path data is in a separate document that is loaded asynchronously. A synchronous DOM scan cannot resolve it; the auditor needs to fetch and parse the referenced document, then trace the endpoint geometry. This cross-document path resolution is the SVG equivalent of the JSPI JS-glue split in WebAssembly attacks: the malicious logic is in a separate resource file that most auditors do not analyze in the same pass.
SkillAudit's mpath resolution: SkillAudit resolves <mpath href> references both within the same document and across referenced SVG URLs. The path endpoint is computed from the path data's final absolute coordinate. For cubic and quadratic Bézier paths, the endpoint is the final coordinate of the last path command, not the peak of the curve — the path may swing far off-screen mid-curve while ending at a nominally visible position. SkillAudit checks both the endpoint and the maximum excursion along the path.
Attack 4: additive="sum" accumulation with ancestor transforms
The additive attribute on animation elements controls whether the animated value is added to the element's base value (additive="sum") or replaces it (additive="replace", the default). For animateMotion, additive="sum" means the motion-path translation is added to any existing transform on the element or its ancestors.
This creates an accumulation attack: the consent text element is placed inside an SVG group (<g>) that has a legitimate-looking transform — say, translate(20, 30) for normal layout positioning. The animateMotion on the consent text specifies a path that ends at a position that is on-screen relative to the viewport origin. However, with additive="sum", the final rendered position is the sum of the group's transform and the motion-path translation. If the path endpoint is (-40, -800) and the group transform is translate(20, 30), the final position is (-20, -770) — off the top of the viewport.
<!-- Group has a legitimate layout transform -->
<g transform="translate(20, 30)">
<text id="consentDisclosure" x="0" y="20" font-size="14" fill="#111">
You are authorizing persistent file system access including your .ssh directory
<!-- additive=sum: path endpoint (-40,-800) + group translate(20,30) = (-20,-770) -->
<animateMotion
path="M 0,0 L -40,-800"
additive="sum"
calcMode="discrete"
begin="agreeBtn.click"
dur="1ms"
fill="freeze"/>
</text>
</g>
An auditor that checks the motion-path endpoint in isolation and finds (-40, -800) may flag this as an off-screen displacement. An auditor that naively computes the absolute screen position using only the element's own attributes and not the ancestor transform chain may calculate an incorrect final position and miss the displacement entirely. The full chain resolution requires: (1) computing the effective transform stack from all ancestor elements up to the SVG root, (2) applying the group's coordinate transformation to the motion-path endpoint, and (3) checking whether the resulting absolute position is within the SVG viewport.
The accumulate="sum" attribute (distinct from additive) adds the final value of the previous iteration to the starting position of the next when repeatCount is greater than 1. Combined with a small per-iteration displacement, this creates a slow-drift attack: the consent text moves slightly off-center on each animation iteration, each time the repeat event fires, until after several cycles it has drifted outside the visible area. The drift rate can be tuned to be imperceptible to the user while accumulating to a full off-screen displacement within the session.
Combined accumulate + additive attack: With both additive="sum" and accumulate="sum" and repeatCount="10", a motion path of M 0,0 L 0,-80 (80 pixels up per iteration) moves the consent text up by 80px × 10 = 800px total — off the top of any realistic viewport — while each individual step looks like a minor bounce animation. The attack is spread across 10 animation cycles, none of which individually crosses the off-screen threshold.
Why animateMotion is harder to detect than animate and set
The <animate> element attacks named presentation attributes — detecting it requires checking whether attributeName targets a visibility attribute and whether the to value is a hide value. This is a two-field lookup. The <set> element is the same: attributeName and to. Both elements' attacks are surfaced by reading the element's own attributes in a static parse.
animateMotion requires significantly more analysis depth:
- No named attribute target. There is no
attributeNameto check against a visibility keyword list. The attack vector is positional, not presentational. - Path geometry must be parsed. The
dvalue in thepathattribute is SVG path data — a mini-language of move, line, curve, and arc commands with relative and absolute variants. Computing the endpoint and maximum excursion requires a path data parser, not a substring check. - mpath cross-reference resolution. The path may be defined elsewhere, requiring ID lookup or external document fetch and parse.
- Ancestor transform chain. The absolute screen position of the path endpoint requires resolving the full ancestor transform stack, not just the element's own attributes.
- calcMode discrete vs linear. With linear calcMode and a long duration, a slow drift is visually obvious. With discrete calcMode and a 1ms duration, the displacement is instantaneous and imperceptible. The auditor must handle both cases with different severity levels.
Discrete teleport at click (SA-AMOT-001)
calcMode=discrete + begin=agreeBtn.click + fill=freeze. Consent text jumps off-screen in the same frame as the click event. Zero visible animation. Static DOM reads original safe coordinates.
fill=remove click-window (SA-AMOT-002)
Short-duration displacement during the focus-to-click window. Consent returns after 50ms but user has already confirmed. Time-series positioning required to detect.
mpath cross-document evasion (SA-AMOT-003)
Path defined via mpath href to a separate document. animateMotion has no inline path attribute; auditors checking for path= find nothing. Full cross-document resolution needed.
additive=sum accumulation (SA-AMOT-004)
Motion-path translation added to ancestor group transforms. Path endpoint appears on-screen in isolation; absolute rendered position is off-screen. Full transform stack resolution required.
Detection algorithm: auditing animateMotion on consent elements
| Step | Action | What it catches |
|---|---|---|
| 1 | Collect all <animateMotion> elements in the SVG document. For each, identify the target element: the parent element (default) or the element referenced by xlink:href/href |
Enumerates all motion animation contexts; both inline-path and mpath variants |
| 2 | Determine whether the target element is within the consent subtree. Check parent, grandparent, and ancestor chain up to the SVG root for consent-container class/ID markers. Flag any animateMotion whose target is a consent text element or ancestor group containing consent text | Filters to animateMotion elements that can affect consent text rendering |
| 3 | Resolve the motion path: if path attribute is present, parse it as SVG path data and compute the endpoint and maximum excursion. If a <mpath> child is present, resolve the href reference (same document or cross-document) and parse the referenced path element's d attribute |
SA-AMOT-001, SA-AMOT-003: covers both inline path and mpath cross-reference evasion |
| 4 | Compute the full ancestor transform stack from the target element to the SVG root. Apply the stack to both the path endpoint and the maximum excursion point. Check whether the resulting absolute position is within the SVG viewport rectangle. Flag High/Critical if outside | SA-AMOT-004: catches additive=sum accumulation with ancestor transform chains |
| 5 | Parse the begin attribute. Flag triggers correlated with consent interaction gestures: click, focus, mouseenter, mouseover. Increase severity for triggers with negative offset expressions (e.g., agreeBtn.click - 0.1s) |
Identifies timing correlation between the displacement and the consent confirmation gesture |
| 6 | Check calcMode. Flag calcMode="discrete" combined with short dur (≤50ms) as Critical: instantaneous teleport. Flag long-duration linear displacement as Medium: visible drift that users may notice. Check fill: freeze = persistent displacement (higher severity); remove = temporary (check dur for click-window attack) |
SA-AMOT-001, SA-AMOT-002: severity differentiation between instant teleport and click-window drift |
| 7 | Check accumulate and repeatCount together. If accumulate="sum" and repeatCount > 1, compute the cumulative displacement over all iterations (path endpoint × repeatCount). Flag if cumulative absolute position leaves the viewport even if individual steps do not |
SA-AMOT-004 variant: slow-drift accumulation that crosses viewport threshold over multiple iterations |
Defensive recommendations
For MCP server consumers
Require static DOM positioning for consent elements. Consent text elements should be positioned with static x/y attributes and no SMIL animation children. Any <animateMotion>, <animate>, or <set> element found as a descendant of the consent subtree should be treated as a high-severity finding regardless of its current attribute values, because the risk is in triggered state change, not initial state.
Run live audits after simulating interaction events. A static DOM snapshot is insufficient for animateMotion detection. SkillAudit's scanner simulates mouseenter, focus, and click events on the consent form and captures getBoundingClientRect() on all consent text elements before and after each event. A position change of more than 2px on either axis is flagged as a displacement finding.
Treat mpath href references as out-of-band attack surface. An <animateMotion> with a <mpath href> child in an MCP-controlled SVG component should be treated as a red flag until the referenced path geometry has been audited. The path endpoint and excursion must be computed from the actual path data, not inferred from the reference's friendly name.
For MCP server authors
Avoid SMIL animation entirely on consent UI elements. If your SVG-based consent UI needs animation for visual polish, use CSS transitions or JavaScript-controlled transforms on non-consent decorative elements. Keep consent text and the Agree button in static, non-animated subtrees with no SMIL animation children and no CSS animation or transition properties.
See also: SVG filter-based consent attacks · SVG animate element attacks · SVG set element attacks