Security Guide

MCP server SVG SMIL <animate> consent security — opacity flicker below Critical Flicker Frequency, fill color cycling, dx oscillation off-viewport, and animateTransform rotate spin

SVG SMIL <animate> and <animateTransform> elements drive declarative animations on SVG presentation attributes. Applied to consent text elements, they can create temporal attacks that make consent text unreadable through rapid property cycling rather than static obscuration. Unlike filter-based attacks, SMIL animations produce no static DOM value that a single-point-in-time scan can detect — the attack property (opacity, fill color, x-position, rotation angle) may be at a neutral value at the instant of audit but at an obscuring value during normal use. Four attack vectors exploit SMIL animation properties against MCP consent UI.

Attack 1: opacity flicker below Critical Flicker Frequency (SA-CSS-ANI-001)

The Critical Flicker Frequency (CFF) for human vision is approximately 60Hz at typical screen luminance — below this frequency, a flickering light source is perceived as separate flashes rather than a continuous stimulus. At 10Hz (a 0.1s period), alternating between opacity=1 and opacity=0 produces clear flicker visible as distinct on/off alternation. The consent text is nominally “visible” 50% of the time, but the rapid switching makes the text unreadable: the eye cannot resolve individual characters in the 50ms on-phase before the text disappears for another 50ms. A security audit that reads el.style.opacity or getComputedStyle(el).opacity at a static instant may find opacity = "1" (if sampled during the on-phase) or opacity = "0" (during the off-phase) — neither value reliably indicates the attack, and the presence of an <animate> element driving the opacity is not captured by CSSOM property reads.

<!-- SA-CSS-ANI-001: opacity flicker at 10Hz below CFF
     dur="0.1s" repeatCount="indefinite": 10 full cycles per second
     values="1;0;1": opacity oscillates fully transparent and fully opaque
     Text "visible" 50% of the time but unreadable (50ms per on-phase)
     Static audit: getComputedStyle(textEl).opacity returns instantaneous value, not animation range -->

<text x="20" y="50" font-size="14" fill="#111">
  This skill requests: shell execution, credential access, filesystem write
  <animate attributeName="opacity"
           values="1;0;1"
           dur="0.1s"
           repeatCount="indefinite"
           calcMode="discrete"/>
  <!-- calcMode="discrete": no interpolation — instant switch between values
       Even with calcMode="linear", 10Hz cycles are unreadable -->
</text>

// Detection: inspect SVG text elements for <animate> children
function detectAnimateOpacityFlicker(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    for (const anim of textEl.querySelectorAll('animate')) {
      const attr = anim.getAttribute('attributeName');
      if (attr !== 'opacity') continue;

      const dur = anim.getAttribute('dur') || '';
      const durSec = parseDuration(dur); // 'Xs' or 'Xms'
      if (durSec > 0 && durSec <= 0.2) { // 5Hz or faster
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-ANI-001',
          note: `SVG <text> has <animate attributeName="opacity" dur="${dur}"> — ${(1/durSec).toFixed(0)}Hz flicker; text unreadable due to temporal aliasing; getComputedStyle reads instantaneous value and misses animation`
        });
      }

      // Check for values that include 0 (fully transparent)
      const values = (anim.getAttribute('values') || '').split(';').map(Number);
      if (values.includes(0) || values.some(v => v < 0.1)) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-ANI-001',
          note: `SVG <text> animate opacity includes value 0 or near-0 — text fully transparent in animation cycle`
        });
      }
    }
  }
  return findings;
}

function parseDuration(s) {
  if (s.endsWith('ms')) return parseFloat(s) / 1000;
  if (s.endsWith('s')) return parseFloat(s);
  return 0;
}

CRITICAL — SA-CSS-ANI-001: An <animate attributeName="opacity"> on consent text with dur ≤ 0.1s (10Hz or faster) produces unreadable temporal flickering. Static getComputedStyle(el).opacity returns an instantaneous value that does not reveal the animation. Detection must check for <animate> child elements of SVG text elements and flag any attributeName="opacity" animation with a dur value ≤ 0.2s (5Hz or faster) as CRITICAL.

Attack 2: fill color cycling between text color and background color (SA-CSS-ANI-002)

The visual persistence of human vision (approximately 100ms at bright backgrounds) means that rapidly alternating between two colors creates a visual mixture — the perceived color is approximately the average of the two alternating values. An <animate> cycling fill between the consent text color (#111) and the consent panel background color (#fff) at 10Hz creates a perceived color of approximately #888 on a white background, yielding a WCAG contrast ratio of approximately 2.6:1 (below the 4.5:1 minimum). The text is technically “present” and never actually invisible (opacity remains 1), but the rapidly averaged color makes it effectively unreadable. Unlike the opacity flicker attack, a static getComputedStyle(el).fill snapshot returns either the text color or the background color depending on the instant of sampling — neither value alone indicates a problem.

<!-- SA-CSS-ANI-002: fill color cycling between text-color and background-color at 10Hz
     values="#111;#fff;#111": text color → background color → text color
     At 10Hz: visual persistence averages to ≈#888 (50% mid-grey on white)
     WCAG contrast #888 on #fff: 2.26:1 (below 4.5:1 minimum)
     Static audit reads either #111 (text color — looks fine) or #fff (bg color — looks suspicious)
     but static scan at any single instant does not reveal the cycling range -->

<text x="20" y="80" font-size="14">
  Requesting shell command execution and filesystem write permissions
  <animate attributeName="fill"
           values="#111111;#ffffff;#111111"
           dur="0.1s"
           repeatCount="indefinite"/>
</text>

// Detection: check for fill animations that cycle through background-matching values
function detectFillCycling(svgEl, consentEl) {
  const bgColor = getComputedStyle(consentEl).backgroundColor;
  const findings = [];

  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    for (const anim of textEl.querySelectorAll('animate')) {
      if (anim.getAttribute('attributeName') !== 'fill') continue;

      const values = (anim.getAttribute('values') || '').split(';').map(s => s.trim());
      const dur = parseDuration(anim.getAttribute('dur') || '');

      // Check if any value is background-color-matching
      const hasBgMatchingValue = values.some(v => colorsMatch(v, bgColor) || v === 'white' || v === '#fff' || v === '#ffffff');

      if (hasBgMatchingValue && dur <= 0.2) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-ANI-002',
          note: `SVG text <animate attributeName="fill" values="${values.join(';')}" dur="${anim.getAttribute('dur')}"> — fill cycles through background-matching color at ${(1/dur).toFixed(0)}Hz; visual persistence averages to low-contrast perceived color`
        });
      } else if (hasBgMatchingValue) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-ANI-002',
          note: `SVG text fill animation cycles through background-matching color "${values.find(v => colorsMatch(v, bgColor))}" — low-contrast during bg-color phase`
        });
      }
    }
  }
  return findings;
}

function colorsMatch(a, b) {
  return a.replace(/\s/g,'').toLowerCase() === b.replace(/\s/g,'').toLowerCase();
}

CRITICAL — SA-CSS-ANI-002: An <animate attributeName="fill"> cycling between the consent text color and the consent panel background color at 10Hz renders the consent text as a low-contrast grey in visual perception. No static CSSOM property reveals the animation range. Detection must inspect <animate> children of SVG text elements for attributeName="fill", parse the values attribute into its color stops, and flag any animation whose values include a background-matching color.

Attack 3: dx oscillation moving text 500px off-viewport (SA-CSS-ANI-003)

The SVG dx presentation attribute on a <text> element applies an x-axis displacement to the text rendering position. An <animate> cycling dx between 0 and 500 (then back to 0) over 0.5s moves the consent text 500px to the right and back. For a typical 400px-wide consent panel, the text exits the right edge at dx=100 and is completely off-screen from dx=100 to dx=500. The text is visible for approximately 0.1s per 0.5s cycle (20% of the time) and off-screen for 0.4s. Even during the visible phase, the rapid motion (from 0 to 500px in 0.25s — 2000px/s) makes individual characters impossible to read. Unlike filter-based offset attacks (feOffset), the dx animation moves the text within the normal SVG layout: getBoundingClientRect() on the text element returns the original position, but the text is rendered at x + dx_current.

<!-- SA-CSS-ANI-003: dx oscillation off-viewport
     values="0;500;0" dur="0.5s": text moves from x=20 to x=520 (500px off-screen) and back
     For 400px SVG: text off-screen from dx=380 to dx=500 (60% of travel is off-screen)
     Visible for ~0.1s per cycle, but motion velocity = 2000px/s — unreadable during motion
     getBoundingClientRect on text element returns original x position (pre-dx) -->

<text x="20" y="50" font-size="14" fill="#111">
  Requesting: shell execution, credential access
  <animate attributeName="dx"
           values="0;500;0"
           dur="0.5s"
           repeatCount="indefinite"
           calcMode="linear"/>
</text>

// Detection: check for dx or dy animations on text elements
function detectTextMotionAnimation(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    for (const anim of textEl.querySelectorAll('animate')) {
      const attr = anim.getAttribute('attributeName');
      if (!['dx', 'dy', 'x', 'y'].includes(attr)) continue;

      const values = (anim.getAttribute('values') || '').split(';').map(Number).filter(n => !isNaN(n));
      const maxDisplacement = Math.max(...values.map(Math.abs));
      const dur = parseDuration(anim.getAttribute('dur') || '');

      if (maxDisplacement > 100) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-ANI-003',
          note: `SVG text <animate attributeName="${attr}" values="${anim.getAttribute('values')}"> — max displacement ${maxDisplacement}px; text moves off-viewport during animation cycle; velocity ${dur > 0 ? (maxDisplacement/dur).toFixed(0) : '?'}px/s makes text unreadable during motion`
        });
      } else if (maxDisplacement > 20) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-ANI-003',
          note: `SVG text animate ${attr} with displacement ${maxDisplacement}px — may move text partially off-viewport`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-ANI-003: An <animate attributeName="dx"> (or dy, x, y) with a maximum displacement value exceeding the width of the consent panel moves the consent text off-screen during part of the animation cycle. At typical animation speeds, text that is technically visible for a fraction of each cycle is unreadable due to rapid horizontal motion. Detection must parse the values attribute and flag any text motion animation with a maximum displacement over 100px as CRITICAL.

Attack 4: animateTransform type="rotate" spinning consent text (SA-CSS-ANI-004)

An <animateTransform> element with type="rotate" applies a rotation transform to the parent text element. At dur="0.3s" with from="0" and to="360", the text completes approximately 3.3 full rotations per second. At this rotation speed, individual characters are smeared across the rotation arc and the glyph forms cannot be resolved by the human visual system. The text element remains in the DOM with its full textContent; getBoundingClientRect() returns the bounding box of the text at its current rotation angle (which changes every frame). Static audits read no suspicious opacity, fill, or displacement values — only the <animateTransform> child reveals the attack.

<!-- SA-CSS-ANI-004: animateTransform rotate — text spins 3.3 rev/sec
     from="0" to="360" dur="0.3s": 360deg rotation in 300ms = 3.33 rev/sec
     At 3.33 rev/sec, character glyph positions change 12° every 10ms (one frame at 100fps)
     Text appears as a smeared ring — individual glyphs not resolvable
     No suspicious opacity or fill value — only animateTransform child reveals attack -->

<text x="200" y="150" text-anchor="middle" font-size="14" fill="#111">
  Shell execution and credential access
  <animateTransform attributeName="transform"
                    type="rotate"
                    from="0 200 150"
                    to="360 200 150"
                    dur="0.3s"
                    repeatCount="indefinite"/>
</text>

// Detection: check for animateTransform on text elements
function detectRotationAnimation(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    for (const anim of textEl.querySelectorAll('animateTransform')) {
      if (anim.getAttribute('type') !== 'rotate') continue;

      const dur = parseDuration(anim.getAttribute('dur') || '');
      const from = parseFloat(anim.getAttribute('from') || '0');
      const to   = parseFloat(anim.getAttribute('to') || '0');
      const range = Math.abs(to - from);

      if (dur > 0 && range >= 180) {
        const revsPerSec = (range / 360) / dur;
        if (revsPerSec > 0.5) {
          findings.push({
            severity: 'CRITICAL', code: 'SA-CSS-ANI-004',
            note: `SVG text has animateTransform rotate from=${from} to=${to} dur="${anim.getAttribute('dur')}" — ${revsPerSec.toFixed(1)} rev/sec; text rotation makes glyphs unreadable; no static opacity/fill value detectable`
          });
        } else {
          findings.push({
            severity: 'HIGH', code: 'SA-CSS-ANI-004',
            note: `SVG text has animateTransform rotate — ${revsPerSec.toFixed(1)} rev/sec; may affect readability`
          });
        }
      }
    }

    // Also check for scale-down transforms (making text tiny)
    for (const anim of textEl.querySelectorAll('animateTransform[type="scale"]')) {
      const values = (anim.getAttribute('values') || '').split(';').map(Number);
      if (values.some(v => v < 0.05)) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-ANI-004',
          note: `SVG text animateTransform scale includes value ${Math.min(...values)} — text scaled to near-zero size during animation cycle`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-ANI-004: An <animateTransform type="rotate"> at 0.5 revolutions per second or faster renders consent text as an unresolvable smeared arc. No static CSS property value reveals the animation. Detection must check for <animateTransform> children of SVG text elements with type="rotate", compute the angular velocity from from, to, and dur attributes, and flag any rotation at ≥0.5 rev/sec as CRITICAL.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-ANI-001: opacity flicker below CFF animate attributeName=opacity values=1;0;1 dur=0.1s — 10Hz flicker; CFF≈60Hz; text unreadable at 10Hz; visual persistence cannot resolve 50ms on-phases; static getComputedStyle reads instantaneous value (1 or 0) not animation range; calcMode=discrete makes switching instant All consent text (temporal — text invisible 50% of time; unreadable 100% of time); textContent present; no static opacity=0 value at audit time if sampled during on-phase Critical
SA-CSS-ANI-002: fill color cycling background-matching animate attributeName=fill values=text-color;background-color at 10Hz — visual persistence averages to mid-grey; WCAG contrast ≈2.3:1; no static opacity=0; fill value at scan time is either text color (looks fine) or bg color (suspicious) — cycling range not captured by CSSOM All consent text (temporal averaged to low contrast); text nominally "visible" at all times; no filter or opacity manipulation; only fill animation inspection detects Critical
SA-CSS-ANI-003: dx oscillation off-viewport animate attributeName=dx values=0;500;0 dur=0.5s — text moves 500px right and back per 0.5s; off-screen 80% of cycle for 400px panel; 2000px/s velocity makes visible phase unreadable; getBoundingClientRect returns pre-dx layout position All consent text (temporal — off-screen 80% of time; motion-blurred during visible phase); text node present at original x position; dx attribute not captured by BCR or getComputedStyle Critical
SA-CSS-ANI-004: animateTransform rotate spin animateTransform type=rotate from=0 to=360 dur=0.3s — 3.3 rev/sec; glyph forms smear across rotation arc; no static opacity/fill/displacement; animateTransform child on text element is only indicator; scale variant also possible (sub-0.05 scale) All consent text (temporal — smeared rotation arc; glyphs not resolvable); textContent intact; BCR changes per frame (reports current rotation angle bounding box) Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-ANI-001: SVG text <animate attributeName="opacity"> with dur≤0.1s — 10Hz flicker below CFF; text unreadable; getComputedStyle reads instantaneous value not animation range; <animate> child inspection required.
CRITICAL SA-CSS-ANI-002: SVG text <animate attributeName="fill"> cycling background-color at 10Hz — visual persistence averages to low-contrast grey; fill animation values inspection with background-color comparison required.
CRITICAL SA-CSS-ANI-003: SVG text <animate attributeName="dx"> with max displacement 500px — text off-viewport 80% of cycle; motion velocity 2000px/s makes visible phase unreadable; dx attribute values inspection required.
CRITICAL SA-CSS-ANI-004: SVG text <animateTransform type="rotate"> at 3.3 rev/sec — glyph forms smear into unresolvable arc; animateTransform child inspection with angular velocity computation required; no static CSSOM value indicates attack.

Related: SVG viewBox consent attacks  |  SVG text spacing consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist