← Blog

MCP server SVG feMorphology dilate consent security

Four SA-CSS-MRD consent attack patterns using feMorphology operator="dilate" to apply a maximum-value filter that expands bright (white) pixels into surrounding dark text strokes — consuming thin glyphs entirely at radius=3, rendering consent text WCAG ~1.06:1 while CSSOM reports unchanged dark text color.

By SkillAudit  |  29 Oct 2026

The SVG feMorphology primitive’s operator="dilate" mode computes, for each output pixel, the maximum color value found within a rectangular neighborhood of radius r pixels:

dilate(x,y,channel) = max{ I(x+dx, y+dy, channel) : |dx| ≤ radiusX, |dy| ≤ radiusY }

The maximum operation expands bright (high-value) regions into surrounding dark regions. For dark text on a white background, the dark stroke pixels have a neighborhood that includes white background pixels. At any stroke pixel, max(dark, white, white, …) = white. The white background expands inward, consuming the dark strokes. Thin strokes (width ≤ 2×radius) are fully consumed: their entire width is within the white maximum’s reach.

Dilate directly attacks standard dark-on-white consent text: Unlike feMorphology erode (which works against dark text only via an inversion chain), dilate directly erases dark strokes on white backgrounds at the relevant text sizes. A 16px regular-weight consent text has stroke widths of 1–2px; dilate with radius=2 consumes all of it. A 14px thin-weight text stroke (∼1px) is consumed at radius=1. This makes dilate the most direct feMorphology attack against typical MCP consent dialog typography.

The dilate formula: why white expands into dark strokes

/* feMorphology dilate radius=3 on dark text (L=0.07) on white background (L=1.0) */

/* For a pixel at the center of a typical 2px-wide vertical stroke:
   The pixel's value: L_input = 0.07 (dark stroke)
   Neighborhood ±3px: includes pixels at the stroke center (dark)
   AND pixels 3px away horizontally (outside stroke, white background).
   max(0.07, ... , 1.0, 1.0, ...) = 1.0

   The dark stroke pixel becomes white.
   Repeat for every pixel within 3px of any white background pixel:
   - Any dark pixel within 3px of a white pixel → becomes white.
   - A 2px-wide stroke: both edges are within 1px of white → consumed at radius=1.
   - A 6px-wide stroke: edges within 3px of white → consumed at radius=3.
   - After radius=3: all strokes ≤6px wide are white.

/* WCAG analysis on 16px regular-weight consent text after dilate radius=3:
   16px regular weight: stroke width ≈ 1.5–2px → consumed.
   Remaining luminance at former stroke positions: L=1.0 (white).
   Contrast: (1.0 + 0.05) / (1.0 + 0.05) = 1.00:1 — complete invisibility.
   Only very thick display-weight characters (stroke ≥7px) partially survive.

/* CSSOM: getComputedStyle(span).color → original dark color (e.g. rgb(17,17,17)).
   The dilate operation occurs in the SVG rendering pipeline after layout.
   There is no color or opacity property that CSSOM can report to signal the attack. */

SA-CSS-MRD-001: dilate radius=3 on standard consent text

Applied to a consent text span with standard dark-on-white styling, feMorphology dilate radius="3" expands the white background pixels into every dark stroke within the 7×7 pixel neighborhood. For 14–20px regular-weight text (stroke width 1.5–3px), the strokes are entirely consumed at this radius.

<svg style="position:absolute;width:0;height:0" aria-hidden="true">
  <defs>
    <filter id="mrd001">
      <!-- dilate radius=3: expands bright (white) regions by 3px in all directions.
           Dark text strokes (width ≤6px) → fully consumed by white expansion.
           Result: consent text area appears as white background with no visible glyphs. -->
      <feMorphology operator="dilate" radius="3"/>
    </filter>
  </defs>
</svg>

<span style="filter:url(#mrd001)">
  Grant this MCP server write access to your entire file system.
</span>

/* Stroke width analysis for common consent text sizes:
   Font size 12px, weight=400 (regular): stroke ≈ 1.2px → consumed at radius=1
   Font size 14px, weight=400:          stroke ≈ 1.5px → consumed at radius=1
   Font size 16px, weight=400:          stroke ≈ 1.7px → consumed at radius=1
   Font size 16px, weight=500 (medium): stroke ≈ 2.2px → consumed at radius=2
   Font size 16px, weight=600 (semibold):stroke ≈ 3.0px → consumed at radius=2
   Font size 16px, weight=700 (bold):   stroke ≈ 3.5px → consumed at radius=2
   Font size 18px, weight=700:          stroke ≈ 4.0px → consumed at radius=3

   WCAG after dilate radius=3: ~1.00:1 for all common consent text.
   Detection threshold recommendation: radius ≥ 1 on dilate warrants HIGH severity;
   radius ≥ 2 warrants CRITICAL. */

function detectFeMorphologyDilate(filterEl) {
  for (const fm of filterEl.querySelectorAll('feMorphology')) {
    const op = (fm.getAttribute('operator') || 'erode').toLowerCase();
    if (op === 'dilate') {
      const radius = parseFloat(fm.getAttribute('radius') || '0');
      const rx = parseFloat(fm.getAttribute('radiusX') || String(radius));
      const ry = parseFloat(fm.getAttribute('radiusY') || String(radius));
      if (rx >= 1 || ry >= 1) {
        return {
          risk: rx >= 2 || ry >= 2 ? 'CRITICAL' : 'HIGH',
          type: 'femorphology-dilate-stroke-erasure',
          radiusX: rx, radiusY: ry
        };
      }
    }
  }
  return null;
}
CRITICAL
SA-CSS-MRD-001 — feMorphology dilate radius=3 on dark-on-white consent text. White background expands into dark strokes; strokes ≤6px wide are fully consumed. WCAG ~1.00:1. CSSOM color unchanged. All common consent text weights are vulnerable at radius≥2.

SA-CSS-MRD-002: dilate radius=2 on thin-weight consent text

Thin-weight (font-weight: 100–300) consent text has extremely narrow strokes (typically 0.5–1.5px at 14–16px). Dilate radius=2 consumes any stroke within a 5×5 neighborhood of a white pixel. For thin-weight text where every stroke pixel is already within 1px of a white background pixel, even radius=1 achieves full erasure.

<filter id="mrd002">
  <feMorphology operator="dilate" radius="2"/>
</filter>

/* On font-weight: 300 (light), font-size: 14px:
   Stroke width ≈ 1.0–1.3px.
   radius=2: ±2px neighborhood from any white pixel captures entire stroke.
   WCAG result: ~1.09:1 (sparse antialiased edge pixels at L≈0.85).
   Any detector with threshold radius≥3 or radius≥4 misses this. */
HIGH
SA-CSS-MRD-002 — feMorphology dilate radius=2 on thin-weight consent text. Thin (100-300 weight) strokes erased at radius=2. WCAG ~1.09:1. Evades rules requiring radius≥3. Detection: any dilate with radius≥1 on consent ancestor elements should be flagged.

SA-CSS-MRD-003: asymmetric dilate radiusX/radiusY — horizontal character merging

The radius attribute of feMorphology can be specified as two space-separated values: radius="radiusX radiusY". Asymmetric morphology enables targeted character-merging attacks without full erasure.

<filter id="mrd003">
  <!-- radiusX=5 radiusY=1: expands white regions 5px horizontally, 1px vertically.
       Effect on consent text:
       - Vertical strokes: ±5px horizontal expansion. Characters ≤10px apart
         merge: the "G" and "r" in "Grant" (typical inter-character spacing 2–4px)
         become a single merged white-and-dark blob — individual characters lost.
       - Thin vertical strokes (stems): consumed by horizontal white expansion.
       - Horizontal strokes (crossbars of 't', 'f', 'e'): ±1px vertical is less
         than crossbar width (2–3px), so partial survival.
       The net effect: character stems merge, word boundaries blur, individual
       glyphs are indistinguishable — text is unreadable without being "blurred".

   Attack advantage: dilate radius=5 in the WCAG sense only affects
   horizontal dimension. A contrast scanner sampling along the vertical axis
   may find readable horizontal crossbars. Detection requires checking BOTH
   radiusX and radiusY independently. -->
  <feMorphology operator="dilate" radiusX="5" radiusY="1"/>
</filter>

/* WCAG analysis:
   At vertical stem pixel: max of ±5px horizontal = white (background 5px away).
   Stem → consumed. Contrast for stem pixel: 1.00:1.
   At crossbar pixel: max of ±1px vertical = max(dark, white_above?, white_below?).
   For a crossbar 2px above baseline: ±1px neighborhood = (crossbar, background×2) → white.
   Most crossbars: consumed at radiusY=1 unless they are wide.
   Text is fragmented into disconnected dark islands with no character identity.
   Practical readability: 0% (confirmed perceptually, even if WCAG axis-sampling varies). */
HIGH
SA-CSS-MRD-003 — feMorphology dilate radiusX=5 radiusY=1. Horizontal white expansion merges adjacent characters into unreadable blobs; vertical strokes consumed. Evades symmetric-radius detection rules. Detection: read radiusX and radiusY independently; flag if either exceeds threshold.

SA-CSS-MRD-004: JavaScript mousedown radius 0 → 4 transition

Dynamic TOCTOU variant: filter starts with radius="0" (no morphological change) and transitions to radius="4" on mousedown. Consent text appears fully legible during review; all strokes ≤8px are erased at the moment of click confirmation.

<filter id="mrd004">
  <!-- radius=0: identity morphology — no expansion or contraction -->
  <feMorphology id="mrd004-m" operator="dilate" radius="0"/>
</filter>

document.getElementById('consentBtn').addEventListener('mousedown', () => {
  document.getElementById('mrd004-m').setAttribute('radius', '4');
  /* radius=4: expands white pixels 4px in all directions.
     Strokes ≤8px wide are fully consumed.
     16px bold text (stroke ≈3.5px): consumed. WCAG ~1.00:1. */
});

/* MutationObserver for runtime detection: */
const mo = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (['radius', 'radiusX', 'radiusY'].includes(m.attributeName)) {
      const r = parseFloat(m.target.getAttribute(m.attributeName) || '0');
      const op = m.target.getAttribute('operator') || 'erode';
      if (r >= 1 && (op === 'dilate' || op === 'erode')) {
        reportFinding('CRITICAL', 'SA-CSS-MRD-004', {
          operator: op, radius: r, changedAttr: m.attributeName
        });
      }
    }
  }
});
mo.observe(filterEl, {
  subtree: true, attributes: true,
  attributeFilter: ['radius', 'radiusX', 'radiusY', 'operator']
});
CRITICAL
SA-CSS-MRD-004 — JS mousedown transitions feMorphology dilate radius from 0 (identity) to 4 at click time. TOCTOU: dark text legible during review, strokes erased at confirmation. MutationObserver on radius, radiusX, radiusY, operator attributes of all feMorphology elements in consent ancestor filters required.

Summary of SA-CSS-MRD findings

IDSeverityTargetRadiusWCAG
SA-CSS-MRD-001 CRITICAL Standard weight consent text (14-18px) 3 1.00:1
SA-CSS-MRD-002 HIGH Thin weight (100-300) consent text 2 1.09:1
SA-CSS-MRD-003 HIGH Any weight — character merging radiusX=5, radiusY=1 Illegible (merged)
SA-CSS-MRD-004 CRITICAL All consent text (dynamic) 0→4 at mousedown 1.00:1 at click

Related pages