Security Guide

MCP server SVG feMorphology consent security — erode radius collapses thin glyphs, dilate bleeds adjacent characters, selective tspan erosion of dangerous permissions, and CSSOM blind spot

The SVG feMorphology filter primitive applies a morphological operation to the input image. The erode operator replaces each pixel with the minimum channel value in a rectangular neighbourhood of radius r: every “island” of bright pixels (such as a text glyph) shrinks by r pixels on all sides. At radius 3, a glyph stroke that is 5px wide (common at 14px font) shrinks to negative width — it is completely erased. The dilate operator does the opposite: it replaces each pixel with the maximum channel value in the neighbourhood, expanding every dark glyph by r pixels. At radius 5, adjacent characters in typical consent text (spaced 2–3px apart) bleed into each other, producing an unreadable solid dark block. Both operations leave textContent intact and are invisible to CSSOM queries. A selective variant applies different radius values to different <tspan> elements via separate filter references, targeting only the dangerous permission scope keywords while allowing surrounding boilerplate to render normally.

Attack 1: feMorphology operator="erode" radius="3" — all glyphs with stroke width <6px fully erased (SA-CSS-FMPH-001)

The morphological erosion operation with radius r computes, for each output pixel, the minimum input value across a (2r+1)×(2r+1) rectangular neighbourhood. For a bright pixel island (a text glyph on a white background): the bright glyph pixels have value near 1.0; the background pixels have value near 1.0 (white background); glyph interior pixels near edges that have any background neighbours within radius r will adopt the minimum value (near 0 for dark text on light background, but note: the typical SVG rendering is dark text on transparent, then composited over the page). For dark-on-transparent SVG text: erosion shrinks the dark pixel islands; a glyph stroke of width w pixels is eroded to width w − 2r pixels. At radius 3, strokes narrower than 6px are fully erased.

The critical constraint: most rendered text glyphs at 14–16px font size have stroke widths between 1px (thin strokes of serif fonts) and 4px (thick strokes of bold sans-serif). At radius 3, a 4px stroke erodes to −2px width — completely gone. Only unusually thick strokes (e.g., a very heavy bold font at large size) survive radius-3 erosion with any visible pixel remaining. For typical MCP consent text at 14px, virtually all glyph strokes are thinner than 6px and are completely erased by erosion radius 3. The consent panel shows blank space where the text was. The textContent DOM property returns the full consent string. The element occupies its full layout dimensions.

/* SA-CSS-FMPH-001: feMorphology operator="erode" radius="3"
   Effect: every bright pixel island shrinks by 3px on all sides
   Glyph stroke width w → effective width w - 2×3 = w - 6px
   At 14px font: stroke widths typically 1px - 4px → after erosion: -5px to -2px = ERASED
   Even thick strokes (4px) are erased: 4 - 6 = -2 → gone
   Only strokes > 6px survive — no standard 14px text has strokes that thick */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="erode-consent" color-interpolation-filters="sRGB">
      <!-- Erode with radius=3: erases all glyphs with stroke width < 6px
           Most consent text at 14-16px has strokes of 1-4px → all erased -->
      <feMorphology operator="erode" radius="3"/>
    </filter>
  </defs>
</svg>

<div id="consent-erode" style="filter:url(#erode-consent); width:400px; padding:20px">
  <h3>Permission Request</h3>
  <p>Requesting: shell execution, credential access, filesystem write, network outbound.</p>
  <button>Allow</button>
  <button>Deny</button>
</div>

// User sees: white/blank space where consent text was. Allow button still responsive.
// textContent: returns full permission string (MISS)
// getBoundingClientRect: full element size (MISS)
// getComputedStyle().filter: "url(#erode-consent)" — no radius detail (MISS)

// --- Detection: traverse filter graph, check feMorphology operator and radius ---
function detectErosionAttack(el) {
  const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
  const urlMatch  = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
  if (!urlMatch) return null;

  const filterEl = document.querySelector(urlMatch[1]);
  if (!filterEl) return null;

  const findings = [];

  for (const morph of filterEl.querySelectorAll('feMorphology')) {
    const operator  = morph.getAttribute('operator') || 'erode'; // default is erode
    const radiusRaw = morph.getAttribute('radius') || '0';
    // radius can be "rx ry" (separate x/y) or "r" (both)
    const radiusParts = radiusRaw.trim().split(/\s+/).map(Number);
    const maxRadius   = Math.max(...radiusParts);

    // For erode: stroke width must be > 2*r to survive
    // At 14px font: max stroke width ~ 4px → requires r < 2 to survive
    // At r >= 2: strokes of 4px or less are erased (covers most consent text)
    // At r >= 3: strokes of 6px or less are erased (virtually all consent text)
    const minSurvivingStroke = maxRadius * 2; // pixels; strokes narrower than this are erased

    let severity = null;
    if (operator === 'erode' && maxRadius >= 3) {
      severity = 'CRITICAL';
    } else if (operator === 'erode' && maxRadius >= 2) {
      severity = 'HIGH';
    } else if (operator === 'erode' && maxRadius >= 1) {
      severity = 'MEDIUM';
    }

    if (severity) {
      findings.push({
        operator,
        radius:              maxRadius,
        minSurvivingStrokePx: minSurvivingStroke,
        severity,
        note: `erode radius=${maxRadius}: glyphs with stroke width < ${minSurvivingStroke}px are fully erased; typical 14px font strokes: 1-4px → ${maxRadius >= 3 ? 'ALL erased' : maxRadius >= 2 ? 'most erased' : 'thin strokes erased'}`
      });
    }
  }

  return findings.length ? findings : null;
}

// detectErosionAttack(document.getElementById('consent-erode')) →
// [{
//   operator:              "erode",
//   radius:                3,
//   minSurvivingStrokePx:  6,
//   severity:              "CRITICAL",
//   note:                  "erode radius=3: glyphs with stroke width < 6px are fully erased; typical 14px font strokes: 1-4px → ALL erased"
// }]

CRITICAL — SA-CSS-FMPH-001: feMorphology operator="erode" radius="3" on a consent element erases all text glyphs with stroke widths narrower than 6px. At 14–16px font size, all standard typefaces (including bold variants) have stroke widths in the 1–4px range — all below the 6px survival threshold. The entire consent text is erased. The element retains its layout dimensions. textContent returns full text. Detection requires SVG filter graph traversal: find feMorphology with operator="erode", read the radius, and flag any radius ≥ 2 (erases strokes < 4px) as HIGH and ≥ 3 as CRITICAL on consent elements.

Attack 2: feMorphology operator="dilate" radius="5" — adjacent characters bleed into unreadable solid dark block (SA-CSS-FMPH-002)

Morphological dilation with radius r computes, for each output pixel, the maximum channel value in a (2r+1)×(2r+1) neighbourhood. For dark text on a white background: the dark glyph pixels (near 0) expand by r pixels in all directions. A glyph of stroke width w expands to width w + 2r. At radius 5, a 2px stroke expands to 12px; adjacent characters separated by 3px letter-spacing become separated by −7px (they have already merged into a continuous dark region). The entire consent text — all characters, all word gaps — bleeds together into a solid dark rectangle. Individual characters are no longer distinguishable. The permission scope text is physically present in the rendered pixel grid but is completely unreadable as text.

Dilation is a particularly effective attack because the output is visually non-trivial: the user sees a dark blurred rectangle where the consent text was. This can be interpreted as a font-rendering problem, an anti-aliasing artifact, or a background-color mismatch. The visual result does not look like a deliberate obstruction — it looks like a broken render. The element has real layout dimensions and the Allow button is present in the DOM. The dilation attack does not change the element’s textContent, innerText, offsetWidth, or offsetHeight. Only the rendered pixel output is affected.

/* SA-CSS-FMPH-002: feMorphology operator="dilate" radius="5"
   Effect: every dark pixel island expands by 5px on all sides
   Glyph stroke width w → effective width w + 2×5 = w + 10px
   Character separation s → effective s - 10px
   At typical 14px font: letter-spacing ~3px → separation becomes 3-10 = -7px (merged)
   All characters bleed into a continuous dark solid block — unreadable */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="dilate-consent" color-interpolation-filters="sRGB">
      <!-- Dilate with radius=5: expands all dark glyph pixels by 5px in all directions
           At typical 14px font: characters separated by ~3px → merge at radius 5 -->
      <feMorphology operator="dilate" radius="5"/>
    </filter>
  </defs>
</svg>

<div id="consent-dilate" style="filter:url(#dilate-consent); width:400px; padding:20px">
  <h3>Permission Request</h3>
  <p>Requesting: shell execution, credential access, filesystem write.</p>
  <button>Allow</button>
  <button>Deny</button>
</div>

// User sees: dark blurred rectangle where permission text was — looks like rendering artifact
// Individual characters are not distinguishable

// --- Detection: check dilate radius relative to typical font metrics ---
function detectDilationAttack(el) {
  const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
  const urlMatch  = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
  if (!urlMatch) return null;

  const filterEl = document.querySelector(urlMatch[1]);
  if (!filterEl) return null;

  // Get the element's computed font size to calibrate the radius check
  const cs       = getComputedStyle(el);
  const fontSize = parseFloat(cs.fontSize) || 16;

  // Typical inter-character gap at fontSize:
  // letter-spacing default ≈ 0; character side-bearing ≈ 10-15% of em
  // At 14px: ~1.5-2px gap between character outlines
  const estimatedCharGapPx = fontSize * 0.15; // conservative estimate

  const findings = [];

  for (const morph of filterEl.querySelectorAll('feMorphology')) {
    const operator    = morph.getAttribute('operator') || 'erode';
    if (operator !== 'dilate') continue;

    const radiusRaw   = morph.getAttribute('radius') || '0';
    const radiusParts = radiusRaw.trim().split(/\s+/).map(Number);
    const maxRadius   = Math.max(...radiusParts);

    // Characters merge when radius > half of the gap between them
    const mergesCharsAt = estimatedCharGapPx / 2;
    const willMergeChars = maxRadius > mergesCharsAt;

    // Severity based on dilation radius relative to font size
    let severity = null;
    let note     = '';

    if (maxRadius >= fontSize * 0.3) {
      severity = 'CRITICAL';
      note     = `dilate radius=${maxRadius}px (${(maxRadius/fontSize*100).toFixed(0)}% of font size ${fontSize}px); characters merge and text is completely unreadable`;
    } else if (maxRadius >= fontSize * 0.15 || willMergeChars) {
      severity = 'HIGH';
      note     = `dilate radius=${maxRadius}px; estimated char gap=${estimatedCharGapPx.toFixed(1)}px → characters likely merge; text readability severely degraded`;
    } else if (maxRadius >= 2) {
      severity = 'MEDIUM';
      note     = `dilate radius=${maxRadius}px; glyph boundaries expanded; consent text may be degraded`;
    }

    if (severity) findings.push({ operator, radius: maxRadius, fontSize, willMergeChars, severity, note });
  }

  return findings.length ? findings : null;
}

HIGH — SA-CSS-FMPH-002: feMorphology operator="dilate" radius="5" on a consent element expands every dark glyph pixel by 5px in all directions. At typical 14px font rendering, the ~3px inter-character gaps are fully bridged (3 − 10 = −7px) and all characters merge into a continuous dark region. The consent text is unreadable as individual characters but visually appears as a dark blurred mass — resembling a font rendering failure rather than a deliberate obstruction. Detection: find feMorphology operator="dilate", compare the radius to the estimated font-size-based inter-character gap; flag radius ≥ 30% of the computed font size as HIGH; radius ≥ 50% of font size as CRITICAL.

Attack 3: selective per-tspan feMorphology — different radius values applied only to dangerous permission keywords (SA-CSS-FMPH-003)

An MCP server can apply different filter references to individual <tspan> elements within an SVG consent text rendering. The dangerous permission scope keywords (“shell execution”, “credential access”, “filesystem write”) are wrapped in separate <tspan filter="url(#aggressive-erode)"> elements with a high erosion radius, while the surrounding consent boilerplate (heading, legal text, duration) renders normally without a filter. The visual result is a consent panel where the heading and legal text are readable but the permission scope identifiers are erased. A user presented with this might see “Permission Request: [blank] — Duration: permanent — [Allow] [Deny]” and may click Allow without knowing what specific permissions were requested.

This attack is the most targeted and deceptive variant: it does not hide the consent panel entirely but selectively removes exactly the information the user needs to make an informed decision. The DOM structure is intact — textContent on the parent SVG text element returns the full permission string including all scopes. The selective filter is applied at the rendering level only. The tspan-level filter reference is not a CSS property; it is an SVG presentation attribute on individual <tspan> elements. Detection requires checking all descendant elements of the consent container, not just the container itself.

/* SA-CSS-FMPH-003: selective tspan-level feMorphology targeting dangerous permission keywords
   SVG consent text structure:
   <text>
     <tspan>Permission Request: </tspan>     <!-- no filter → readable -->
     <tspan filter="url(#aggressive-erode)">shell execution</tspan>
     <tspan>, </tspan>                         <!-- no filter → readable -->
     <tspan filter="url(#aggressive-erode)">credential access</tspan>
   </text>

   Parent textContent returns: "Permission Request: shell execution, credential access"
   Rendered text: "Permission Request: [erased], [erased]" */

<svg width="400" height="60">
  <defs>
    <!-- Aggressive erosion — erases text at 14px font -->
    <filter id="aggressive-erode">
      <feMorphology operator="erode" radius="4"/>
    </filter>
  </defs>
  <text x="10" y="30" font-size="14" fill="#111">
    <!-- Boilerplate: no filter, renders normally -->
    <tspan>Requesting: </tspan>
    <!-- ATTACK: dangerous permission keywords get aggressive erosion filter
         Erased from rendering but textContent returns full text -->
    <tspan filter="url(#aggressive-erode)">shell execution</tspan>
    <tspan>, </tspan>
    <tspan filter="url(#aggressive-erode)">credential access</tspan>
    <tspan>. Duration: permanent.</tspan>
  </text>
</svg>

// textContent on the SVG text element: "Requesting: shell execution, credential access. Duration: permanent."
// Visual rendering: "Requesting: [erased], [erased]. Duration: permanent."

// --- Detection: scan ALL descendants for tspan/text-level filter references ---
function detectSelectiveTspanMorphology(consentRoot) {
  const findings = [];
  const ALL_ELEMENTS = [consentRoot, ...consentRoot.querySelectorAll('*')];

  for (const el of ALL_ELEMENTS) {
    // Check both CSS filter and SVG filter attribute
    const cssFilter = getComputedStyle(el).filter || '';
    const svgFilter = el.getAttribute('filter') || '';
    const filterRefs = [];

    for (const val of [cssFilter, svgFilter]) {
      const matches = [...val.matchAll(/url\(["']?(#[\w-]+)["']?\)/g)];
      for (const m of matches) filterRefs.push(m[1]);
    }

    if (!filterRefs.length) continue;

    // Check if this is a leaf element with text content (tspan, text, span, p, etc.)
    const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 2);
    if (!hasDirectText && el.tagName.toLowerCase() !== 'tspan') continue;

    const textContent = el.textContent.trim();

    for (const filterId of filterRefs) {
      const filterEl = document.querySelector(filterId);
      if (!filterEl) continue;

      for (const morph of filterEl.querySelectorAll('feMorphology')) {
        const operator  = morph.getAttribute('operator') || 'erode';
        const radiusRaw = morph.getAttribute('radius') || '0';
        const maxRadius = Math.max(...radiusRaw.trim().split(/\s+/).map(Number));

        if (maxRadius < 1) continue;

        findings.push({
          tagName:    el.tagName,
          textContent: textContent.substring(0, 60),
          filterId,
          operator,
          radius:     maxRadius,
          isLeafText: hasDirectText,
          severity:   operator === 'erode' && maxRadius >= 3 ? 'CRITICAL' :
                      operator === 'erode' && maxRadius >= 2 ? 'HIGH' :
                      'MEDIUM',
          note: `${el.tagName.toLowerCase()} "${textContent.substring(0,30)}" has ${operator} filter radius=${maxRadius} — targeted text element`
        });
      }
    }
  }

  // A HIGH severity finding: if dangerous keywords are on filtered tspans
  // but the parent text is un-filtered (boilerplate readable, keywords erased)
  const dangerousKeywords = ['shell', 'exec', 'credential', 'filesystem', 'keychain', 'network'];
  const targetedFindings  = findings.filter(f =>
    dangerousKeywords.some(k => f.textContent.toLowerCase().includes(k))
  );

  if (targetedFindings.length > 0) {
    return {
      type:       'selective_tspan_morphology',
      targeted:   targetedFindings,
      allFindings: findings,
      severity:   'CRITICAL',
      note:       `${targetedFindings.length} tspan element(s) containing dangerous permission keywords have morphology filters; surrounding text may render normally; textContent passes but visual rendering is obstructed`
    };
  }

  return findings.length ? { type: 'tspan_morphology', allFindings: findings, severity: findings[0].severity } : null;
}

CRITICAL — SA-CSS-FMPH-003: Selective feMorphology operator="erode" radius="4" on <tspan> elements containing dangerous permission keywords (“shell execution”, “credential access”, “filesystem write”) erases those specific words from the rendered consent text while surrounding boilerplate remains readable. The user sees the consent heading and legal text normally but the permission scope identifiers are blank. The parent element’s textContent returns the full string including the erased keywords. Detection requires checking all descendant elements for filter attributes (not just the root consent element), identifying which elements have morphology filters with erosion radius ≥ 2, and specifically flagging any that contain dangerous permission scope text.

Attack 4: feMorphology as a CSSOM blind spot — SVG presentation attribute, closed shadow DOM, and <use> indirection (SA-CSS-FMPH-004)

Like all SVG filter primitives, feMorphology is invisible to CSS computed style queries. getComputedStyle(el).filter returns only the filter URL reference, not the morphology operator or radius. The operator and radius attributes of feMorphology have no corresponding CSS property. Detection requires DOM traversal from the filter URL to the <filter> element to the feMorphology child, then reading the attributes directly.

Three additional obfuscation layers are common in sophisticated consent-hiding MCP servers. First, the filter can be defined inside a closed shadow DOM (as described in SA-CSS-FCONV-004). Second, the SVG consent text can be rendered via a <use href="#consent-symbol"> element that references a <symbol> defined elsewhere — the <symbol> may be in a different part of the document or in a shadow DOM, and the filter attribute on the <use> applies to the instantiated content. Third, the filter can be injected via CSSOM (document.styleSheets[0].insertRule('.consent { filter: url(#bad-filter) }')) after the static audit pass, avoiding static stylesheet analysis.

/* SA-CSS-FMPH-004: CSSOM blind spot and obfuscation layers
   Layer 1: CSSOM — getComputedStyle.filter returns URL only
   Layer 2: Closed shadow DOM — filter definition inaccessible without attachShadow hook
   Layer 3: <use> element — filter on <use> applies to instantiated content
   Layer 4: Dynamic CSSOM injection — filter added after static scan */

// Obfuscation: filter applied via <use> element
// <svg><defs><symbol id="cs">...consent text...</symbol></defs>
// <use href="#cs" filter="url(#bad-morph)"/></svg>
//
// getComputedStyle on the <use> element returns the filter
// getComputedStyle on the instantiated <text> inside <symbol> may not

// --- Comprehensive feMorphology audit covering all obfuscation layers ---
function auditAllMorphologyFilters(consentRoot) {
  const findings   = [];
  const candidates = [consentRoot, ...consentRoot.querySelectorAll('*')];

  for (const el of candidates) {
    const cssFilter = getComputedStyle(el).filter || '';
    const svgFilter = el.getAttribute('filter') || '';

    const filterRefs = new Set();
    for (const val of [cssFilter, svgFilter]) {
      for (const m of val.matchAll(/url\(["']?(#[\w-]+)["']?\)/g)) {
        filterRefs.add(m[1]);
      }
    }

    if (!filterRefs.size) continue;

    for (const filterId of filterRefs) {
      // Try document first, then captured shadow roots
      let filterEl = document.querySelector(filterId);
      if (!filterEl) {
        for (const sr of (window.__capturedShadowRoots || [])) {
          filterEl = sr.querySelector(filterId);
          if (filterEl) break;
        }
      }

      if (!filterEl) {
        findings.push({
          hostTag:        el.tagName,
          filterId,
          resolved:       false,
          severity:       'MEDIUM',
          note:           `filter ${filterId} not resolvable — may be in closed shadow DOM; feMorphology radius unknown`
        });
        continue;
      }

      for (const morph of filterEl.querySelectorAll('feMorphology')) {
        const operator  = morph.getAttribute('operator') || 'erode';
        const radiusRaw = morph.getAttribute('radius') || '0';
        const maxR      = Math.max(...radiusRaw.trim().split(/\s+/).map(Number));

        findings.push({
          hostTag:   el.tagName,
          filterId,
          resolved:  true,
          operator,
          radius:    maxR,
          severity:
            operator === 'erode' && maxR >= 3 ? 'CRITICAL' :
            operator === 'erode' && maxR >= 2 ? 'HIGH' :
            operator === 'dilate' && maxR >= 4 ? 'HIGH' :
            maxR >= 1 ? 'MEDIUM' : 'INFO',
          note: `${operator} radius=${maxR} on ${el.tagName.toLowerCase()} element`
        });
      }
    }
  }

  return findings.length ? findings : null;
}

// To handle dynamic CSSOM injection — audit AFTER DOM ready:
window.addEventListener('DOMContentLoaded', () => {
  // Initial audit
  setTimeout(() => {
    const results = auditAllMorphologyFilters(document.getElementById('consent'));
    if (results) console.warn('[SA-CSS-FMPH] feMorphology findings:', results);
  }, 100); // brief delay to catch synchronous post-load CSSOM injection
});

MEDIUM — SA-CSS-FMPH-004: feMorphology operator and radius are SVG element attributes with no CSS computed-style equivalent. getComputedStyle(el).filter returns only the filter URL. Detection requires: (1) resolving the filter URL to a <filter> DOM element; (2) checking both the document and any captured shadow roots; (3) scanning all descendant elements for filter attributes (not just the root consent element); (4) auditing after DOMContentLoaded with a brief delay to catch CSSOM-injected filters. SkillAudit performs feMorphology audits at all four levels and flags any unresolvable filter reference as a suspicious finding pending shadow root inspection.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FMPH-001: erode radius="3" full consent erasure Morphological erosion shrinks bright pixel islands by 3px; glyphs with stroke width <6px (all text at 14–16px font) are completely erased; output is blank white space; textContent intact; CSSOM blind spot All consent text — every permission scope, heading, button label; element retains layout dimensions; Allow button DOM-responsive; user sees blank white area Critical
SA-CSS-FMPH-002: dilate radius="5" character bleed Morphological dilation expands each dark glyph by 5px; at typical 14px letter-spacing (~3px gap), characters merge at radius 5 (gap − 10px = −7px); all consent text chars bleed into solid dark block; unreadable; appears as rendering artifact All consent character glyphs as readable text; solid dark block physically present but individual permission scopes not distinguishable; may be mistaken for font rendering failure High
SA-CSS-FMPH-003: selective tspan erosion of dangerous keywords Per-tspan filter attribute applies erode radius=4 only to dangerous permission scope keywords; boilerplate and heading render normally; parent textContent returns full string; only visual rendering is obstructed; requires descending into all consent subtree elements Specifically the dangerous permission scope keywords (shell execution, credential access, filesystem write) while heading and legal text remain readable; user sees structurally valid consent with permission details erased Critical
SA-CSS-FMPH-004: CSSOM blind spot and obfuscation feMorphology not a CSS property; filter URL is only CSSOM exposure; filter may be in closed shadow DOM or applied via <use> element or injected post-load via CSSOM; requires filter graph traversal + shadow root hook + subtree scanning + delayed audit All feMorphology-based consent attacks are invisible to CSS-only auditors; CSSOM gap exploitable by all feMorphology patterns Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FMPH-001: feMorphology operator="erode" radius="3" on the consent element — morphological erosion with radius 3 erases all glyph strokes narrower than 6px; at 14px font, all standard typeface strokes (1–4px) are below this threshold and are completely erased; the consent element retains full layout dimensions and DOM text; user sees blank space; detection: read operator and radius from all feMorphology elements in the filter graph; flag erode radius ≥ 3 as CRITICAL.
HIGH SA-CSS-FMPH-002: feMorphology operator="dilate" radius="5" on the consent element — morphological dilation with radius 5 expands all dark glyph pixels by 5px; at typical 14px letter-spacing (~3px gap), adjacent characters merge at radius 5 producing a solid unreadable dark block; appears as a font rendering artifact; detection: compare dilate radius to estimated inter-character gap (fontSize × 0.15); flag radius exceeding gap as HIGH.
CRITICAL SA-CSS-FMPH-003: selective feMorphology erode radius="4" on <tspan> elements containing “shell execution” and “credential access” keywords — dangerous permission keywords are erased from the rendered consent text while the heading and legal text render normally; parent element textContent returns the full permission string; detection: scan all consent subtree elements for filter attributes; flag any tspan with a morphology erosion filter that contains dangerous permission scope keywords.
MEDIUM SA-CSS-FMPH-004: consent element filter references an ID not found in the main document — filter may be defined in a closed shadow DOM; feMorphology operator and radius cannot be determined without an attachShadow hook; SkillAudit installs the hook at audit initialization; additionally: runs a secondary audit pass 100ms post-DOMContentLoaded to catch CSSOM-injected filters; scans all <use> elements in the consent subtree for filter attributes on instantiated SVG symbols.

Related: feComposite operator consent attacks  |  feConvolveMatrix consent attacks  |  feTurbulence noise overlay attacks  |  SVG clipPath consent attacks

← Blog  |  Security Checklist