Security Deep-Dive · 2026-10-01

SVG SMIL Animation as a Consent Timing Attack

SVG SMIL animate and set elements can change filter primitive attribute values at user-interaction events using nothing but SVG markup — no JavaScript, no event listeners, no inline script. The attack surface is attribute-based: a filter primitive starts in a neutral state and transitions to an attack state at precisely the moment the user interacts with the consent form. Static analysis reads the pre-animation initial value and concludes the filter is safe. Runtime behavior tells a different story.

Why SMIL is harder to audit than JavaScript animation

The dominant mental model for SVG filter consent attacks assumes that manipulation requires JavaScript. An auditor scans for addEventListener, looks for style.opacity assignments, checks for element.setAttribute('flood-color', …) calls in script. When none appear, the consent form is flagged as clean.

SVG SMIL animation breaks this assumption at the specification level. The SVG 1.1 animation model — carried forward into SVG 2.0 via the Web Animations API integration — allows any SVG presentation attribute to be animated declaratively inside the SVG document itself. No JavaScript. No event handlers registered with addEventListener. The animation is entirely specified in markup.

JavaScript animation attack

  • Requires addEventListener or onclick=
  • Visible in script analysis and event listener enumeration
  • Blocked by strict Content Security Policy (script-src)
  • Detectable via getEventListeners() in DevTools
  • Attribute value changeable via MutationObserver

SMIL animation attack

  • Requires only SVG markup — no script
  • Invisible to script analysis and event listener enumeration
  • Not blocked by script-src CSP directive
  • Does not appear in getEventListeners()
  • Attribute appears to change spontaneously at interaction

The second asymmetry is the initial-value trap. When an auditor calls el.getAttribute('flood-color') or reads feFloodElement.floodColor at parse time, the value returned is the initial pre-animation value — which is by design a safe value. The attack state only exists after the animation's begin trigger fires. To detect a SMIL timing attack, an auditor must enumerate the animate and set child elements within the filter, read their begin attribute for interaction references, and evaluate the to value as the effective attribute value during the consent interaction window.

The fundamental detection gap

Every existing consent auditor tool we have reviewed checks present-tense property values: getComputedStyle(), getAttribute(), getBoundingClientRect(). These APIs return the current state. SMIL attacks are future-tense: they encode an attack state that does not exist at audit time but will exist at interaction time. Detecting them requires shifting from "what is the current value?" to "what values can this attribute transition to, under what trigger conditions, and does any such transition produce an attack state during the consent interaction window?"

The SMIL animation model: a brief primer

Four SVG animation elements are relevant to consent attack surfaces: <animate>, <set>, <animateTransform>, and <animateMotion>. All four share a timing model built on three concepts:

AttributePurposeAttack-relevant values
begin When the animation starts. Can reference interaction events on any element by ID. button.click, checkbox.mouseenter, form.submit, 0s (immediate)
to / from / values The target attribute value at animation end (or intermediate keyframes). Any attack-state attribute value: flood-color: white, slope: 0, transformed filter region
fill What happens to the attribute after the animation duration ends. freeze keeps the end value; remove reverts to initial. freeze — attack persists permanently after trigger; remove — attack exists only during animation duration
dur Animation duration. Combined with fill="freeze", controls whether the attack is momentary or permanent. indefinite — animation runs forever (common with set); short duration + fill="freeze" — instant permanent change

The interaction event reference syntax is elementId.eventName inside the begin attribute. Any element on the page can be referenced by ID, not just elements in the same subtree as the filter or consent text. A submit button in the consent form, referenced by its ID, can trigger a filter animation on any element anywhere in the document.

Attack pattern 1 (SA-SMIL-001): feFlood flood-color animation

SA-SMIL-001 · Severity: Critical

feFlood flood-color transitions to white at button click

A feFlood primitive starts with flood-color="transparent" and flood-opacity="0". An animate child transitions flood-color to white and flood-opacity to 1 when the submit button is clicked (begin="submitBtn.click"). The feFlood output is used as the source for a feComposite operator="over" that overlays white on top of the consent text at the click moment, erasing the text precisely as the user activates the submit action.

<filter id="consent-filter">
  <feFlood flood-color="transparent" flood-opacity="0" result="cover">
    <animate attributeName="flood-color" to="white"
             begin="submitBtn.click" dur="0.1s" fill="freeze"/>
    <animate attributeName="flood-opacity" to="1"
             begin="submitBtn.click" dur="0.1s" fill="freeze"/>
  </feFlood>
  <feComposite in="cover" in2="SourceGraphic" operator="over" result="out"/>
</filter>

At parse time: getAttribute('flood-color') === 'transparent'. Static filter analysis: feFlood produces transparent output, composite passes SourceGraphic through. Assessment: safe. After submitBtn.click: flood-color becomes white, flood-opacity becomes 1, the composite outputs a solid white rectangle. The consent text is erased at the moment of action — the user sees white space where the disclosure was.

The fill="freeze" attribute is the critical detail. Without it, the filter would revert to transparent after 0.1 seconds. With it, the attack state is permanent: every subsequent render of the consent text element produces a white rectangle until the page is reloaded.

GDPR Art. 7(2) relevance: If the consent text is erased at the moment the user clicks submit, the user cannot read the precise scope of consent they are granting at the critical decision moment. The consent capture occurs but the informational basis for it — the disclosure text — was erased by the MCP server at the exact moment of capture. This is the highest-severity consent timing attack pattern: the attack window aligns perfectly with the action window.

Attack pattern 2 (SA-SMIL-002): feFuncA slope animation

SA-SMIL-002 · Severity: Critical

feFuncA slope transitions to zero on mouseenter, freezes

A feComponentTransfer + feFuncA type="linear" slope="1" starts with a pass-through alpha transfer (slope=1, intercept=0 — no change to alpha channel). A set element changes the slope to 0 when the mouse enters the consent form element. With slope=0, the alpha channel of every pixel in the consent text is multiplied by zero — the entire element becomes transparent. The attack fires at the first mouse movement toward the consent form, before any click occurs, and with fill="freeze" the zero-slope state persists for the remainder of the session.

<filter id="consent-alpha-filter">
  <feComponentTransfer>
    <feFuncA type="linear" slope="1" intercept="0">
      <set attributeName="slope" to="0"
           begin="consentForm.mouseenter" fill="freeze"/>
    </feFuncA>
  </feComponentTransfer>
</filter>

Why set rather than animate? set changes the attribute to its to value instantaneously at the begin trigger — no interpolation, no visible transition. The consent text disappears at the precise frame when the mouse enters the form area. There is no visible animation to notice. To the user, the consent text simply stops rendering as the cursor approaches, which may be attributed to a rendering glitch if noticed at all.

The feFuncA slope attack is particularly evasive because:

  1. The initial slope=1 is correct. getComputedStyle analysis, attribute inspection, and filter graph analysis all find a safe pass-through transfer function.
  2. The attack fires on mouseenter — the most natural preamble to a form interaction — before any click event occurs.
  3. The feFuncA element is nested two levels deep (filter → feComponentTransfer → feFuncA) making recursive descent more complex.
  4. The set element has no duration attribute — it activates instantaneously — making animation-duration analysis ineffective.

Attack pattern 3 (SA-SMIL-003): animateTransform on filter region

SA-SMIL-003 · Severity: High

animateTransform repositions the filter region onto consent text at activation

A <filter> element with x="-200%" y="-200%" positions the filter region entirely outside the consent element at load time. An animateTransform changes the filter's x and y to cover the consent text when the form receives focus. Because the filter region initially does not overlap the element, the filter's visual effects are not applied at parse time. Once the form is focused, the region snaps into position and the filter (a feFlood overlay, opacity eraser, or feBlend darkener) takes effect.

<filter id="offscreen-filter" x="-200%" y="-200%" width="100%" height="100%">
  <feFlood flood-color="white" flood-opacity="1" result="cover"/>
  <feComposite in="cover" in2="SourceGraphic" operator="over"/>
  <animateTransform attributeName="x" type="translate"
                    to="0%" begin="consentForm.focus"
                    dur="0s" fill="freeze"/>
  <animateTransform attributeName="y" type="translate"
                    to="0%" begin="consentForm.focus"
                    dur="0s" fill="freeze"/>
</filter>

Detection gap: auditing the filter's primitives finds a dangerous feFlood + feComposite combination. But the filter region has x="-200%" at parse time, placing it outside the element. A static check that evaluates the filter region coordinates would conclude the filter has no visual effect. Only checking whether any animate or animateTransform child modifies x, y, width, or height reveals that the safe coordinates are temporary.

Attack pattern 4 (SA-SMIL-004): chained animate creating a consent-phase visibility window

SA-SMIL-004 · Severity: High

Chained animate elements create a window where consent is hidden only during the interaction phase

Two animate elements on a feFlood flood-opacity attribute are chained: the first sets opacity to 1 at a button's mousedown (hiding the consent text with a white overlay), the second sets opacity back to 0 at the button's mouseup (removing the overlay). Between mousedown and mouseup — the precise window of a click event — the consent text is erased. After the mouseup, the overlay disappears, the consent text is visible again, and a post-hoc audit of the page finds no attack state.

<feFlood flood-color="white" flood-opacity="0" result="cover">
  <animate attributeName="flood-opacity" to="1"
           begin="submitBtn.mousedown" dur="indefinite" fill="freeze"/>
  <animate attributeName="flood-opacity" to="0"
           begin="submitBtn.mouseup" dur="0.1s" fill="freeze"/>
</feFlood>

This pattern is the most evasive: a post-interaction audit finds flood-opacity="0" (safe state), no visible attack, and no suspicious DOM modifications. Only auditing the filter before interaction — or reading the animate elements' begin/to values — reveals that a click event window attack was possible.

Why click-window attacks matter: Human click events have a mousedown-to-mouseup window of approximately 80–300ms for normal users. This window is imperceptibly short — the user cannot observe the overlay appearing and disappearing. The consent text is erased at the moment of decision. Legally, the capture occurred while disclosure was hidden. The attack window does not need to span the entire consent session — only the capture moment.

The detection algorithm

Detecting SMIL timing attacks requires traversing the filter element's full descendant tree and evaluating each animate/set/animateTransform element against three criteria: does it target a consent-relevant attribute, what is its to value, and does its begin trigger reference a user interaction event on the consent form or its interactive children?

function detectSMILConsentTimingAttacks(filterEl, consentFormEl) {
  const risks = [];
  const interactiveIds = new Set(
    [...consentFormEl.querySelectorAll('button, input, [tabindex]')]
      .map(el => el.id).filter(Boolean)
  );
  interactiveIds.add(consentFormEl.id);

  // Interaction event names SMIL can reference
  const INTERACTION_EVENTS = new Set([
    'click','mousedown','mouseup','mouseenter','mouseover',
    'focus','focusin','submit','change','keydown','keyup'
  ]);

  // Attributes whose attack-state values indicate a consent erasure
  const ATTACK_CHECKS = {
    'flood-opacity': v => parseFloat(v) > 0.5,
    'flood-color':   v => isLightColor(v),
    'slope':         v => parseFloat(v) < 0.2,       // feFuncA slope
    'intercept':     v => parseFloat(v) > 0.5,       // feFuncA intercept
    'amplitude':     v => parseFloat(v) < 0.1,       // feFuncA amplitude
    'opacity':       v => parseFloat(v) < 0.2,
    'x':             _v => true,                     // filter region repositioning
    'y':             _v => true,
    'width':         v => parseFloat(v) < 5,
    'height':        v => parseFloat(v) < 5,
  };

  const animationEls = filterEl.querySelectorAll(
    'animate, set, animateTransform, animateMotion'
  );

  for (const anim of animationEls) {
    const attrName = anim.getAttribute('attributeName');
    const toVal    = anim.getAttribute('to') || anim.getAttribute('values');
    const beginVal = anim.getAttribute('begin') || '';
    const fillVal  = anim.getAttribute('fill') || 'remove';

    if (!attrName || !toVal) continue;

    // Parse begin= for interaction event references
    const beginParts = beginVal.split(';').map(s => s.trim());
    for (const part of beginParts) {
      const dotIdx = part.lastIndexOf('.');
      if (dotIdx === -1) continue;
      const refId    = part.slice(0, dotIdx);
      const eventName = part.slice(dotIdx + 1);

      if (!INTERACTION_EVENTS.has(eventName)) continue;
      if (!interactiveIds.has(refId)) continue;

      // Interaction-triggered animation on consent form element — check attack value
      const checker = ATTACK_CHECKS[attrName];
      if (checker && checker(toVal)) {
        risks.push({
          animElement: anim,
          attribute: attrName,
          toValue: toVal,
          trigger: part,
          persistent: fillVal === 'freeze',
          severity: fillVal === 'freeze' ? 'critical' : 'high'
        });
      }
    }
  }

  return risks;
}

function isLightColor(colorStr) {
  if (/^(white|#fff|#ffffff)$/i.test(colorStr)) return true;
  const m = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!m) return false;
  return parseInt(m[1]) > 200 && parseInt(m[2]) > 200 && parseInt(m[3]) > 200;
}

Three design decisions in this algorithm deserve explanation:

  1. Recursive querySelector vs. manual descent. querySelectorAll on the filter element descends into all shadow-host boundaries that are in the same tree, including deeply nested feComponentTransfer → feFuncA hierarchies. Manual stack-based descent is equivalent but more explicit. Either approach reaches the feFuncA level without requiring special handling.
  2. ID-based begin= parsing vs. full SMIL timing model. The SMIL timing model supports accesskey, wallclock, repeat, and event references without IDs. The algorithm above handles only the ID-event form (elementId.eventName) because that is the most common attack vector. A complete auditor should also handle 0s (immediate on load), click (without element ID — references the animation element's own parent), and indefinite.
  3. fill="freeze" as severity escalator. A fill="remove" animation creates an attack window equal to its duration. A fill="freeze" animation creates a permanent attack after the trigger. Both are dangerous, but the freeze variant converts a one-time interaction into a persistent page-state change — the consent element remains hidden for the lifetime of the page session.

Comparison: SMIL vs. JavaScript consent timing attacks

PropertyJavaScript timing attackSMIL timing attack
Attack mechanism Event listener calls setAttribute/style.property animate/set element with begin= trigger
Script required Yes — inline, external, or eval No — pure SVG markup
CSP resistance Blocked by script-src 'none' Not blocked by any CSP directive
getEventListeners() visible Yes (Chrome DevTools) No — not an event listener
Static attribute read reveals attack No — JS reads always show initial value No — getAttribute reads pre-animation value
MutationObserver detectable Yes — attribute changes trigger MutationObserver Yes — but only during animation; no JS source to attribute it to
Detection method Script analysis, event listener enumeration Recursive animate/set child enumeration + begin= inspection
Common in practice More common — requires scripting knowledge Less common — requires SVG animation knowledge; harder to detect

Remediation

ControlWhy it helps
For every <filter> applied to a consent text element, enumerate all animate, set, and animateTransform descendants at any depth; for each, check whether begin= references an interaction event on the consent form or any of its interactive children The only reliable way to detect SMIL timing attacks — static property reads will not find them. Enumeration must be recursive: animate elements can be grandchildren or deeper (filter → feComponentTransfer → feFuncA → set). The begin= attribute must be parsed for the ID.event syntax.
Treat any animate/set targeting flood-opacity, slope (feFuncA), amplitude (feFuncA), intercept, opacity, filter region x/y/width/height as requiring evaluation of the to value against the same attack-state criteria used for static filter analysis The attack lives in the to value, not in the initial attribute value. An attribute that is safe at load time can become an attack attribute at interaction time if a SMIL animation targets it with an attack-state value.
Disable SMIL animations on consent form elements by policy: add animationPlayState: paused via CSS to all elements within the consent subtree, or use a MutationObserver to detect and neutralize animate/set elements added after document parse Preventive control for consent forms that cannot be audited at scale: pausing all animations on the consent subtree prevents SMIL timing attacks without requiring per-filter analysis. MutationObserver coverage handles dynamic injection of SMIL animation elements after page load.
Apply a strict Content Security Policy with require-trusted-types-for 'script' and validate that any SVG injected into the DOM passes a Trusted Types policy that strips animate/set elements from filter subtrees Supply-chain control: prevents a third-party MCP SDK from injecting SMIL animation elements alongside its consent form. Trusted Types applies to innerHTML and similar DOM injection APIs, catching late-injected SMIL before it reaches the DOM.

SkillAudit performs recursive enumeration of animate, set, and animateTransform elements within SVG filter graphs applied to consent text, evaluates begin= triggers for interaction event references, and flags any animation whose to value produces an attack-state filter at the consent interaction moment. Run a free audit on any MCP server GitHub URL to detect SMIL consent timing attacks alongside the full filter primitive attack surface.

Related findings