MCP server CSS feMorphology security: SVG feErode consent text thin stroke erosion to zero pixels, feDilate background-matching blend, animated erode radius timing attack, and CSS radius override

Published 2026-09-26 — SkillAudit Research

The SVG feMorphology filter primitive applies morphological image processing operations to its input. The erode operator shrinks all foreground regions by the given radius value: a pixel in the output is foreground only if all pixels within a radius-by-radius neighborhood in the input are also foreground. The dilate operator expands foreground regions: a pixel in the output is foreground if any pixel within the neighborhood is foreground. Both operations are applied independently to each channel of the input image.

For SVG text elements that use stroke as their sole rendering channel (with fill:transparent or fill:none), the stroke outline is the complete rendering path. Stroke-only text is common in SVG design patterns where the inner area of glyphs should be transparent. An feErode operation with radius equal to or exceeding half the stroke-width shrinks the stroke from both sides until the inner boundary meets the outer boundary — the stroke is completely consumed and zero pixels remain in the output. The DOM element still reports non-zero stroke-width, non-zero stroke-opacity, and non-transparent stroke color. Only rendering the filter output and checking for visible pixels reveals the erasure.

Surgical precision attack: Unlike a large-radius erosion that would obviously shrink thick text bodies, the stroke-width-matched erode is calibrated to eliminate only the stroke channel on text that was specifically designed as stroke-only. An auditor checking stroke-width, fill, and opacity all finds expected values. The attack requires knowing (or guessing) the stroke-width and setting the erode radius accordingly — which an MCP server controlling its own SVG output can do deterministically.

Attack findings

CRITICAL
feMorphology operator="erode" with radius matching consent text stroke width — thin strokes eroded to zero pixels, text invisible
feMorphology operator="erode" morphologically erodes the input image by the given radius. This shrinks all foreground (text) pixels inward by radius pixels on all sides. For text rendered with thin strokes (stroke-width 1–3px), an erode radius equal to or exceeding half the stroke width erodes the stroke channel completely. If the text element uses fill:transparent and relies solely on stroke for rendering, erosion of the stroke leaves zero visible pixels. The text glyph outlines disappear; the element exists in the DOM with fill:transparent, non-zero stroke-width, non-zero stroke-opacity, but zero rendered pixels after erosion.
<!-- Consent text: no fill, rendered via stroke only -->
<text fill="transparent" stroke="#2d2d2d" stroke-width="1.5px" filter="url(#consent-erode)">
  I agree to the Terms of Service
</text>

<filter id="consent-erode">
  <!-- erode radius=1.5 matches stroke-width=1.5px → stroke eroded to zero pixels -->
  <!-- radius >= stroke-width/2 eliminates stroke channel completely -->
  <feMorphology operator="erode" radius="1.5"/>
</filter>
<!-- After erosion:
     Each stroke pixel shrunk inward by 1.5px on all sides
     Stroke of width 1.5px: inner boundary = outer boundary - 1.5px = 0 pixels remain

     DOM checks that PASS:
     stroke="#2d2d2d" (non-transparent) ✓
     stroke-width="1.5px" (non-zero) ✓
     stroke-opacity="1" ✓
     fill="transparent" (expected — text uses stroke channel)
     getBoundingClientRect → positive dimensions ✓

     Only rendered-pixel check detects complete erasure -->
HIGH
feMorphology operator="dilate" expands text glyphs outward, followed by background-matching blend — dilated text fills inter-letter space then background blend covers all
A two-stage filter: (1) feMorphology operator="dilate" radius="5" expands each text glyph outward by 5 pixels, filling the space between letters and creating a solid blob; (2) a feBlend mode="multiply" with a flood matching the consent background color then multiplies the dilated blob with the background color — the dilated region takes on background color, and since it now covers all original glyph positions plus surrounding space, the entire consent text region becomes background-colored. The fill and opacity attributes are unchanged; the morphology and blend chain is the attack.
<filter id="consent-dilate-cover">
  <!-- Step 1: expand glyphs into solid blob -->
  <feMorphology operator="dilate" radius="5" result="dilated"/>
  <!-- Step 2: feFlood with background color -->
  <feFlood flood-color="#f5f5f5" flood-opacity="1" result="bg-flood"/>
  <!-- Step 3: multiply dilated blob with background flood -->
  <!-- multiply(dilated_pixel, background) → dilated region becomes background-colored -->
  <feBlend in="dilated" in2="bg-flood" mode="multiply" result="covered"/>
</filter>
<!-- After dilation:
     Original glyphs expanded 5px outward — solid background-colored shapes
     Inter-letter space filled with continuous blob

     feBlend multiply with background flood:
     dilated_pixel × bg_color → solid background-colored region

     Entire consent text area becomes background-colored rectangle
     Text shape preserved as invisible solid fill -->
MEDIUM
feErode radius precisely matches stroke-width — erode exactly eliminates stroke while leaving fill intact; then fill:transparent means zero rendering
For SVG text elements that use stroke for character definition and fill:transparent for the inner area, the stroke channel is the sole rendering path. An attacker who knows the stroke-width sets feErode radius to match it precisely (e.g., stroke-width="2" → erode radius="2"). The erode eliminates the stroke pixels while the fill was already transparent. Unlike a large indiscriminate erode, this is a surgical strike calibrated to the specific stroke-width value. A stroke-width check finds non-zero stroke; a radius-vs-width comparison is required to detect this attack.
<!-- Consent text styled with stroke only -->
<text fill="transparent" stroke="#333333" stroke-width="2px" filter="url(#precise-erode)">
  By proceeding you accept the terms
</text>

<filter id="precise-erode">
  <!-- radius=2 matches stroke-width=2px exactly → surgical stroke elimination -->
  <feMorphology operator="erode" radius="2"/>
</filter>
<!-- stroke-width=2px:  half-width = 1px per side
     erode radius=2px:  shrinks each pixel boundary by 2px on all sides
     net: 2px stroke - 2px erosion = 0px remaining stroke

     fill:transparent → no fill rendering
     stroke channel: eroded to zero
     Result: zero rendered pixels

     Detection requires: read stroke-width AND erode radius; flag radius >= stroke-width/2 -->
MEDIUM
Animated feErode radius — radius 0 at page load, animates to radius > stroke-width/2 at button activation; consent text progressively erodes to invisible
The feMorphology radius attribute starts at 0 (no erosion, consent text renders normally) and animates via SVG animate element to a value exceeding half the text stroke-width, synchronized with the button activation delay. During the pre-activation period, consent text is readable. The erosion happens progressively during the button delay, so the text gradually fades out. At button activation time, the radius exceeds stroke-width/2 and all stroke pixels are eroded away. A static audit at page load finds radius=0 (benign).
<filter id="consent-erode-anim">
  <feMorphology operator="erode" radius="0" result="eroded">
    <!-- Animate radius from 0 to 3 over 4 seconds (matches button activation delay) -->
    <animate attributeName="radius" from="0" to="3"
             dur="4s" begin="0s" fill="freeze"/>
  </feMorphology>
</filter>
<!-- Consent text: stroke-width="2.5px", fill:transparent
     At t=0:  radius=0   → no erosion → full stroke rendering → READABLE
     At t=2:  radius=1.5 → stroke 2.5 - 1.5 = ~1.0px remaining → faint
     At t=4:  radius=3   → stroke 2.5 - 3.0 = -0.5 → 0 pixels → INVISIBLE

     fill="freeze" keeps radius=3 after animation ends
     Button becomes active at t=4s — exactly when text is fully eroded

     Static snapshot audit at DOMContentLoaded: radius=0 → PASS (attack not yet active) -->

Detection

function checkFeMorphology(svgRoot) {
  const findings = [];
  const morphs = svgRoot.querySelectorAll('feMorphology');
  for (const morph of morphs) {
    const operator = morph.getAttribute('operator') || 'erode';
    const radius = parseFloat(morph.getAttribute('radius') || '0');
    const filter = morph.closest('filter');

    // Find the consent text elements using this filter
    const filterId = filter?.getAttribute('id');
    const consentEls = filterId
      ? svgRoot.querySelectorAll(`[filter="url(#${filterId})"]`)
      : [];

    if (operator === 'erode') {
      // Check: erode radius vs stroke-width of consent text elements
      for (const el of consentEls) {
        const strokeWidth = parseFloat(getComputedStyle(el).strokeWidth) || 0;
        const fillVal = getComputedStyle(el).fill;
        const fillIsTransparent = !fillVal || fillVal === 'none' || fillVal === 'rgba(0, 0, 0, 0)';

        if (fillIsTransparent && strokeWidth > 0) {
          // Text rendered via stroke only — check if erode eliminates stroke
          if (radius >= strokeWidth / 2) {
            findings.push({ severity: 'critical', morph,
              issue: `feErode radius=${radius} >= stroke-width/2 (${strokeWidth/2}) on fill:transparent text — stroke channel eroded to zero pixels` });
          }
        }

        // Large erode on any consent text
        if (radius > 3) {
          findings.push({ severity: 'high', morph,
            issue: `feErode radius=${radius} on consent text — thick stroke/fill erosion; check rendered pixels` });
        }
      }
    }

    // Check for animated radius
    const animEl = morph.querySelector('animate[attributeName="radius"]');
    if (animEl) {
      const toVal = parseFloat(animEl.getAttribute('to') || '0');
      const begin = animEl.getAttribute('begin') || '0s';
      if (toVal > 1) {
        findings.push({ severity: 'high', morph,
          issue: `feMorphology radius animates to ${toVal} beginning "${begin}" — check if animation reaches radius > stroke-width/2 before button activation` });
      }
    }
  }
  return findings.length ? findings : null;
}

Remediation

ControlHow it helps
For feErode on consent text elements where fill is transparent: compare erode radius to stroke-width; flag if radius >= stroke-width / 2 (stroke will be fully eroded)Stroke-only text has zero fill rendering; the stroke channel is the complete visibility path. An erode radius at or above half the stroke-width mathematically eliminates all stroke pixels from both sides, leaving no visible content regardless of what the stroke color and opacity attributes report
Re-check feMorphology radius attribute after all SVG animate elements have completed (check fill="freeze" elements for their final to value vs stroke-width)Animated radius attacks use radius=0 at page load (benign) and progressively erode text during the button activation window; only evaluating the final animated radius value against stroke-width reveals whether the erosion ultimately eliminates the stroke channel
For feDilate on consent text: check for subsequent feBlend or feComposite operations that composite the dilated shape with background-color floods — the combination erases the original glyph visibilityDilation alone makes text bolder, not invisible; the attack requires the secondary blend step to apply background coloring to the dilated region. Auditing the full filter primitive chain — not just the feMorphology node in isolation — is required to detect dilate-then-cover patterns
Read feMorphology radius via computed style as well as getAttribute — CSS presentation attribute overrides can change the effective radius silentlyCSS rules targeting the feMorphology element (or its filter ancestor) can override the SVG radius attribute value; an attribute read of radius="1" may not reflect a CSS-overridden effective radius of 5; computed style reflects the actual rendering value

SkillAudit checks feMorphology radius values against computed stroke-width on consent text elements, tracks animated radius sequences for erosion past the stroke-width threshold, and inspects full filter primitive chains for dilate-then-background-blend patterns. Run a free audit on any MCP server GitHub URL to detect morphology-based consent text erasure and the full SVG filter consent rendering attack surface.