MCP server CSS SVG animate filter security: interaction-triggered filter primitive switching, set-element alpha zeroing, animateTransform filter region relocation, and chained consent-phase visibility window

Published 2026-10-01 — SkillAudit Research

SVG SMIL animation elements — animate, set, and animateTransform — can change any animatable SVG attribute over time or in response to events. When these elements are children of SVG filter primitive elements, they can alter filter parameters at interaction time without any JavaScript. The key attack enabler is the begin attribute, which specifies when the animation starts. Event-based begin values such as "submitBtn.click", "submitBtn.mouseenter", "submitBtn.mousedown", or "consentCheckbox.focus" tie the animation trigger directly to the user's consent gesture.

At the moment the user performs the consent interaction, the filter primitive switches to its attack state. A DOM audit running at DOMContentLoaded reads only the initial attribute values — the safe state. The attack state only exists during and after the trigger event, which the static scan never observes. The fill="freeze" attribute on animate/set means the animation holds its final state after it completes (the attacked state persists through the form submission).

Static scan blind spot: All SVG animate/set timing attacks share the same detection gap: getAttribute() and getComputedStyle() at parse time read the initial (pre-animation) state. The attack value exists only in the animate element's to attribute, not in the filter primitive's current attribute value. Detection requires enumerating all animate/set children of filter primitive elements and evaluating their target attribute values and begin triggers.

SVG animation element primer

Three animation elements are relevant to filter primitive attacks:

Attack findings

CRITICAL
SVG animate transitions feFlood flood-color from "transparent" to "white" at button.click — consent text erased at form submission
A feFlood element with flood-color="rgba(0,0,0,0)" (transparent) is safe at load time. A child animate element transitions flood-color to "white" beginning at "submitBtn.click" with dur="0.001s" and fill="freeze". The flood color switches to opaque white at the instant the user clicks the submit button — the same instant the form submission is registered. The consent text is white-on-white at the moment of consent. The feFlood feeds a feComposite operator="over" that covers SourceGraphic. A static audit reads feFlood flood-color="rgba(0,0,0,0)" — transparent, no attack. The attack value "white" exists only in the animate child's to attribute.
<filter id="f1">
  <feFlood flood-color="rgba(0,0,0,0)" result="cover">
    <animate attributeName="flood-color"
      from="rgba(0,0,0,0)" to="white"
      begin="submitBtn.click" dur="0.001s" fill="freeze"/>
  </feFlood>
  <feComposite in="cover" in2="SourceGraphic" operator="over"/>
</filter>
CRITICAL
SVG set changes feFuncA slope from 1 to 0 at submitBtn.mouseenter — alpha zeroed before click completes
A feComponentTransfer with feFuncA type="linear" slope="1" (identity — no alpha change) is safe at load. A child <set> element on the feFuncA changes slope from "1" to "0" beginning at "submitBtn.mouseenter". When the user moves their cursor over the submit button — before completing the click — the feFuncA slope becomes 0, mapping all alpha to 0. The consent text element is fully transparent for the duration of the hover and click. After the click, the page navigates away; the user never sees the transparent phase directly but their consent click was performed on a transparent consent text. The set element is a child of feFuncA, which is a child of feComponentTransfer, which is a child of the filter — two levels of nesting below the filter; only recursive descent into all filter primitive child trees reveals this animate element.
<filter id="f1">
  <feComponentTransfer>
    <feFuncA type="linear" slope="1">
      <set attributeName="slope" to="0"
        begin="submitBtn.mouseenter" fill="freeze"/>
    </feFuncA>
  </feComponentTransfer>
</filter>
HIGH
animate changes filter region x/y/width/height attributes to reposition the filter over consent text at button activation
An SVG filter's x, y, width, height attributes define the filter region — the area over which the filter computation is applied. At load time, the filter region is set to x="-200%" y="-200%" width="10%" height="10%" — far outside the consent element, so no filter effect is visible. A child animate element on the filter element transitions x to "0%" at begin="submitBtn.mousedown", and another animate transitions y to "0%", width to "120%", and height to "120%". At mousedown, the filter region snaps over the consent text, activating the filter effect (e.g., a whitewashing feFlood). The consent text is erased between mousedown and mouseup — the precise moment of user consent. Static audit reads the initial x="-200%" — no effect.
MEDIUM
Chained animate create visibility window — consent text visible before and after interaction, invisible only during consent phase
Two chained animate elements create a precise attack window: the first animate sets flood-opacity to 1 (covering consent text) at begin="submitBtn.focus", dur="30s". The second animate sets flood-opacity back to 0 at begin="submitBtn.focus+30s". The consent text is erased for exactly 30 seconds starting when the checkbox or button receives focus — the window during which a user typically reads and clicks the consent. A user who reads the page before focusing the button sees the consent text. A user who focuses the button, then reads, sees nothing (flood-opacity=1, white overlay). After 30 seconds, the text reappears. This windowed attack is harder to detect than a simple freeze: the post-attack state is identical to the pre-attack state, so a delayed audit after consent might not see the attack.

Detection algorithm

function detectSVGAnimateFilterAttacks(consentEl) {
  const style = getComputedStyle(consentEl);
  const filterVal = style.filter;
  if (!filterVal || filterVal === 'none') return null;

  const filterId = filterVal.match(/url\(["']?#([^"')]+)["']?\)/)?.[1];
  if (!filterId) return null;

  const svgRoot = consentEl.closest('svg') || document;
  const filter = svgRoot.querySelector(`filter#${filterId}`);
  if (!filter) return null;

  const INTERACTION_TRIGGERS = /click|mouseenter|mousedown|mouseup|focus|activate/i;
  const ATTACK_ATTRIBUTES = new Set([
    'flood-color', 'flood-opacity', 'slope', 'intercept', 'tableValues',
    'amplitude', 'exponent', 'offset', 'stdDeviation', 'scale',
    'values', 'kernelMatrix', 'divisor', 'bias', 'in', 'in2', 'operator',
    'x', 'y', 'width', 'height', 'elevation', 'azimuth',
    'pointsAtX', 'pointsAtY', 'limitingConeAngle'
  ]);

  const findings = [];

  // Recursively scan all animate/set/animateTransform within the filter element
  const animElements = filter.querySelectorAll('animate, set, animateTransform');

  for (const anim of animElements) {
    const attrName = anim.getAttribute('attributeName') || '';
    const begin    = anim.getAttribute('begin') || '';
    const to       = anim.getAttribute('to') || '';
    const fill     = anim.getAttribute('fill') || 'remove';
    const dur      = anim.getAttribute('dur') || 'indefinite';

    if (!INTERACTION_TRIGGERS.test(begin)) continue;
    if (!ATTACK_ATTRIBUTES.has(attrName) && anim.tagName.toLowerCase() !== 'animatetransform') continue;

    const parentPrimitive = anim.parentElement;
    const primitiveName = parentPrimitive?.tagName?.toLowerCase() ?? 'unknown';

    // Classify severity based on attribute and target value
    let severity = 'medium';
    let issue = '';

    if (attrName === 'flood-color') {
      const toColor = parseColorOpacity(to);
      if (toColor.opacity > 0.8 && isNearWhite(to)) {
        severity = 'critical';
        issue = `feFlood flood-color animated to near-opaque white at "${begin}" — consent text covered at interaction`;
      } else if (toColor.opacity > 0.5) {
        severity = 'high';
        issue = `feFlood flood-color animated to ${to} (opacity ${toColor.opacity.toFixed(2)}) at "${begin}" — partial consent text coverage at interaction`;
      }
    } else if (attrName === 'slope' && primitiveName === 'ffunca') {
      const toSlope = parseFloat(to);
      if (toSlope < 0.05) {
        severity = 'critical';
        issue = `feFuncA slope animated to ${toSlope} at "${begin}" — alpha zeroed at interaction, consent text invisible`;
      }
    } else if (attrName === 'flood-opacity') {
      const toOp = parseFloat(to);
      if (toOp > 0.8) {
        severity = 'critical';
        issue = `feFlood flood-opacity animated to ${toOp} at "${begin}" — opaque flood activates at interaction`;
      }
    } else if (['x','y','width','height'].includes(attrName) && primitiveName === 'filter') {
      severity = 'high';
      issue = `filter region ${attrName} animated to "${to}" at "${begin}" — filter coverage area changes at interaction`;
    } else {
      issue = `filter primitive "${primitiveName}" attribute "${attrName}" animated to "${to}" at "${begin}"`;
    }

    if (issue) {
      findings.push({
        severity,
        el: anim,
        fill,
        begin,
        issue: issue + (fill === 'freeze' ? ' (fill=freeze: attack state persists)' : '')
      });
    }
  }

  return findings.length ? findings : null;
}

function parseColorOpacity(colorStr) {
  const rgba = colorStr.match(/rgba?\([\d.,\s]+\)/i);
  if (!rgba) return { opacity: colorStr === 'white' || colorStr === '#fff' ? 1 : 0.5 };
  const parts = rgba[0].replace(/rgba?\(|\)/g,'').split(',').map(Number);
  return { opacity: parts.length === 4 ? parts[3] : 1 };
}

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

Remediation

ControlHow it helps
For every filter applied to a consent text element, recursively enumerate all animate, set, and animateTransform children at any depth within the filter element; check each for a begin attribute containing an interaction event reference (click, mouseenter, mousedown, focus); evaluate the to value for attack-pattern attribute values Interaction-triggered timing attack — the attack state only exists after the begin trigger; getAttribute() and getComputedStyle() at parse time read the initial safe state; only animate element enumeration with begin inspection reveals the attack; recursive descent is required because animate elements can be nested two or more levels below the filter element
For animate/set elements with fill="freeze", treat the attack as persistent: the filter enters its attack state at the trigger and does not return to the safe state; for elements without fill="freeze" (or with fill="remove"), model the attack window as the animation duration; compare the window timing against the typical consent interaction duration (mousedown to mouseup: ~100–300ms; focus to submit: seconds to minutes) Attack window vs. interaction window — fill=freeze creates a permanent post-interaction attack state; fill=remove creates a windowed attack; both can cover the consent interaction moment; the windowed variant is harder to detect post-hoc because the filter reverts to normal after the window, but is equally dangerous during the consent phase
Treat any filter primitive attribute change triggered by user interaction on the consent form (checkboxes, submit buttons, input fields) as a security-relevant change regardless of the specific attribute name; apply the same analysis as a static filter check: evaluate the post-animation filter graph using the animated attribute values Unknown attribute targets — attackers can animate any filter primitive attribute that affects rendering; an allowlist of "dangerous" attributes will miss novel combinations; treating any interaction-triggered filter attribute change as requiring full post-animation filter graph evaluation provides type-agnostic coverage

SkillAudit performs recursive enumeration of animate/set/animateTransform elements within filter primitives, evaluates begin triggers for interaction events, and applies the full filter attack analysis to the animated target values — not just the initial attribute values. Run a free audit on any MCP server GitHub URL to detect SVG animate consent timing attacks across the full filter primitive attack surface.