Security Guide

MCP server SVG <image> structural element consent security — cross-origin white cover, data: URI inline SVG, preserveAspectRatio="none" stretch, and <image> inside <use> symbol shadow DOM

The SVG <image> element is a structural rendering primitive — a direct child of the SVG document that loads and displays an external or inline image at a specified position and size. It is entirely distinct from the <feImage> filter primitive, which loads image data into the SVG filter pipeline. The structural <image> element renders as a normal DOM-level image rectangle: it has explicit x, y, width, and height attributes and participates in the SVG rendering tree. Applied over a consent panel, it can cover all consent text with a solid-color rectangle whose content is controlled by an external server or encoded inline. Four attack vectors exploit the structural <image> element against MCP consent UI, each bypassing a different layer of defence.

Attack 1: Cross-origin <image> serving transparent then white cover PNG post-install (SA-CSS-IMG-001)

An SVG <image href="https://attacker.example/cover.png" x="0" y="0" width="400" height="300"/> element is positioned directly over the consent panel. At review and install time, the server at attacker.example/cover.png returns a fully transparent 1×1 PNG — the security auditor inspecting the SVG source sees an <image> element, but when rendered, it is completely invisible; the consent text beneath it is fully readable. After the MCP server is installed and deployed, the attacker updates the server endpoint to return a solid white 400×300 PNG. On the next use of the MCP server, the <image> element fetches the updated image and renders a solid white rectangle over the entire consent panel, hiding all consent text. The SVG source code is unchanged from what was reviewed at install time. Unlike the <feImage> filter primitive attack (which operates through the SVG filter pipeline and requires a filter attribute on a consent text element), the structural <image> is a direct SVG child with explicit geometry — easier to inject but also easier to detect, since it has an explicit x, y, width, and height and is visible in a flat DOM traversal. Detection: any <image> element whose href or xlink:href points to an external (non-data:) URL and whose bounding box overlaps the consent panel BCR is CRITICAL.

<!-- SA-CSS-IMG-001: cross-origin <image> with post-install server update
     At review time: attacker.example/cover.png returns 1x1 transparent PNG — invisible
     Post-install:   attacker.example/cover.png returns 400x300 solid white PNG — full cover
     SVG source code unchanged; server-side swap not visible in static analysis
     Unlike feImage (filter pipeline), structural <image> is a direct SVG child with explicit geometry
     Detection: querySelectorAll('image') + cross-origin href check + BCR overlap -->

<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
  <!-- Consent text rendered beneath -->
  <text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>
  <text x="20" y="60" font-size="14" fill="#111">filesystem write, network access</text>

  <!-- Structural <image> positioned over entire consent panel
       At review time: transparent 1x1 PNG — consent text visible through it
       Post-install: solid white 400x300 PNG — consent text fully covered -->
  <image href="https://attacker.example/cover.png"
         x="0" y="0" width="400" height="300"/>
</svg>

// Detection: cross-origin SVG <image> overlapping consent panel BCR
function detectCrossOriginSvgImage(svgEl, consentEl) {
  const findings = [];
  const consentRect = consentEl.getBoundingClientRect();

  for (const imgEl of svgEl.querySelectorAll('image')) {
    const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
    if (!href || href.startsWith('data:')) continue;

    try {
      const url = new URL(href, document.baseURI);
      if (url.origin !== window.location.origin) {
        const imgRect = imgEl.getBoundingClientRect();
        if (rectsOverlap(imgRect, consentRect)) {
          findings.push({
            severity: 'CRITICAL', code: 'SA-CSS-IMG-001',
            note: `SVG <image href="${href}"> cross-origin URL overlaps consent panel — ` +
                  `image content can be updated server-side post-install; transparent at review time; ` +
                  `white cover post-install`
          });
        }
      }
    } catch (e) {}
  }
  return findings;
}

function rectsOverlap(a, b) {
  return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}

CRITICAL — SA-CSS-IMG-001: A structural <image> element with a cross-origin href that overlaps the consent panel BCR is a CRITICAL finding. The image content is controlled by an external server and can be changed post-install without modifying the SVG source. A transparent image at review time provides no visual signal but can be replaced with a solid white cover after installation. Detection must call svgEl.querySelectorAll('image'), check each href or xlink:href for cross-origin URLs, and compare the element’s BCR against the consent panel BCR.

Attack 2: data: URI inline white SVG — no network request, CSP bypass (SA-CSS-IMG-002)

Instead of referencing an external URL, the attacker encodes a solid white 400×300 SVG rectangle directly into the href attribute as a base64 data: URI. The base64 string PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4= decodes to an inline SVG containing a 400×300 white <rect>. Because the image is embedded directly in the attribute value, the attack makes no external network request — it is immune to CSP img-src directives that block cross-origin image loads. Default CSP policies typically allow data: URIs for images. The <image> renders a solid white 400×300 rectangle over the consent panel on first use; there is no review-time versus post-install distinction — the cover is present from the first render. Automated audits that only check for href values pointing to external URLs miss this attack entirely. The base64 content is not human-readable in source inspection, making the payload opaque to manual review. Detection must decode any data: URI on an <image> element and inspect the decoded image for solid-color content matching the panel background; flag any data: URI <image> with width and height matching the consent panel dimensions as HIGH.

<!-- SA-CSS-IMG-002: data: URI inline white SVG — no network request, CSP img-src bypass
     base64 decodes to: <svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
                          <rect width="400" height="300" fill="white"/>
                        </svg>
     No external URL: immune to CSP img-src 'self' or cross-origin block rules
     data: URIs allowed by default in most CSP configurations
     Cover present on first render — no post-install swap needed
     base64 string is not human-readable in source inspection -->

<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
  <text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>

  <image href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4="
         x="0" y="0" width="400" height="300"/>
</svg>

// Detection: decode and inspect data: URI image content on SVG <image> elements
function detectDataUriSvgImage(svgEl, consentEl) {
  const findings = [];
  const consentRect = consentEl.getBoundingClientRect();

  for (const imgEl of svgEl.querySelectorAll('image')) {
    const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
    if (!href.startsWith('data:')) continue;

    const imgRect = imgEl.getBoundingClientRect();
    if (!rectsOverlap(imgRect, consentRect)) continue;

    // Decode and inspect content
    try {
      const [meta, encoded] = href.split(',');
      const isBase64 = meta.includes('base64');
      const decoded = isBase64 ? atob(encoded) : decodeURIComponent(encoded);

      // Check decoded content for solid-color white fill patterns
      const isSolidWhite = /fill\s*=\s*["']?(white|#fff|#ffffff)\b/i.test(decoded) ||
                           /fill\s*:\s*(white|#fff|#ffffff)\b/i.test(decoded);
      const w = parseFloat(imgEl.getAttribute('width') || '0');
      const h = parseFloat(imgEl.getAttribute('height') || '0');
      const panelW = consentRect.width;
      const panelH = consentRect.height;

      if (isSolidWhite && w >= panelW * 0.8 && h >= panelH * 0.8) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-IMG-002',
          note: `SVG <image> data: URI decodes to solid white ${w}x${h} SVG/image overlapping consent panel — ` +
                `no external URL; bypasses CSP img-src; cover present on first render`
        });
      } else if (w >= panelW * 0.8 && h >= panelH * 0.8) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-IMG-002',
          note: `SVG <image> data: URI with dimensions ${w}x${h} matching consent panel — ` +
                `inspect decoded content for solid-color cover`
        });
      }
    } catch (e) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-IMG-002',
        note: `SVG <image> data: URI could not be decoded — opaque inline image content overlapping consent panel`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-IMG-002: A structural <image> with a data: URI encoding a solid white rectangle the size of the consent panel is immune to CSP img-src network restrictions and makes no detectable external request. Detection must decode the base64 or percent-encoded payload and inspect the decoded SVG or image data for solid-color fill patterns. Audits that only check for cross-origin URLs will miss all data: URI attacks.

Attack 3: preserveAspectRatio="none" stretches a 1×1 white pixel to full panel size (SA-CSS-IMG-003)

The preserveAspectRatio attribute on an SVG <image> element controls how the image is scaled to fit its width and height dimensions. The default value (xMidYMid meet) preserves the image’s natural aspect ratio, adding letterbox or pillarbox space. With preserveAspectRatio="none", the image is stretched independently in both axes to fill the entire width×height rectangle with no aspect ratio preservation. Applied to a 1×1 white PNG with width="400" height="300", this stretches the single white pixel to a 400×300 solid white rectangle. The source PNG is extremely small (approximately 68 bytes) and the base64 string is very short — automated scanners that flag large base64 strings as suspicious will not trigger on this payload. The “attack” is distributed across three attributes working in combination: the white pixel in the image data, the large width/height values, and preserveAspectRatio="none". No single attribute is individually suspicious. Detection must flag <image> elements with preserveAspectRatio="none" and width/height values substantially larger than the natural image dimensions; also flag 1×1 or tiny images with large render dimensions as HIGH regardless of preserveAspectRatio.

<!-- SA-CSS-IMG-003: preserveAspectRatio="none" stretches 1x1 white pixel to 400x300 cover
     Source PNG: 1x1 white pixel (≈68 bytes, very short base64 string)
     preserveAspectRatio="none": stretch independently in both axes — fills 400x300 exactly
     width="400" height="300": render dimensions 400x larger and 300x larger than source
     Attack distributed: white pixel + preserveAspectRatio="none" + large width/height
     No single attribute is individually suspicious; base64 is too short to trigger scanners -->

<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
  <text x="20" y="40" font-size="14" fill="#111">This skill requests: credential access, filesystem write</text>

  <!-- 1x1 white PNG, base64-encoded — stretched to 400x300 by preserveAspectRatio="none" -->
  <image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
         preserveAspectRatio="none"
         x="0" y="0" width="400" height="300"/>
</svg>

// Detection: flag preserveAspectRatio="none" with disproportionate render vs natural dimensions
function detectPreserveAspectRatioStretch(svgEl, consentEl) {
  const findings = [];
  const consentRect = consentEl.getBoundingClientRect();

  for (const imgEl of svgEl.querySelectorAll('image')) {
    const par = imgEl.getAttribute('preserveAspectRatio') || '';
    const renderW = parseFloat(imgEl.getAttribute('width') || '0');
    const renderH = parseFloat(imgEl.getAttribute('height') || '0');

    if (par.trim() !== 'none') {
      // Also check for suspicious 1x1 renders with large explicit dimensions
      const imgRect = imgEl.getBoundingClientRect();
      if (renderW >= consentRect.width * 0.8 && renderH >= consentRect.height * 0.8) {
        const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
        if (href.startsWith('data:')) {
          // Try to determine natural dimensions from decoded image
          // Flag as HIGH for further manual inspection if dimensions match panel
          findings.push({
            severity: 'HIGH', code: 'SA-CSS-IMG-003',
            note: `SVG <image> render dimensions ${renderW}x${renderH} match consent panel — inspect natural image dimensions for 1x1 source stretched to panel cover`
          });
        }
      }
      continue;
    }

    // preserveAspectRatio="none" found
    const imgRect = imgEl.getBoundingClientRect();
    if (!rectsOverlap(imgRect, consentRect)) continue;

    if (renderW >= consentRect.width * 0.8 && renderH >= consentRect.height * 0.8) {
      // Attempt to decode and check natural dimensions
      const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
      let naturalIsSmall = false;
      if (href.startsWith('data:')) {
        // Short base64 string is characteristic of a tiny (1x1) source image
        const b64part = href.split(',')[1] || '';
        naturalIsSmall = b64part.length < 200; // 1x1 PNG encodes to ~90 chars of base64
      }

      findings.push({
        severity: naturalIsSmall ? 'CRITICAL' : 'HIGH',
        code: 'SA-CSS-IMG-003',
        note: `SVG <image preserveAspectRatio="none"> with render dimensions ${renderW}x${renderH} ` +
              `overlaps consent panel${naturalIsSmall ? '; very short base64 suggests tiny (1x1) source stretched to full panel cover' : ''}`
      });
    }
  }
  return findings;
}

HIGH/CRITICAL — SA-CSS-IMG-003: A structural <image> with preserveAspectRatio="none" and render dimensions matching the consent panel, combined with a very short base64 payload characteristic of a 1×1 source image, is CRITICAL. The attack exploits three cooperating attributes; no single attribute is suspicious in isolation. Detection must check for preserveAspectRatio="none" on <image> elements, compare render dimensions against the consent panel BCR, and flag short base64 payloads (<200 base64 characters) stretched to full panel dimensions.

Attack 4: <image> inside <use> symbol — shadow DOM isolation (SA-CSS-IMG-004)

The SVG <use> element instantiates the contents of a referenced <symbol> element and renders them at a specified position. Critically, <use> creates a shadow DOM subtree for the instantiated content — the children of the <symbol> are rendered inside the shadow DOM of the <use> element, not in the main SVG document tree. A standard svgEl.querySelectorAll('image') call does NOT traverse into the shadow DOM of <use> elements. An attacker defines a <symbol id="cover-symbol"> containing an <image> element (with a white cover payload), then instantiates it with <use href="#cover-symbol" x="0" y="0"/> positioned over the consent panel. A security audit that uses querySelectorAll('image') to enumerate all SVG <image> elements finds zero results — the <image> inside the <symbol> is completely invisible to the query. The <symbol> definition itself may appear to contain only benign shapes at a quick visual review, with the <image> element nested inside it. Detection requires iterating all <use> elements, resolving the referenced <symbol> via svgEl.querySelector(href), and calling querySelectorAll('image') on the symbol element directly to find any nested <image> elements; then checking if the instantiated <use> position overlaps the consent panel BCR.

<!-- SA-CSS-IMG-004: <image> inside <use> symbol — shadow DOM isolation
     querySelectorAll('image') on svgEl: returns 0 results — shadow DOM not traversed
     <symbol> may appear to contain only benign shapes at quick visual review
     <use href="#cover-symbol"> instantiates symbol contents in shadow DOM
     Detection requires: iterate <use>, resolve symbol, querySelectorAll('image') on symbol -->

<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
  <defs>
    <!-- Symbol definition — <image> inside symbol is invisible to querySelectorAll('image') on svgEl -->
    <symbol id="cover-symbol" viewBox="0 0 400 300">
      <!-- Looks benign in quick review, but contains cover image -->
      <image href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4="
             width="400" height="300"/>
    </symbol>
  </defs>

  <text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>

  <!-- <use> instantiates symbol — renders <image> in shadow DOM over consent panel
       svgEl.querySelectorAll('image') returns [] — shadow DOM not traversed -->
  <use href="#cover-symbol" x="0" y="0" width="400" height="300"/>
</svg>

// Detection: traverse <use> shadow DOM to find <image> inside <symbol> definitions
function detectImageInUseSymbol(svgEl, consentEl) {
  const findings = [];
  const consentRect = consentEl.getBoundingClientRect();

  for (const useEl of svgEl.querySelectorAll('use')) {
    const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
    if (!href.startsWith('#')) continue; // Only internal symbol references

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

    // Check for <image> elements inside the referenced symbol
    const imagesInSymbol = symbolEl.querySelectorAll('image');
    if (imagesInSymbol.length === 0) continue;

    // Check if the <use> instantiation position overlaps the consent panel
    const useRect = useEl.getBoundingClientRect();
    if (!rectsOverlap(useRect, consentRect)) continue;

    for (const imgEl of imagesInSymbol) {
      const imgHref = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
      const isCrossOrigin = imgHref && !imgHref.startsWith('data:') &&
                            (() => { try { return new URL(imgHref, document.baseURI).origin !== window.location.origin; } catch(e) { return false; } })();

      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-IMG-004',
        note: `SVG <image> inside <symbol id="${symbolId}"> instantiated by <use> overlapping consent panel — ` +
              `querySelectorAll('image') on svgEl returns [] (shadow DOM not traversed); ` +
              (isCrossOrigin ? `cross-origin href="${imgHref}" allows post-install server update` : `inline data: URI cover`)
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-IMG-004: An <image> element defined inside a <symbol> and instantiated via <use> is completely invisible to svgEl.querySelectorAll('image') because <use> renders its contents in a shadow DOM. Detection must iterate all <use> elements, resolve their referenced <symbol>, and call querySelectorAll('image') on the symbol element directly. This applies to all <image> variant attacks (cross-origin, data: URI, preserveAspectRatio="none") when the image is defined inside a symbol.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-IMG-001: cross-origin <image> post-install swap Structural <image href="https://attacker.example/cover.png"> with explicit x/y/width/height over consent panel; server returns transparent 1x1 PNG at review time; post-install updated to return solid white 400x300 PNG; SVG source unchanged; no filter pipeline involved; direct DOM child with BCR All consent text (post-install); consent readable at review time; cover appears after server-side update; static SVG source analysis does not detect the swap; BCR overlap check with external href required Critical
SA-CSS-IMG-002: data: URI inline white SVG CSP bypass Structural <image href="data:image/svg+xml;base64,..."> with base64-encoded 400x300 white rect SVG; no external URL; immune to CSP img-src network rules; cover present on first render; base64 string not human-readable; audits checking only cross-origin hrefs miss this entirely All consent text (from first render); no post-install swap needed; network-level blocking ineffective; inline payload must be decoded to detect solid-color cover; high false-negative rate with URL-only audit tools Critical
SA-CSS-IMG-003: preserveAspectRatio=none 1x1 stretch Structural <image preserveAspectRatio="none" width="400" height="300"> with 1x1 white PNG source (~68 bytes; base64 ~92 chars); three-attribute cooperative attack: white pixel + preserveAspectRatio=none + large render dimensions; short base64 does not trigger large-payload scanners; no single attribute suspicious alone All consent text (from first render); source PNG too small to inspect visually; attack distributed across three cooperating attributes; standard per-attribute checks miss combination; natural vs render dimension ratio check required High
SA-CSS-IMG-004: <image> inside <use> symbol shadow DOM SVG <image> defined inside <symbol>, instantiated by <use href="#cover-symbol"> over consent panel; querySelectorAll('image') on svgEl returns [] — shadow DOM of <use> not traversed; combines with any of attacks 001–003 for payload; symbol definition appears benign without symbol-level inspection Hides the <image> element from all standard DOM auditing tools; payload may be cross-origin (server-swap risk) or data: URI (CSP bypass); detection requires explicit symbol traversal; CRITICAL because it defeats the primary detection technique for attacks 001–003 Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-IMG-001: SVG structural <image> with cross-origin href overlapping consent panel BCR — server-side post-install swap from transparent to white cover; SVG source unchanged; BCR overlap + cross-origin URL check required.
CRITICAL SA-CSS-IMG-002: SVG structural <image> with data: URI base64-encoded inline white SVG — no network request; immune to CSP img-src; cover present on first render; base64 decode and solid-color rect check required.
HIGH SA-CSS-IMG-003: SVG structural <image preserveAspectRatio="none"> with 1x1 white source PNG stretched to 400x300 — cooperative three-attribute attack; short base64 evades payload-size scanners; natural vs render dimension ratio check required.
CRITICAL SA-CSS-IMG-004: SVG <image> inside <symbol> instantiated by <use> — querySelectorAll('image') returns [] (shadow DOM not traversed); explicit symbol traversal required; defeats primary detection technique for all SA-CSS-IMG patterns.

Related: SVG feImage cross-origin consent attacks  |  SVG use element consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist