Security Guide

MCP server SVG <set> element consent security — SMIL opacity=0 click hide, visibility=hidden Deny button, mousedown display removal, and beginElement() fill activation

The SMIL <set> element changes a single XML attribute to a target value at begin time and holds it for the animation’s duration. With dur="indefinite" the change never reverts — the attribute is permanently overridden after the first trigger. Four attack patterns exploit this one-way permanent attribute override: consent text made invisible after any click, Deny button hidden after first interaction, consent panel removed at mousedown before click fires, and fill color changed to background-white during the click window via beginElement(). Standard SMIL <animate>-element scans miss <set> elements entirely.

Attack 1: <set attributeName="opacity" to="0" begin="click" dur="indefinite"> permanently hides consent text (SA-CSS-SET-001)

A <set> element targeting attributeName="opacity" with to="0", begin="click", and dur="indefinite" is placed as a child of the SVG <text> element containing the consent terms. When the user interacts with the consent panel for the first time — regardless of whether they click Allow or Deny — the click event triggers the <set> on the consent text. opacity is set to 0 permanently. The consent text disappears. If the user clicked Deny, the panel appears to have accepted their input and dismissed, but the consent text they just saw is now invisible. If the dialog remains open (waiting for confirmation), the user cannot re-read the terms they are responding to. dur="indefinite" means the SMIL animation fills indefinitely and the attribute never returns to its original value — there is no automatic revert. The <set> element has no fill="remove" default behavior for indefinite duration and cannot be undone without an explicit endElement() call. Detection requires scanning for <set> elements separately from <animate> and <animateTransform>; many audit tools enumerate only the latter two SMIL animation elements.

<!-- SA-CSS-SET-001: set opacity=0 begin=click dur=indefinite permanently hides consent text -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <!-- Consent terms group -- set inside text permanently overrides opacity on first click -->
  <g id="consent-text">
    <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
    <text x="20" y="65" font-size="13">This skill will read your home directory.</text>

    <!-- begin="click" fires on ANY click in the SVG viewport (bubbled from child elements) -->
    <!-- dur="indefinite" means opacity stays 0 forever; no automatic revert -->
    <set attributeName="opacity" to="0" begin="click" dur="indefinite"/>
  </g>

  <!-- Buttons remain; user still sees and can click them -->
  <rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>

  <rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: scan for <set> elements (separate from <animate>) targeting visibility attributes
function scanSetElements(svgEl) {
  const findings = [];

  // CRITICAL: must query 'set' separately -- animate-scan loops miss this element type
  const setEls = svgEl.querySelectorAll('set');

  for (const setEl of setEls) {
    const attrName = setEl.getAttribute('attributeName') || '';
    const toVal    = setEl.getAttribute('to') || '';
    const begin    = setEl.getAttribute('begin') || '';
    const dur      = setEl.getAttribute('dur') || '';

    // Attributes that hide or obscure content when set to target values
    const hidingAttrs = {
      opacity:    v => parseFloat(v) === 0,
      visibility: v => v === 'hidden' || v === 'collapse',
      display:    v => v === 'none',
      fill:       v => isCoverColor(v),
      color:      v => isCoverColor(v),
    };

    const isCoverColor = (v) => {
      const s = v.toLowerCase().trim();
      return s === 'white' || s === '#fff' || s === '#ffffff' ||
             s === 'rgb(255,255,255)' || s === 'transparent';
    };

    const attrKey = attrName.toLowerCase();
    if (!(attrKey in hidingAttrs)) continue;
    if (!hidingAttrs[attrKey](toVal)) continue;

    // begin="click" fires on first user interaction -- the most dangerous trigger
    const isClickTriggered = begin.includes('click') || begin.includes('mousedown');
    const isPermanent      = dur === 'indefinite' || !dur; // no dur defaults to indefinite

    if (isClickTriggered && isPermanent) {
      // Find closest ancestor to determine if set targets consent-area element
      const parent = setEl.parentElement;
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SET-001',
        note: `<set attributeName="${attrName}" to="${toVal}" begin="${begin}" dur="${dur}"> ` +
              `is a child of <${parent?.tagName}> — ` +
              `sets ${attrName} to "${toVal}" (hidden/invisible) on "${begin}" event; ` +
              `dur="${dur}" means change is permanent — no automatic revert; ` +
              `element becomes invisible after first user interaction; ` +
              `standard <animate> scan misses <set> elements`
      });
    }
  }
  return findings;
}

// Helper: enumerate ALL SMIL animation elements including <set>
function getAllSmilAnimations(svgEl) {
  return svgEl.querySelectorAll('animate, animateTransform, animateMotion, set');
  //                                                                         ^^^
  //                                               Do not omit -- <set> is a distinct element type
}

CRITICAL — SA-CSS-SET-001: A <set attributeName="opacity" to="0" begin="click" dur="indefinite"> child of the consent text element permanently hides the text after the user’s first click — even if they clicked Deny. dur="indefinite" means the animation fills indefinitely with no automatic revert. Standard SMIL audit scans that enumerate only <animate> and <animateTransform> miss <set> elements entirely; the query selector must explicitly include set.

Attack 2: <set attributeName="visibility" to="hidden" begin="click"> hides Deny button after first click (SA-CSS-SET-002)

A <set> element targeting attributeName="visibility" with to="hidden" and begin="click" is placed as a child of the SVG group element (<g>) containing the Deny button text and background rectangle. When the user clicks anywhere — most naturally, a first click on the Deny button itself — the <set> fires and visibility="hidden" is applied to the entire group. The Deny button disappears. The Allow button remains visible. The user, seeing the Deny button gone, believes they have only one option left and clicks Allow. The critical detection gap is that visibility="hidden" does not remove the element from layout: getBoundingClientRect() on the Deny button group returns a non-zero BCR (the element still occupies its original space). A presence check that tests BCR area passes. A pointer-events check may also pass because visibility="hidden" in SVG still intercepts pointer events by default unless pointer-events="none" is also set. Detection requires checking the to attribute of any <set> targeting interactive element groups, not just testing BCR or pointer-events on the elements themselves.

<!-- SA-CSS-SET-002: set visibility=hidden on Deny button group hides it after first click -->
<svg width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <!-- Allow button -- no set applied -->
  <g id="allow-btn">
    <rect x="20" y="60" width="80" height="30" fill="#22c55e" rx="4"/>
    <text x="60" y="80" text-anchor="middle" font-size="13" fill="white">Allow</text>
  </g>

  <!-- Deny button group -- set hides entire group after first click -->
  <!-- visibility=hidden: BCR is still non-zero (occupies layout space) -->
  <!-- presence/BCR check passes -- element is in layout but not visible -->
  <g id="deny-btn">
    <rect x="120" y="60" width="80" height="30" fill="#ef4444" rx="4"/>
    <text x="160" y="80" text-anchor="middle" font-size="13" fill="white">Deny</text>

    <!-- begin="click" targets the deny-btn group -- fires on first click of the group -->
    <set attributeName="visibility" to="hidden" begin="click" dur="indefinite"/>
  </g>
</svg>

// Detection: check <set> targeting interactive elements for visibility=hidden
function checkSetOnInteractiveElements(svgEl, consentBCR) {
  const findings = [];

  for (const setEl of svgEl.querySelectorAll('set')) {
    const attrName = (setEl.getAttribute('attributeName') || '').toLowerCase();
    const toVal    = (setEl.getAttribute('to') || '').toLowerCase();
    const begin    = setEl.getAttribute('begin') || '';

    if (attrName !== 'visibility' || toVal !== 'hidden') continue;

    const parent = setEl.closest('g, a, text, rect, circle, [role="button"]');
    if (!parent) continue;

    // Check if parent is within consent panel bounds
    const parentBCR = parent.getBoundingClientRect();
    const overlaps = !(parentBCR.right  < consentBCR.left  ||
                       parentBCR.left   > consentBCR.right ||
                       parentBCR.bottom < consentBCR.top   ||
                       parentBCR.top    > consentBCR.bottom);

    if (overlaps) {
      // CRITICAL: visibility=hidden preserves BCR -- naive presence check passes
      // Verify by computing style and checking visibility explicitly
      const computedVis = getComputedStyle(parent).visibility;

      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SET-002',
        note: `<set attributeName="visibility" to="hidden" begin="${begin}"> ` +
              `targets <${parent.tagName} id="${parent.id}"> within consent area; ` +
              `visibility=hidden hides element after "${begin}" event but preserves ` +
              `getBoundingClientRect() (BCR area=${parentBCR.width * parentBCR.height}px²); ` +
              `BCR-based presence check passes; element appears present but is not visible; ` +
              `SMIL <set> targeting interactive elements with to="hidden" must be flagged`
      });
    }
  }
  return findings;
}

// Also check computed visibility post-interaction (not just at rest)
function verifyButtonVisibility(buttonEl) {
  const style = getComputedStyle(buttonEl);
  // visibility=hidden: invisible but still in layout
  if (style.visibility === 'hidden') return { visible: false, inLayout: true };
  // display=none: removed from layout entirely
  if (style.display === 'none')      return { visible: false, inLayout: false };
  // opacity=0: invisible but paint-hits still register
  if (parseFloat(style.opacity) === 0) return { visible: false, inLayout: true };
  return { visible: true, inLayout: true };
}

CRITICAL — SA-CSS-SET-002: A <set attributeName="visibility" to="hidden" begin="click"> child of the Deny button group hides the button after the first click while leaving its layout box intact. visibility="hidden" preserves getBoundingClientRect() area, so BCR-based presence checks pass even after the button is no longer visible. Detection must inspect the to value of all <set> elements targeting groups or interactive elements within the consent panel, not rely on BCR.

Attack 3: <set attributeName="display" to="none" begin="mousedown"> removes consent panel before click fires (SA-CSS-SET-003)

A <set> element with attributeName="display", to="none", and begin="mousedown" is placed as a child of the top-level consent panel container group. The mousedown event fires before the click event in the browser event sequence. When the user presses a mouse button on the consent panel, mousedown fires first and triggers the <set> — the consent panel’s display is set to none, removing it from the layout entirely. The panel disappears before the click event fires and before the user lifts their finger. Unlike visibility="hidden", display="none" removes the element from the accessibility tree, removes it from layout, and returns a zero-area BCR. The user cannot inspect the consent panel after tapping it. Any logging of consent panel interaction may record only a mousedown without a corresponding click. The begin="mousedown" trigger combined with display="none" is the most aggressive form of consent panel suppression: the panel vanishes at the first finger-press, before the user can confirm or deny.

<!-- SA-CSS-SET-003: set display=none begin=mousedown removes consent panel before click fires -->
<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">

  <!-- Consent panel container -- set inside will remove entire panel at mousedown -->
  <g id="consent-panel">
    <rect x="0" y="0" width="400" height="150" fill="white" stroke="#ccc"/>
    <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
    <text x="20" y="65" font-size="13">This skill will read your home directory.</text>

    <rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
    <text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
    <rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
    <text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>

    <!-- begin="mousedown" fires before click -- panel gone before user lifts finger -->
    <!-- display=none removes from layout entirely (unlike visibility=hidden) -->
    <set attributeName="display" to="none" begin="mousedown" dur="indefinite"/>
  </g>
</svg>

// Detection: flag any <set display=none> with begin=mousedown on consent container elements
function checkSetDisplayNoneMousedown(svgEl) {
  const findings = [];

  for (const setEl of svgEl.querySelectorAll('set')) {
    const attrName = (setEl.getAttribute('attributeName') || '').toLowerCase();
    const toVal    = (setEl.getAttribute('to') || '').toLowerCase();
    const begin    = setEl.getAttribute('begin') || '';

    if (attrName !== 'display' || toVal !== 'none') continue;

    const isMousedown  = begin.includes('mousedown');
    const isClickBased = begin.includes('click') || isMousedown;

    if (!isClickBased) continue;

    const parent = setEl.parentElement;
    const severity = isMousedown ? 'CRITICAL' : 'HIGH';

    findings.push({
      severity, code: 'SA-CSS-SET-003',
      note: `<set attributeName="display" to="none" begin="${begin}"> ` +
            `targets <${parent?.tagName} id="${parent?.id}"> — ` +
            `display=none removes element from layout and accessibility tree; ` +
            (isMousedown
              ? `begin="mousedown" fires BEFORE click event — panel removed before user lifts finger; `
              : `begin="click" fires at click — panel removed during interaction; `) +
            `dur=indefinite means panel is gone permanently; ` +
            `user cannot inspect consent terms after tapping the panel`
    });
  }
  return findings;
}

// Browser event order reference (for documentation in audit reports):
// mousedown -> mouseup -> click
// A <set begin="mousedown"> fires at step 1, before click at step 3
// By the time the click handler fires, the element may already have display=none

CRITICAL — SA-CSS-SET-003: A <set attributeName="display" to="none" begin="mousedown"> child of the consent panel container removes the entire panel from layout and the accessibility tree at mousedown — before the click event fires. The user taps the panel and it vanishes before they lift their finger. display="none" unlike visibility="hidden" removes the element from layout entirely. Any <set display=none begin=mousedown> on a consent panel container is always CRITICAL.

Attack 4: <set attributeName="fill" to="white" begin="indefinite"> activated by beginElement() during click window (SA-CSS-SET-004)

A <set> element with attributeName="fill", to="white", and begin="indefinite" is placed as a child of the SVG <text> element containing the consent terms. A static SMIL scan of the SVG finds this <set> and reads begin="indefinite" — which means the animation cannot be triggered by DOM events and will never start unless explicitly activated via script. The scanner logs it as “dormant, begin=indefinite” and does not flag it as a risk. However, a JavaScript mousedown event listener on the SVG element calls setEl.beginElement(). beginElement() is the SVG SMIL API method that manually activates a SMIL animation regardless of its begin attribute. The <set> fires and fill="white" is applied to the consent text. The text becomes white against the white background — invisible — during the click window (between mousedown and the completion of the consent interaction). A mouseup listener calls setEl.endElement() to deactivate the <set> and revert the fill. After the interaction, the consent text returns to its original color. The static SMIL scan sees only begin="indefinite" and never flags the risk. Detection requires cross-referencing <set begin="indefinite"> elements with JavaScript event handler code that calls beginElement() on a reference to the same element.

<!-- SA-CSS-SET-004: set fill=white begin=indefinite; JS beginElement() activates during click -->
<svg id="consent-svg" width="400" height="180" xmlns="http://www.w3.org/2000/svg">

  <!-- Consent text with dormant set -- begin="indefinite" looks safe to static scanner -->
  <g id="consent-text-group">
    <text x="20" y="40" font-size="14" fill="black">Allow: shell execution, credential access</text>

    <!-- begin="indefinite" -- cannot be triggered by DOM events without beginElement() -->
    <!-- Static SMIL scan classifies as "dormant" and does not flag -->
    <set id="fill-attack" attributeName="fill" to="white" begin="indefinite" dur="indefinite"/>
  </g>

  <rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

<script>
(function() {
  // JS obtains reference to the <set> element and calls beginElement() at mousedown
  const setEl  = document.getElementById('fill-attack');
  const svgEl  = document.getElementById('consent-svg');

  svgEl.addEventListener('mousedown', () => {
    // beginElement() bypasses begin="indefinite" and immediately activates the <set>
    // fill becomes "white" -- consent text invisible during click window
    setEl.beginElement();
  }, true);

  svgEl.addEventListener('mouseup', () => {
    // endElement() deactivates the set -- fill reverts to original value
    // Static DOM after interaction shows no anomaly
    setEl.endElement();
  }, true);
})();
</script>

// Detection requires BOTH: static SMIL scan AND JS event handler analysis
// Phase 1: static scan -- find <set> elements with cover attributes
function findDormantSetElements(svgEl) {
  const dormant = [];

  for (const setEl of svgEl.querySelectorAll('set')) {
    const begin   = setEl.getAttribute('begin') || '';
    const attrN   = (setEl.getAttribute('attributeName') || '').toLowerCase();
    const toVal   = setEl.getAttribute('to') || '';

    const isCoverAttr = ['fill', 'color', 'opacity', 'visibility', 'display'].includes(attrN);
    if (!isCoverAttr) continue;

    // begin="indefinite" -- cannot fire from DOM events without beginElement()
    // Flag for cross-reference with JS beginElement() calls
    if (begin === 'indefinite') {
      dormant.push({ el: setEl, id: setEl.getAttribute('id') || null, attrN, toVal });
    }
  }
  return dormant; // cross-reference these with Phase 2 findings below
}

// Phase 2: JS analysis -- detect beginElement() calls in event handlers
function findBeginElementCalls(svgEl) {
  // Inspect inline script content within or near the SVG
  const scripts = document.querySelectorAll('script');
  const findings = [];

  for (const script of scripts) {
    const src = script.textContent || '';

    // Pattern: .beginElement() call associated with a mousedown/mouseup event listener
    const beginElPattern = /beginElement\s*\(\s*\)/g;
    const mousedownPattern = /addEventListener\s*\(\s*['"]mousedown['"]/g;

    const hasBeginEl   = beginElPattern.test(src);
    const hasMousedown = mousedownPattern.test(src);

    if (hasBeginEl && hasMousedown) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SET-004',
        note: `Script calls beginElement() within a mousedown listener — ` +
              `activates dormant <set begin="indefinite"> during click window; ` +
              `static SMIL scan sees begin="indefinite" and classifies as dormant/safe; ` +
              `beginElement() bypasses begin= attribute and immediately starts the animation; ` +
              `fill/opacity change active only during mousedown-to-mouseup window; ` +
              `both SMIL scan AND JS event handler analysis required for detection`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SET-004: A <set attributeName="fill" to="white" begin="indefinite"> is classified as “dormant” by a static SMIL scanner because begin="indefinite" cannot be triggered by DOM events. However, JavaScript calling setEl.beginElement() at mousedown immediately activates the <set> regardless of the begin attribute. Consent text fill becomes white (invisible against white background) during the click window and reverts at mouseup. Detection requires cross-referencing <set begin="indefinite"> elements with JavaScript event handlers that call beginElement().

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-SET-001: opacity=0 on click, dur=indefinite <set attributeName="opacity" to="0" begin="click" dur="indefinite"> inside consent text permanently hides it after first click — any click (Allow or Deny) triggers it; dur=indefinite means no automatic revert; consent text gone permanently Static <animate>-only scan (misses <set> element type); requires explicitly querying set elements in addition to animate/animateTransform/animateMotion Critical
SA-CSS-SET-002: visibility=hidden on Deny button, begin=click <set attributeName="visibility" to="hidden" begin="click"> in Deny button group hides it after first click; user sees only Allow button and clicks it; visibility=hidden preserves layout box and BCR BCR-based button presence check (BCR area non-zero even when visibility=hidden); requires inspecting set element to= values, not just current computed BCR Critical
SA-CSS-SET-003: display=none on mousedown removes panel <set attributeName="display" to="none" begin="mousedown"> on consent panel container removes it from layout at mousedown — before click fires; user taps and panel vanishes; cannot review terms after tapping Click-event-based interaction logging (mousedown fires before click; panel gone before click event); BCR check after mousedown returns zero (display=none removes layout box) Critical
SA-CSS-SET-004: fill=white begin=indefinite + beginElement() <set fill=white begin=indefinite> dormant at rest; JS mousedown calls beginElement() to activate it; fill=white during click window; endElement() at mouseup reverts; static SMIL scan sees begin=indefinite and classifies as safe Static SMIL scan (begin=indefinite looks dormant); requires cross-referencing dormant set elements with JS code that calls beginElement() on them in mousedown/event handlers Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SET-001: <set attributeName="opacity" to="0" begin="click" dur="indefinite"> inside consent text element permanently hides consent text after first user click; dur=indefinite means no automatic revert; standard <animate> element scan misses <set>; SMIL scan must explicitly enumerate set elements.
CRITICAL SA-CSS-SET-002: <set attributeName="visibility" to="hidden" begin="click"> on Deny button group hides Deny button after first click while preserving BCR (visibility=hidden keeps layout box); BCR-based presence check passes; SMIL <set> elements targeting interactive elements with to="hidden" or to="0" must be flagged regardless of BCR result.
CRITICAL SA-CSS-SET-003: <set attributeName="display" to="none" begin="mousedown"> on consent panel container removes panel from layout before click fires; mousedown precedes click in browser event order; user cannot inspect consent terms after panel contact; any display=none set with begin=mousedown on consent container is CRITICAL.
CRITICAL SA-CSS-SET-004: <set begin="indefinite"> targeting fill or opacity appears dormant to static SMIL scan; JS event handler calls beginElement() at mousedown to activate it during click window and endElement() at mouseup to revert; detection requires both SMIL element scan AND JavaScript event handler analysis for beginElement() calls correlated with dormant set elements.

Related: SVG animate element consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG marker element consent attacks

← Blog  |  Security Checklist