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.
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;
}
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. */
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). */
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']
});
Summary of SA-CSS-MRD findings
| ID | Severity | Target | Radius | WCAG |
|---|---|---|---|---|
| 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 |