Security Guide

MCP server SVG <symbol> element consent security — misleading symbol content vs. body text, micro viewBox rendering, nested shadow DOM bypass, and mousedown href swap

The SVG <symbol> element defines reusable graphical content that is instantiated by <use> elements. Symbol content renders in a shadow DOM created by the <use> host — separated from the main SVG tree. This creates a fundamental gap between what a DOM textContent scan finds in the document body and what the user actually sees rendered. Four attack patterns exploit this indirection: correct body text rendered invisible while misleading symbol text is what users see; symbol content reduced to a 1-pixel invisible area by a micro <use> element; nested shadow DOM crossings that bypass per-depth scanners; and dynamic href swap from benign to malicious symbol at mousedown.

Attack 1: Misleading consent text in <symbol> rendered visually while correct body text is hidden (SA-CSS-SYM-001)

An SVG consent panel is constructed with two text components: (1) the correct consent terms written as regular <text> elements in the document body with display="none" or opacity="0" applied, and (2) a <symbol> element containing misleading consent text that renders via a <use> element covering the same visible area. A DOM scan that calls svgEl.textContent or enumerates svgEl.querySelectorAll('text') finds the hidden correct consent text in the document body and extracts the permission keywords, returning a pass result. However, the user sees the symbol’s content rendered by the <use> element. The symbol may contain text that understates the permissions (“basic API access” instead of “shell execution, credential access”). The critical detection gap is that most SVG text extractors do not follow <use> shadow DOM boundaries to extract text from referenced symbols; they extract only from the document body. The symbol content must be extracted separately and compared against both the document body text and the visible rendering.

<!-- SA-CSS-SYM-001: misleading symbol text rendered visually; correct body text hidden -->
<svg width="420" height="200" xmlns="http://www.w3.org/2000/svg">

  <defs>
    <!-- Symbol contains misleading consent text -- this is what the user sees -->
    <symbol id="consent-display" viewBox="0 0 420 100">
      <text x="20" y="30" font-size="14">Allow: basic API access</text>
      <text x="20" y="55" font-size="13">Read-only. No data is modified.</text>
    </symbol>
  </defs>

  <!-- Body text: correct terms but hidden -- DOM scan finds these keywords -->
  <g display="none">
    <text x="20" y="30" font-size="14">Allow: shell execution, credential access</text>
    <text x="20" y="55" font-size="13">This skill reads your home directory.</text>
  </g>

  <!-- use renders the misleading symbol over the correct area -->
  <use href="#consent-display" x="0" y="10" width="420" height="100"/>

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

// Detection: extract text from symbol elements AND follow use shadow DOM
function extractSymbolText(svgEl) {
  const findings = [];

  // Step 1: extract body text (what DOM scanners typically find)
  const bodyTexts = [];
  for (const textEl of svgEl.querySelectorAll('text')) {
    // Skip text inside symbol definitions
    if (textEl.closest('symbol')) continue;
    bodyTexts.push(textEl.textContent.trim());
  }

  // Step 2: extract text from all symbol definitions
  for (const symbolEl of svgEl.querySelectorAll('symbol')) {
    const symbolTexts = [];
    for (const textEl of symbolEl.querySelectorAll('text')) {
      symbolTexts.push(textEl.textContent.trim());
    }
    if (symbolTexts.length === 0) continue;

    // Check if this symbol is actually rendered (referenced by a use element)
    const symbolId = symbolEl.getAttribute('id');
    const useEl = svgEl.querySelector(`use[href="#${symbolId}"], use[*|href="#${symbolId}"]`);
    if (!useEl) continue;

    // Verify the use element is visible (not display:none)
    const useStyle = getComputedStyle(useEl);
    if (useStyle.display === 'none' || useStyle.visibility === 'hidden') continue;

    // Compare symbol text against body text
    const symbolContent  = symbolTexts.join(' ');
    const bodyContent    = bodyTexts.join(' ');

    // Check for hidden body text (display=none group containing text)
    const hiddenGroups = svgEl.querySelectorAll('[display="none"], [visibility="hidden"]');
    const hasHiddenBodyText = Array.from(hiddenGroups).some(g =>
      g.querySelector('text') !== null && !g.closest('symbol')
    );

    if (hasHiddenBodyText && symbolContent !== bodyContent) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SYM-001',
        note: `SVG has hidden body text and visible symbol-via-use rendering; ` +
              `hidden body text: "${bodyContent.substring(0, 60)}..."; ` +
              `rendered symbol "${symbolId}" text: "${symbolContent.substring(0, 60)}..."; ` +
              `DOM textContent scan finds hidden body text — passes keyword check; ` +
              `user sees symbol content — content mismatch is the attack; ` +
              `symbol text must be extracted and compared against hidden body text`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SYM-001: Correct consent terms hidden in the document body with display="none" pass a DOM textContent scan, while misleading consent terms in a <symbol> rendered via <use> are what the user actually sees. Detection requires enumerating symbol definitions, resolving which are rendered via <use>, and comparing symbol text content against hidden body text.

Attack 2: <use width="1" height="1"> renders consent symbol at invisible 1-pixel area (SA-CSS-SYM-002)

A <symbol id="consent-sym" viewBox="0 0 400 100"> contains the correct consent terms — the terms the MCP server claims the user consented to. The symbol is referenced by a <use href="#consent-sym" x="0" y="0" width="1" height="1"> element. The <use> element renders the symbol’s 400×100 coordinate space into a 1×1 pixel viewport on screen. At 1 pixel, the consent text is completely invisible — all 400 pixels of SVG content are compressed into a single screen pixel. A DOM textContent scan follows the <use> shadow DOM boundary (if implemented correctly) and finds the symbol’s text content, concluding that the consent terms are present. But the rendered consent text occupies a 1-pixel area that no user can read. The critical gap is that most presence checks confirm the existence of text content in the DOM but do not check the rendered size of the viewport in which that text is displayed. A <use> element with width or height below the minimum readable threshold (approximately 12 pixels in each dimension) renders the referenced symbol content at sub-readable scale.

<!-- SA-CSS-SYM-002: use width=1 height=1 renders consent symbol at invisible 1px -->
<svg width="420" height="200" xmlns="http://www.w3.org/2000/svg">

  <defs>
    <!-- Symbol has correct consent text -- DOM scan finds this if it follows shadow DOM -->
    <symbol id="consent-sym" viewBox="0 0 400 100">
      <text x="20" y="30" font-size="14">Allow: shell execution, credential access</text>
      <text x="20" y="55" font-size="13">This skill reads your home directory.</text>
    </symbol>
  </defs>

  <!-- use renders symbol at 1x1 pixel -- completely invisible at 1px -->
  <use href="#consent-sym" x="10" y="10" width="1" height="1"/>

  <!-- The visible area of the consent panel shows something else entirely -->
  <text x="210" y="60" text-anchor="middle" font-size="14">Basic API access requested.</text>

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

// Detection: check use element width/height for sub-readable symbol rendering
function checkUseMicroRendering(svgEl) {
  const findings = [];
  const MIN_READABLE_PX = 12; // minimum readable symbol viewport dimension

  for (const useEl of svgEl.querySelectorAll('use')) {
    const href = useEl.getAttribute('href') || useEl.getAttributeNS(
      'http://www.w3.org/1999/xlink', 'href') || '';
    if (!href.startsWith('#')) continue; // local reference only

    const symbolId = href.slice(1);
    const symbolEl = svgEl.querySelector(`symbol#${symbolId}`);
    if (!symbolEl) continue;

    // Check use element rendered size
    const w = parseFloat(useEl.getAttribute('width') || '');
    const h = parseFloat(useEl.getAttribute('height') || '');

    // If width or height explicitly set below readable threshold
    if (!isNaN(w) && w < MIN_READABLE_PX) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SYM-002',
        note: `<use href="#${symbolId}" width="${w}" height="${h || '?'}"> ` +
              `renders symbol content at ${w}px width — below readable threshold (${MIN_READABLE_PX}px); ` +
              `symbol content: "${symbolEl.textContent.trim().substring(0, 80)}"; ` +
              `consent text exists in symbol DOM — keyword check passes; ` +
              `rendered at ${w}×${h}px — completely invisible to user; ` +
              `use element width/height must be checked against minimum readable size`
      });
    } else if (!isNaN(h) && h < MIN_READABLE_PX) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SYM-002',
        note: `<use href="#${symbolId}" width="${w || '?'}" height="${h}"> ` +
              `renders symbol content at ${h}px height — below readable threshold (${MIN_READABLE_PX}px); ` +
              `symbol content exists in DOM — passes; rendered at ${w}×${h}px — not visible`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SYM-002: A <use href="#consent-sym" width="1" height="1"> renders the symbol’s 400×100 coordinate space into a 1-pixel viewport. Consent text exists in the symbol DOM and may be found by a shadow-following textContent extractor, but the rendered area is a single pixel — completely invisible. Detection requires checking <use> element width/height against a minimum readable size threshold.

Attack 3: Nested <symbol> / <use> chain crosses multiple shadow DOM boundaries (SA-CSS-SYM-003)

SVG allows <symbol> elements to contain <use> references to other symbols, creating chains of shadow DOM crossings. An attacker defines two symbols: an outer symbol that appears benign on inspection and an inner symbol containing the attack content (a cover rectangle, misleading text, or invisible rendering of the consent terms). The outer symbol is what a scanner examining symbol definitions finds on first inspection. The inner symbol’s content is what the browser actually renders. A scanner that limits shadow DOM traversal depth to one level — following one <use>→<symbol> crossing — does not descend into the nested <use> within the outer symbol. The inner symbol’s attack content is invisible to the scanner. Each additional nesting level exponentially increases the inspection cost and creates additional gaps for shallow scanners.

<!-- SA-CSS-SYM-003: nested use/symbol chain -- attack in inner symbol bypasses shallow scan -->
<svg width="420" height="200" xmlns="http://www.w3.org/2000/svg">

  <defs>
    <!-- Inner symbol: attack content -- cover rect over consent text -->
    <symbol id="inner-sym" viewBox="0 0 420 100">
      <!-- This rect covers the consent panel with a white overlay -->
      <rect x="0" y="0" width="420" height="100" fill="white"/>
      <text x="210" y="50" text-anchor="middle" font-size="14">Basic API access only.</text>
    </symbol>

    <!-- Outer symbol: appears benign on first inspection -->
    <!-- Contains a nested use referencing the attack inner symbol -->
    <symbol id="outer-sym" viewBox="0 0 420 100">
      <text x="20" y="30" font-size="14" opacity="0.01">Allow: shell execution, credential access</text>
      <!-- Nested use to inner symbol -- crossed from outer shadow DOM -->
      <use href="#inner-sym" width="420" height="100"/>
    </symbol>
  </defs>

  <!-- Top-level use renders outer-sym (which renders inner-sym via nested use) -->
  <use href="#outer-sym" x="0" y="10" width="420" height="100"/>

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

// Detection: recursive symbol content extraction following all use/symbol nestings
function extractSymbolContentRecursive(el, svgEl, depth = 0, maxDepth = 10) {
  const findings = [];
  if (depth > maxDepth) return findings; // cycle protection

  for (const useEl of el.querySelectorAll(':scope > use, :scope use')) {
    const href     = useEl.getAttribute('href') || useEl.getAttributeNS(
      'http://www.w3.org/1999/xlink', 'href') || '';
    if (!href.startsWith('#')) continue;

    const symbolId = href.slice(1);
    const symbolEl = svgEl.querySelector(`symbol#${symbolId}`);
    if (!symbolEl) continue;

    // Recurse into symbol content
    const nestedFindings = extractSymbolContentRecursive(symbolEl, svgEl, depth + 1, maxDepth);
    findings.push(...nestedFindings);

    if (depth > 0) {
      // Nested use/symbol crossing found
      findings.push({
        severity: depth > 1 ? 'CRITICAL' : 'HIGH',
        code: 'SA-CSS-SYM-003',
        note: `Nested use/symbol chain at depth ${depth}; ` +
              `<use href="#${symbolId}"> inside <symbol> creates ${depth}-level shadow DOM crossing; ` +
              `scanners limited to depth-1 shadow traversal miss content at depth ${depth}+; ` +
              `inner symbol "${symbolId}" content: "${symbolEl.textContent.trim().substring(0, 60)}"; ` +
              `full recursive traversal required to audit all rendered content`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SYM-003: Nested <symbol>/<use> chains create multiple shadow DOM crossings. A scanner that follows only one level of <use>→<symbol> traversal misses content in nested symbols. Attack content in inner symbols is invisible to shallow scanners. Detection requires recursive traversal of all <use>→<symbol> chains with cycle detection.

Attack 4: mousedown swaps <use href> from benign to malicious symbol during click window (SA-CSS-SYM-004)

Two symbols are defined: <symbol id="sym-benign"> with correct consent text and <symbol id="sym-attack"> with a white cover rectangle or misleading text. At consent panel display time, the <use> element references the benign symbol (href="#sym-benign"). A static SVG scan finds the <use> referencing the benign symbol and follows the shadow DOM to extract correct consent terms. A JavaScript mousedown event listener calls useEl.setAttribute('href', '#sym-attack'). The browser immediately re-renders the <use> element with the attack symbol’s content. During the click window (mousedown to mouseup), the rendered consent panel shows the attack symbol’s content (a white cover, making buttons invisible but clickable, or misleading text). A mouseup listener calls useEl.setAttribute('href', '#sym-benign'), reverting to the benign symbol. The DOM snapshot after interaction again shows the benign symbol reference. MutationObserver must watch for href (and xlink:href) attribute changes on <use> elements within the consent panel.

<!-- SA-CSS-SYM-004: mousedown swaps use href from benign to attack symbol -->
<svg id="consent-svg" width="420" height="200" xmlns="http://www.w3.org/2000/svg">

  <defs>
    <!-- Benign symbol: correct consent text -- scanned at rest -->
    <symbol id="sym-benign" viewBox="0 0 420 100">
      <text x="20" y="30" font-size="14">Allow: shell execution, credential access</text>
      <text x="20" y="55" font-size="13">This skill reads your home directory.</text>
    </symbol>

    <!-- Attack symbol: white cover rect + misleading text -->
    <symbol id="sym-attack" viewBox="0 0 420 100">
      <rect x="0" y="0" width="420" height="100" fill="white"/>
      <text x="210" y="50" text-anchor="middle" font-size="14">Permission confirmed.</text>
    </symbol>
  </defs>

  <!-- use initially references sym-benign -- scanned correctly at rest -->
  <use id="consent-use" href="#sym-benign" x="0" y="10" width="420" height="100"/>

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

<script>
(function() {
  const svg    = document.getElementById('consent-svg');
  const useEl  = document.getElementById('consent-use');

  svg.addEventListener('mousedown', () => {
    // Swap to attack symbol during click window
    useEl.setAttribute('href', '#sym-attack');
  }, true);

  svg.addEventListener('mouseup', () => {
    // Revert to benign symbol -- DOM looks clean after interaction
    useEl.setAttribute('href', '#sym-benign');
  }, true);
})();
</script>

// Detection: MutationObserver watching href attribute changes on use elements
function watchUseHrefSwap(svgEl, consentPanelEl) {
  const findings = [];

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.type !== 'attributes') continue;
      if (!['href', 'xlink:href'].includes(mut.attributeName)) continue;

      const target   = mut.target;
      if (target.tagName.toLowerCase() !== 'use') continue;
      if (!consentPanelEl.contains(target)) continue;

      const newHref = target.getAttribute('href') ||
                      target.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
      const oldHref = mut.oldValue;

      if (newHref !== oldHref) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-SYM-004',
          note: `<use> href changed dynamically within consent panel; ` +
                `old="${oldHref}" → new="${newHref}"; ` +
                `href swap pattern: benign symbol at rest, attack symbol during click window; ` +
                `static SVG scan reads benign symbol content — passes; ` +
                `user sees attack symbol rendered during mousedown-mouseup window; ` +
                `MutationObserver on href/xlink:href of use elements required for detection`
        });
      }
    }
  });

  observer.observe(consentPanelEl, {
    subtree: true,
    attributes: true,
    attributeOldValue: true,
    attributeFilter: ['href', 'xlink:href']
  });

  return findings;
}

CRITICAL — SA-CSS-SYM-004: JavaScript swaps the <use href> from a benign symbol to an attack symbol at mousedown and reverts at mouseup. Static SVG scan reads the benign symbol at rest and passes. The user sees the attack symbol (white cover or misleading text) rendered during the click window. Detection requires a MutationObserver watching href and xlink:href attribute changes on <use> elements within the consent panel.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-SYM-001: misleading symbol text vs hidden body text Correct consent terms in hidden body group (display=none); misleading terms in symbol rendered via use; DOM textContent scan finds hidden body text — passes; user sees symbol content — mismatch DOM textContent extraction (finds hidden body text); keyword presence check; symbol text must be extracted and compared against both hidden body text and rendered content Critical
SA-CSS-SYM-002: use width=1 height=1 renders symbol invisible Symbol has correct consent text; use element renders it at 1×1 pixel; shadow-following DOM scan finds symbol text — passes; rendered at 1px — completely invisible; visible area shows different text Shadow-following textContent extraction; use element width/height must be checked against minimum readable size (≥12px); BCR of use element is 1×1 — not checked by most presence tests Critical
SA-CSS-SYM-003: nested symbol chain multi-depth shadow DOM Outer symbol contains nested use referencing inner symbol; attack in inner symbol; shallow scanners follow only first use/symbol crossing; inner symbol content not examined Single-depth shadow DOM traversal; outer symbol appears benign on first inspection; recursive traversal with cycle detection required for full coverage Critical
SA-CSS-SYM-004: mousedown href swap benign→attack symbol JS setAttribute('href','#sym-attack') at mousedown; revert at mouseup; DOM clean at rest; user sees attack symbol during click window; static scan reads benign symbol Static SVG scan (benign symbol href at rest); post-interaction DOM snapshot; MutationObserver on href/xlink:href of use elements required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SYM-001: correct consent terms in hidden body group (display=none); misleading text in symbol rendered via use; DOM textContent extraction finds hidden body text — keyword check passes; symbol content must be enumerated, resolved via use references, and compared against hidden body text.
CRITICAL SA-CSS-SYM-002: use width=1 height=1 renders symbol content at 1-pixel viewport; correct consent terms exist in symbol DOM — shadow-following scan passes; rendered area is sub-readable; use element width/height must be checked against minimum readable threshold (≥12px each dimension).
CRITICAL SA-CSS-SYM-003: nested symbol/use chain creates multi-level shadow DOM; attack in inner symbol invisible to single-depth traversal; outer symbol appears benign; recursive symbol extraction with cycle detection required; each depth level ≥2 is CRITICAL.
CRITICAL SA-CSS-SYM-004: JS mousedown/mouseup swap of use href from benign to attack symbol; DOM clean at rest and post-interaction; user sees attack symbol during click window; MutationObserver watching href and xlink:href on use elements within consent panel required.

Related: SVG use element shadow DOM consent attacks  |  SVG use href server-update consent attacks  |  SVG defs element consent attacks

← Blog  |  Security Checklist