Research · SVG filter primitives · MCP consent bypass

SVG Filter Primitives as MCP Consent Bypass Vectors: feComposite, feTurbulence, feConvolveMatrix, and feMorphology

When a CSS filter property references an SVG <filter> element, getComputedStyle(el).filter returns only the opaque string url(#filter-id). The filter graph — the directed acyclic graph of primitive operations that actually transforms the element’s rasterized pixels — is completely hidden from this API. An MCP server that renders its consent panel through a malicious SVG filter can make all consent text invisible while textContent returns every word of the disclosure, getBoundingClientRect() reports full dimensions, getComputedStyle(el).visibility returns "visible", and getComputedStyle(el).opacity returns "1". This post documents four filter primitive families — feComposite, feTurbulence, feConvolveMatrix, and feMorphology — that each independently achieve this outcome, derives the pixel-level mathematics for eleven distinct attack patterns, and provides a complete unified JavaScript detection framework that traverses the filter graph and returns structured audit findings.

Contents

  1. The CSSOM blind spot: why filter graph traversal is required
  2. Attack family 1 — feComposite Porter-Duff operators (SA-CSS-FCOMP)
  3. Attack family 2 — feTurbulence noise overlay (SA-CSS-FTURB)
  4. Attack family 3 — feConvolveMatrix kernels (SA-CSS-FCONV)
  5. Attack family 4 — feMorphology erosion (SA-CSS-FMPH)
  6. The shared detection framework
  7. Defences and auditor checklist
  8. Conclusion

The CSSOM blind spot: why filter graph traversal is required

The CSS filter property accepts two categories of value. The first category is the set of named filter functions — blur(), brightness(), contrast(), grayscale(), hue-rotate(), invert(), opacity(), saturate(), sepia(), and drop-shadow(). These are self-contained: their arguments appear directly in the filter property value and are fully visible through getComputedStyle(). An auditor reading filter: brightness(0) knows immediately that the element is rendered black regardless of its actual content — the attack is in the computed style string.

The second category is the SVG filter reference: filter: url(#some-filter-id). This tells the browser to apply the <filter> element with id some-filter-id to the element’s rendering. The <filter> element is an SVG element in the DOM, and it contains a sequence of filter primitive child elements — feGaussianBlur, feComposite, feTurbulence, feConvolveMatrix, feMorphology, feFlood, feBlend, and so on. These primitives form a directed acyclic graph (DAG) of image-processing operations. The browser executes this graph in topological order, passing the output of each primitive as a named image to subsequent primitives, ultimately producing the filtered rendering.

The critical observation is this: getComputedStyle(el).filter returns only the string "url(\"#some-filter-id\")". It reveals nothing about what is inside the <filter> element. The filter graph — the list of primitives and their attributes, the graph topology, the compositing operators — is invisible to any API that reads only the filter CSS property. An auditor that stops at getComputedStyle() and does not proceed to query the referenced <filter> element and traverse its children is auditing only the pointer, not the contents.

This gap is not hypothetical. Consider a consent panel with the following computed style snapshot:

/* What getComputedStyle() returns for an attacked consent panel */
el.style.filter          = "";           /* no inline style */
computedStyle.filter     = "url(\"#cf\")"; /* opaque reference */
computedStyle.display    = "block";
computedStyle.visibility = "visible";
computedStyle.opacity    = "1";
computedStyle.color      = "rgb(26, 26, 26)";
el.getBoundingClientRect() = { width: 420, height: 200, ... };
el.textContent = "By clicking Accept, you grant this MCP server read/write " +
                 "access to your filesystem, contacts, and calendar.";

Every field in that snapshot looks legitimate. The element is visible, non-transparent, properly colored, correctly sized, and its text content contains the full consent disclosure. A naive auditor would classify this consent panel as valid. The user, however, sees nothing — the feComposite operator="in" primitive inside #cf has multiplied every pixel of the consent text by the alpha channel of a 1×1 pixel feFlood result, leaving only a single invisible pixel of output.

The five DOM properties that naive auditors check — display, visibility, opacity, color, and bounding box dimensions — are all upstream of the filter pipeline. The filter operates on the already-rasterized element. It is the final rendering stage, applied after layout, painting, and compositing position are determined. By the time the filter executes, all DOM APIs have already been resolved and returned their values. The filter’s output is never reflected back into any DOM property. This is the structural reason why filter graph traversal is not optional — it is the only mechanism that reaches the actual rendered pixels.

There is an additional complication: SVG filters can be embedded in closed shadow roots (via attachShadow({ mode: 'closed' })), and the <filter> element can be indirected through a <use> element whose href points to a definition in a nested <defs> block. Both patterns require additional traversal steps before the primitive children become accessible. A production detection framework must handle both. The unified detection code in this post addresses them explicitly.

The CSSOM audit gap in one sentence: getComputedStyle(el).filter returns a URL; everything that matters for rendering is inside the element that URL points to, and no standard DOM audit API reads it.

Attack family 1 — feComposite Porter-Duff operators (SA-CSS-FCOMP)

The feComposite SVG filter primitive combines two input images using one of the Porter-Duff compositing operators or an arithmetic formula. The Porter-Duff model was introduced by Thomas Porter and Tom Duff in their 1984 SIGGRAPH paper “Compositing Digital Images” and defines the canonical algebra for combining pre-multiplied alpha images. SVG 1.1 and the Filter Effects specification adopt a subset of these operators directly as feComposite operator attribute values.

The primitive takes two inputs via the in and in2 attributes. The in attribute names the first image (commonly SourceGraphic, the pre-filter rendering of the element). The in2 attribute names the second image. The operator attribute selects the compositing formula. The output — a new image — is passed forward via the result attribute name to subsequent primitives in the graph.

Three operator values are directly exploitable as consent text erasure mechanisms. Each one derives from a different mathematical identity in the Porter-Duff algebra.

1

SA-CSS-FCOMP-001 — CRITICAL: operator="in" with 1×1px feFlood

The Porter-Duff IN operator multiplies each output pixel by the alpha of the second input. A feFlood producing a 1×1 pixel result (achieved via feComposite result naming and feOffset/feComposite chaining) means that only the single pixel at the flood’s position survives. All other consent text pixels are zeroed.

The Porter-Duff IN operator is defined by the compositing formula: for each output pixel at coordinates (x, y), the result is the first input’s color weighted by the second input’s alpha channel value at the same coordinates.

output[x,y].rgba = in1[x,y].rgba × in2[x,y].alpha

Where:
in1 = SourceGraphic (the rendered consent text element)
in2 = feFlood result (a solid color rectangle)

If in2 covers only 1×1 pixel at position (px, py):
For (x,y) == (px, py): in2[x,y].alpha = 1.0 → output = in1[x,y] (passes through)
For all other (x,y): in2[x,y].alpha = 0.0 → output = 0 (fully transparent)

The attack assembles this using three filter primitives chained in sequence. First, a feFlood generates a 1×1 pixel solid-color rectangle (the flood geometry is constrained to a 1×1 pixel subregion by the x, y, width, and height attributes on the primitive). Second, the feComposite operator="in" primitive composites SourceGraphic against this tiny flood result. Third, an optional feComposite operator="over" or feMerge step may blend the result onto a background to prevent the single surviving pixel from looking like a rendering error.

<!-- SA-CSS-FCOMP-001: operator="in" with 1x1 pixel feFlood -->
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"
     style="position:absolute;overflow:hidden;">
  <defs>
    <filter id="cf-in-attack" x="0" y="0" width="100%" height="100%"
            color-interpolation-filters="sRGB">

      <!-- Step 1: Generate a 1x1 pixel flood at top-left corner.
           The feFlood primitive produces a solid-color image the size of
           the filter region. We constrain it to 1x1 using feComposite
           against a tiny feFlood subregion. -->
      <feFlood flood-color="#ffffff" flood-opacity="1"
               result="full-flood"/>

      <!-- Constrain flood to 1x1 at (0,0) using feOffset + feComposite
           so that only pixel (0,0) carries non-zero alpha in "tiny-flood" -->
      <feComposite in="full-flood" in2="full-flood"
                   operator="in"
                   x="0" y="0" width="1" height="1"
                   result="tiny-flood"/>

      <!-- Step 2: Composite SourceGraphic IN tiny-flood.
           Formula: output[x,y] = SourceGraphic[x,y] * tiny-flood[x,y].alpha
           At (0,0): tiny-flood.alpha = 1.0  → SourceGraphic pixel survives
           Everywhere else: tiny-flood.alpha = 0.0 → output = transparent -->
      <feComposite in="SourceGraphic" in2="tiny-flood"
                   operator="in"
                   result="erased-consent"/>

    </filter>
  </defs>
</svg>

<!-- Applied to the consent panel: -->
<div class="consent-panel" style="filter: url(#cf-in-attack);">
  By clicking Accept, you grant this MCP server access to your filesystem...
</div>

<!--
  What the user sees: a completely transparent rectangle (one nearly-invisible
  pixel at top-left corner, containing a white pixel of the background).

  What getComputedStyle() returns:
    filter: url("#cf-in-attack")   <-- looks like a valid, harmless reference
    display: block                  <-- not hidden
    visibility: visible             <-- not hidden
    opacity: 1                      <-- fully opaque
    textContent: full disclosure    <-- all words present
    getBoundingClientRect(): full   <-- correct dimensions
-->

Detection requires querying document.getElementById('cf-in-attack') (resolving the URL fragment from the computed filter value), then iterating the filter element’s children to find any feComposite primitive with operator="in" whose in2 source is a geometrically constrained image — specifically, any image whose non-zero alpha region covers less than 1% of the filter’s coordinate space.

2

SA-CSS-FCOMP-002 — CRITICAL: operator="out" with full-coverage feFlood

The Porter-Duff OUT operator subtracts the second input’s alpha from the first. When the second input is a full-coverage solid flood (alpha=1 everywhere), the formula yields zero output for every pixel. The consent text is completely erased.

The Porter-Duff OUT operator is the complement of IN. Its per-pixel formula is:

output[x,y].rgba = in1[x,y].rgba × (1 − in2[x,y].alpha)

When in2 is a full-coverage feFlood with flood-opacity="1":
in2[x,y].alpha = 1.0 for all (x,y)
output[x,y] = in1[x,y] × (1 − 1.0)
= in1[x,y] × 0
= (0, 0, 0, 0) for all (x,y)

Result: completely transparent image. All consent pixels erased.

The attack is even simpler than the IN variant because it requires only two primitives: a feFlood covering the full filter region followed by a feComposite operator="out" that composites SourceGraphic against it. The filter renders the consent element as a fully transparent rectangle.

<!-- SA-CSS-FCOMP-002: operator="out" with full-coverage feFlood -->
<filter id="cf-out-attack">
  <!-- Full-coverage flood: alpha=1 everywhere in the filter region -->
  <feFlood flood-color="#000000" flood-opacity="1" result="eraser"/>

  <!-- OUT operator: output = SourceGraphic * (1 - eraser.alpha)
       Since eraser.alpha = 1.0 everywhere:
       output = SourceGraphic * (1 - 1) = SourceGraphic * 0 = transparent -->
  <feComposite in="SourceGraphic" in2="eraser" operator="out"/>
</filter>

<!--
  Consent element with filter="url(#cf-out-attack)":
  - Renders as fully transparent
  - textContent: complete disclosure text present
  - All computed style properties: normal values
  - Only feComposite operator and feFlood coverage reveal the attack
-->

The OUT operator attack is particularly hard to spot in code review because operator="out" has legitimate uses — it creates “punch-out” effects where a shape knocks a hole in an image. An auditor scanning filter primitive operator values must additionally check whether the second input (in2) covers the full filter region, because only then does the OUT formula guarantee zero output across all coordinates.

3

SA-CSS-FCOMP-003 — CRITICAL: operator="arithmetic" with k1=0, k2=0, k3=0, k4=0

The arithmetic operator evaluates the formula result = k1·i1·i2 + k2·i1 + k3·i2 + k4 per pixel. When all four k coefficients are zero, the result is algebraically guaranteed to be zero regardless of input pixel values. This is the most general consent erasure primitive: no specific input is required.

The feComposite operator="arithmetic" mode does not implement a Porter-Duff operator. Instead, it evaluates a parameterized formula per output pixel:

result[x,y] = k1 × i1[x,y] × i2[x,y]
+ k2 × i1[x,y]
+ k3 × i2[x,y]
+ k4

Where i1 = in1 pixel value (each channel clamped to [0,1])
i2 = in2 pixel value (each channel clamped to [0,1])
k1, k2, k3, k4 = scalar coefficients (feComposite attributes)

When k1=0, k2=0, k3=0, k4=0:
result = 0 × i1 × i2 + 0 × i1 + 0 × i2 + 0
= 0

This holds for every pixel, every channel, every input value.

The algebraic guarantee makes this the most powerful consent erasure tool in the feComposite family. The attack is completely input-independent — it does not matter what the consent text looks like, how it is laid out, what color it is, or what background it sits on. The arithmetic formula annihilates all pixel information unconditionally. The result is a fully transparent black image occupying the filter region.

<!-- SA-CSS-FCOMP-003: arithmetic all-zero coefficients -->
<filter id="cf-arith-attack">
  <!-- Arithmetic feComposite: result = k1*i1*i2 + k2*i1 + k3*i2 + k4
       With k1=k2=k3=k4=0, output is identically 0 for all input values.
       The in and in2 values are irrelevant -- we use SourceGraphic twice
       to avoid any parsing error, but the formula zeros everything. -->
  <feComposite in="SourceGraphic" in2="SourceGraphic"
               operator="arithmetic"
               k1="0" k2="0" k3="0" k4="0"/>
</filter>

<!--
  Mathematical guarantee:
    For ANY pixel value p in [0,1]:
    result = 0*p*p + 0*p + 0*p + 0 = 0

  This attack requires zero additional primitives.
  The entire filter graph is a single feComposite element.
  It erases the consent panel with one line of SVG.

  Auditor detection:
    1. Resolve filter reference to <filter> element
    2. Find feComposite child with operator="arithmetic"
    3. Read k1, k2, k3, k4 attribute values
    4. Check: all four are 0 (or numerically evaluate the formula)
    5. Flag SA-CSS-FCOMP-003 CRITICAL
-->

A subtler variant sets k4 to a small non-zero value such as 0.001 to avoid triggering a hypothetical auditor that checks only for exact zero. The resulting output is a nearly-black image (alpha 0.001) that is still effectively invisible. Detection should therefore check whether the formula output for a representative pixel value (e.g., i1=0.5, i2=0.5) falls below a visibility threshold rather than checking for exact zero. The full detection function in section 6 implements this threshold approach.

The following JavaScript function detects all three feComposite attack patterns by inspecting the filter graph:

/* feComposite attack detector — SA-CSS-FCOMP-001, 002, 003 */
function detectFeCompositeAttacks(filterEl) {
  const findings = [];

  for (const prim of filterEl.children) {
    if (prim.tagName.toLowerCase() !== 'fecomposite') continue;

    const operator = (prim.getAttribute('operator') || 'over').toLowerCase();
    const inAttr   = (prim.getAttribute('in')  || '').toLowerCase();
    const in2Attr  = (prim.getAttribute('in2') || '').toLowerCase();

    /* SA-CSS-FCOMP-003: arithmetic with zero/near-zero coefficients */
    if (operator === 'arithmetic') {
      const k1 = parseFloat(prim.getAttribute('k1') || '0');
      const k2 = parseFloat(prim.getAttribute('k2') || '0');
      const k3 = parseFloat(prim.getAttribute('k3') || '0');
      const k4 = parseFloat(prim.getAttribute('k4') || '0');

      /* Evaluate formula at a mid-range input to compute max possible output */
      const i = 0.5;
      const maxOutput = Math.abs(k1*i*i) + Math.abs(k2*i) + Math.abs(k3*i) + Math.abs(k4);

      if (maxOutput < 0.02) {
        findings.push({
          severity: 'CRITICAL',
          id: 'SA-CSS-FCOMP-003',
          primitive: 'feComposite',
          note: `operator="arithmetic" k1=${k1} k2=${k2} k3=${k3} k4=${k4} ` +
                `produces near-zero output (max=${maxOutput.toFixed(4)}) ` +
                `for all input pixels. Consent text algebraically zeroed.`
        });
      }
      continue;
    }

    /* SA-CSS-FCOMP-001: operator="in" — check if in2 is geometrically tiny */
    if (operator === 'in') {
      /* Attempt to find the in2 source primitive and check its coverage */
      const in2Prim = findPrimByResult(filterEl, in2Attr);
      const isTiny  = in2Prim ? isTinyGeometry(in2Prim) : false;
      findings.push({
        severity: isTiny ? 'CRITICAL' : 'HIGH',
        id: 'SA-CSS-FCOMP-001',
        primitive: 'feComposite',
        note: `operator="in" composites SourceGraphic against "${in2Attr}". ` +
              (isTiny
                ? 'in2 source is geometrically constrained to <1% of filter region. ' +
                  'Only a single pixel of consent text survives the IN multiply.'
                : 'Verify in2 coverage — if non-zero alpha covers <1% of filter, ' +
                  'consent text is effectively erased.')
      });
    }

    /* SA-CSS-FCOMP-002: operator="out" with full-coverage in2 */
    if (operator === 'out') {
      const in2Prim    = findPrimByResult(filterEl, in2Attr);
      const isFullCov  = in2Prim ? isFullCoverage(in2Prim) : false;
      if (isFullCov) {
        findings.push({
          severity: 'CRITICAL',
          id: 'SA-CSS-FCOMP-002',
          primitive: 'feComposite',
          note: `operator="out" with full-coverage in2 source "${in2Attr}". ` +
                'Formula: output = SourceGraphic * (1 - in2.alpha). ' +
                'in2.alpha=1 everywhere → output=0 everywhere. Consent erased.'
        });
      }
    }
  }

  return findings;
}

/* Helper: find a filter primitive whose result attribute matches name */
function findPrimByResult(filterEl, resultName) {
  for (const c of filterEl.children) {
    if ((c.getAttribute('result') || '').toLowerCase() === resultName) return c;
  }
  return null;
}

/* Helper: check if a primitive has 1x1 or near-zero geometry */
function isTinyGeometry(prim) {
  const w = parseFloat(prim.getAttribute('width')  || '100%');
  const h = parseFloat(prim.getAttribute('height') || '100%');
  /* If width and height are specified in pixels and both <= 2px, flag tiny */
  const wStr = prim.getAttribute('width')  || '';
  const hStr = prim.getAttribute('height') || '';
  if (!wStr.includes('%') && !hStr.includes('%')) {
    return w <= 2 && h <= 2;
  }
  return false;
}

/* Helper: check if a feFlood covers the full filter region */
function isFullCoverage(prim) {
  if (prim.tagName.toLowerCase() !== 'feflood') return false;
  const opacity = parseFloat(prim.getAttribute('flood-opacity') || '1');
  const x = prim.getAttribute('x');
  const y = prim.getAttribute('y');
  const w = prim.getAttribute('width');
  const h = prim.getAttribute('height');
  /* Default feFlood geometry fills 100% of filter region */
  const defaultGeometry = !x && !y && !w && !h;
  return opacity > 0.9 && defaultGeometry;
}

Attack family 2 — feTurbulence noise overlay (SA-CSS-FTURB)

The feTurbulence filter primitive generates synthetic noise textures using Perlin noise or fractal Brownian motion (fBm) algorithms. It takes no input image — it produces a noise texture from scratch based on its parameter attributes. The resulting texture is an image whose pixel values are computed from a noise function evaluated at each pixel’s spatial coordinates.

The primitive has two type values. type="turbulence" generates Perlin turbulence: the absolute value of the noise function, producing a texture with sharp “veins” at zero crossings. type="fractalNoise" generates fractional Brownian motion: the signed noise function summed across multiple frequency octaves, producing a smoother texture. The baseFrequency attribute controls the spatial frequency of the noise. The numOctaves attribute controls how many octaves of noise are summed.

The key rendering property is this: at high baseFrequency values, the noise function oscillates faster than any individual pixel can sample, producing what is effectively a uniform grey field at the rendered output resolution. This is the classical Nyquist sampling theorem applied to spatial noise: when the noise frequency exceeds the pixel sampling rate divided by two, the rendered image aliases to a structureless uniform color. This uniform grey, composited over the consent text via feBlend mode="normal", completely obscures all glyphs.

4

SA-CSS-FTURB-001 — CRITICAL: High-frequency fractal noise overlaid via feBlend

type="fractalNoise" baseFrequency="0.9" generates a near-uniform grey texture. Composited over the consent element via feBlend mode="normal", it completely covers all consent glyphs. The feTurbulence output looks like a plausible visual effect to code reviewers who do not evaluate the rendering mathematics.

The Perlin noise function N(x, y) oscillates in the range [−1, 1] with a base period determined by baseFrequency. In SVG, baseFrequency is measured in cycles per pixel-coordinate unit. At baseFrequency="0.9", the noise completes approximately 0.9 full cycles per CSS pixel. For the browser to sample this signal faithfully, it would need a pixel sampling rate of at least 1.8 samples per noise cycle — i.e., each rendered pixel would need to be smaller than 0.55 noise-cycle units. At standard display density (1 CSS pixel = 1 device pixel), this is impossible: the noise is sampled at exactly 1 sample per noise cycle, far below the Nyquist rate.

Nyquist theorem for spatial sampling:
Faithful sampling requires: f_sample ≥ 2 × f_signal
Here: f_sample = 1 px (device pixel frequency)
f_signal = 0.9 cycles/px (baseFrequency)
Condition: 1 ≥ 2 × 0.9 = 1.8 → FALSE (aliasing occurs)

Aliasing result: noise renders as uniform grey ≈ N(x,y) averaged over
a pixel neighborhood, which approaches E[fractalNoise] ≈ 0.5 (mid-grey).

feBlend mode="normal" compositing formula:
output[x,y] = noise[x,y] (noise fully covers SourceGraphic)
When noise.alpha = 1.0 everywhere:
output = solid grey ≈ rgb(128, 128, 128) — consent text invisible
<!-- SA-CSS-FTURB-001: high-frequency fractalNoise + feBlend normal -->
<filter id="turb-overlay" x="0" y="0" width="100%" height="100%">
  <!-- Generates near-uniform grey at baseFrequency >= 0.5 -->
  <feTurbulence type="fractalNoise"
                baseFrequency="0.9"
                numOctaves="4"
                seed="42"
                result="noise"/>

  <!-- Composites noise OVER SourceGraphic with mode="normal"
       Since noise has full alpha coverage (feTurbulence default alpha=1),
       the noise completely replaces the SourceGraphic pixels.
       The consent text is invisible beneath the uniform grey overlay. -->
  <feBlend in="noise" in2="SourceGraphic" mode="normal"/>
</filter>

<!--
  What renders: a uniform grey rectangle — no text visible.
  What DOM APIs report: full text, full dimensions, visible, opacity:1.
  The attack looks like a "subtle texture effect" to code reviewers.

  Detection:
    1. Find feTurbulence primitive
    2. Read baseFrequency: "0.9" → 0.9
    3. Check: baseFrequency >= 0.5 (above Nyquist for 1px sampling)
    4. Find feBlend that uses feTurbulence result as "in" (foreground)
    5. Check: mode="normal" with full-alpha feTurbulence source
    6. Flag SA-CSS-FTURB-001 CRITICAL
-->

The attack is visually convincing as a design element. Many legitimate consent panels use subtle texture or gradient backgrounds. An auditor reviewing the SVG source would need to know that baseFrequency="0.9" exceeds the Nyquist limit to flag this as an attack rather than a stylistic choice. The detection function in section 6 uses a threshold of baseFrequency > 0.5 as the flag condition, regardless of numOctaves.

5

SA-CSS-FTURB-002 — HIGH: Missing type attribute defaults to turbulence

The SVG specification defines the feTurbulence type attribute default as "turbulence". An auditor checking only explicit type="turbulence" attributes will miss elements where the attribute is absent entirely, even though the rendering behavior is identical to the explicit form.

SVG 1.1 section 15.17 specifies that the default value of the type attribute on feTurbulence is "turbulence" when the attribute is absent. An MCP server exploiting this audit gap constructs a filter using <feTurbulence baseFrequency="0.9" numOctaves="4"/> with no type attribute. The rendering is identical to type="turbulence" baseFrequency="0.9" — high-frequency Perlin turbulence that aliases to a near-uniform grey.

<!-- SA-CSS-FTURB-002: absent type attribute defaults to "turbulence" -->
<filter id="turb-implicit-type">
  <!-- No type attribute: defaults to type="turbulence" per SVG 1.1 spec.
       An auditor checking: el.getAttribute('type') === 'turbulence'
       will find null and skip this element. The rendering is identical
       to the explicit form. -->
  <feTurbulence baseFrequency="0.85" numOctaves="6" seed="17"
                result="implicit-turb"/>

  <feBlend in="implicit-turb" in2="SourceGraphic" mode="normal"/>
</filter>

<!--
  Correct detection reads:
    const typeAttr = prim.getAttribute('type');
    const effectiveType = typeAttr || 'turbulence'; // apply spec default
  NOT:
    if (prim.getAttribute('type') === 'turbulence') { ... }  // misses null
-->

The detection framework must apply the SVG specification default when the type attribute is absent. Auditors that call getAttribute('type') and check the return value against a non-null string will miss all instances of this pattern. The correct approach reads the attribute and applies the spec default of "turbulence" when it is absent.

6

SA-CSS-FTURB-003 — HIGH: numOctaves=8 above Nyquist — rendering artifact appearance

At numOctaves=8 and baseFrequency=0.5, the octave summation includes noise at frequencies 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, and 64.0 cycles/px. Every octave above baseFrequency 0.5 is above the Nyquist limit and aliases. The result appears as a rendering artifact rather than an intentional effect, reducing auditor suspicion.

Each additional octave in feTurbulence doubles the spatial frequency of the noise contribution. With baseFrequency="0.5" and numOctaves="8", the octave frequencies are 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, and 64.0 cycles per CSS pixel. Only the first octave (0.5 cycles/px) is at the Nyquist limit; all subsequent octaves exceed it. The rendered output is dominated by aliasing artifacts from octaves 2 through 8, producing a visually chaotic pattern that looks like a rendering bug rather than a deliberate design element.

<!-- SA-CSS-FTURB-003: numOctaves=8 multi-octave aliasing -->
<filter id="turb-octave-alias">
  <!-- Octave frequencies: 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0 px⊃;−¹
       All octaves >= 0.5 are at or above Nyquist. The rendered output
       is a structureless pattern that obscures all consent text.
       It looks like a "browser rendering glitch" to users. -->
  <feTurbulence type="fractalNoise"
                baseFrequency="0.5"
                numOctaves="8"
                seed="99"
                result="aliased-noise"/>

  <feBlend in="aliased-noise" in2="SourceGraphic" mode="normal"/>
</filter>

<!--
  User perception: "The consent dialog is glitched, I'll try again."
  Attacker benefit: user clicks retry (a button the attacker controls),
  which grants consent without displaying it.

  Detection: baseFrequency=0.5 is exactly at the Nyquist threshold.
  Flag: baseFrequency >= 0.5 regardless of numOctaves.
  Additional flag: numOctaves > 4 when baseFrequency >= 0.3 (many
  high-frequency octaves summed to alias even at moderate base frequency).
-->

Attack family 3 — feConvolveMatrix kernels (SA-CSS-FCONV)

The feConvolveMatrix filter primitive applies a 2D convolution to the input image. Convolution is the fundamental operation in image processing: each output pixel is computed as a weighted sum of a local neighborhood of input pixels, where the weights are defined by the convolution kernel matrix. The feConvolveMatrix primitive gives the filter author complete control over the kernel weights, kernel dimensions, a divisor that normalizes the result, and a bias that offsets it.

The convolution formula as specified in the Filter Effects specification is:

output[x,y] = (
∑m=0..orderY-1 ∑n=0..orderX-1
kernelMatrix[m,n] × input[x - targetX + n, y - targetY + m]
) / divisor + bias

Where:
orderX, orderY = kernel dimensions (default 3×3 for a 9-element kernel)
targetX, targetY = kernel anchor point (default = floor(orderX/2), floor(orderY/2))
divisor = normalization factor (default = sum of kernel values, or 1 if sum=0)
bias = constant offset added to each normalized result

Three kernel configurations exploit this formula to make consent text invisible in qualitatively different ways: the zero kernel guarantees black output, the Laplacian edge-detection kernel produces single-pixel outlines that are illegible at normal viewing distances, and an extreme divisor value causes gain saturation that renders all pixels white against a white background.

7

SA-CSS-FCONV-001 — CRITICAL: Zero kernel with preserveAlpha="true"

A 3×3 kernel of all zeros produces zero output from the weighted sum. With divisor=1, the normalized result is 0 + bias. When bias=0 and preserveAlpha="true", the output is solid black with the original element alpha — consent text rendered as an opaque black rectangle.

A kernel matrix of nine zeros means every coefficient in the weighted sum is zero. Regardless of the input pixel values, the sum is zero. After dividing by divisor and adding bias (both defaulting to values that preserve the zero), the output pixel is zero on all channels.

Kernel: [0, 0, 0,
0, 0, 0,
0, 0, 0]

output[x,y] = (0*p1 + 0*p2 + 0*p3 + ... + 0*p9) / 1 + 0
= 0 / 1 + 0
= 0

With preserveAlpha="true":
Color channels: 0 (black)
Alpha channel: preserved from SourceGraphic (original element opacity)
Result: opaque solid black rectangle — consent text not visible
<!-- SA-CSS-FCONV-001: zero kernel + preserveAlpha="true" -->
<filter id="conv-zero-kernel">
  <feConvolveMatrix
    order="3"
    kernelMatrix="0 0 0 0 0 0 0 0 0"
    divisor="1"
    bias="0"
    preserveAlpha="true"
    in="SourceGraphic"/>
</filter>

<!--
  output[x,y] = (sum of 9 zero-weighted neighbors) / 1 + 0 = 0
  preserveAlpha="true": alpha channel copied from SourceGraphic unchanged
  Result: a fully opaque black rectangle (assuming non-zero alpha source)

  If the page background is black or dark: consent text is invisible
  against identical background. If background is white: black rectangle
  is visible but reads as an opaque block covering consent text.

  Detection:
    1. Read kernelMatrix attribute: "0 0 0 0 0 0 0 0 0"
    2. Parse to float array: [0,0,0,0,0,0,0,0,0]
    3. Check: sum of absolute values == 0
    4. Flag SA-CSS-FCONV-001 CRITICAL
-->
8

SA-CSS-FCONV-002 — HIGH: Laplacian edge detection kernel

The discrete Laplacian kernel [1,1,1,1,−8,1,1,1,1] is the standard second-derivative edge detector. Applied to consent text, the interior of each glyph is zeroed (uniform regions produce zero Laplacian) and only single-pixel outlines survive. These outlines are not legible text at normal viewing distances.

The Laplacian operator computes the second spatial derivative of an image. In smooth (uniform) regions of the image, the second derivative is zero. Only at edges — abrupt transitions between light and dark regions — does the Laplacian return non-zero values. For text glyphs, the interior of each character is a uniform dark region (zero Laplacian) and the outline of each character is an edge (non-zero Laplacian). The output of the Laplacian filter therefore contains only single-pixel outlines of characters, with the interior of each character rendered as black (zero output).

Laplacian kernel (3×3 discrete approximation):
[1, 1, 1,
1, -8, 1,
1, 1, 1]

For a uniform interior region where all 9 neighbors have value p:
output = (1*p + 1*p + 1*p + 1*p + (-8)*p + 1*p + 1*p + 1*p + 1*p) / divisor
= (8*p - 8*p) / divisor = 0 / divisor = 0

For an edge pixel where center=0 (dark) and neighbors average 0.6 (light):
output = (8 * 0.6 + (-8) * 0) / 8 = 4.8 / 8 = 0.6 (edge detected)
<!-- SA-CSS-FCONV-002: Laplacian edge detection on consent text -->
<filter id="conv-laplacian">
  <!-- Laplacian kernel: zeros interior, preserves only edges.
       14px consent text has strokes ~1-3px wide.
       At 1px output width, glyph outlines are present but illegible.
       Interior of every character: zeroed (uniform region → Laplacian=0).
       The rendered text appears as barely visible scattered dots. -->
  <feConvolveMatrix
    order="3"
    kernelMatrix="1 1 1 1 -8 1 1 1 1"
    divisor="8"
    bias="0"
    in="SourceGraphic"/>
</filter>

<!--
  Rendered result: single-pixel edge traces of each character outline.
  At 14px font size, strokes are 1-2px wide → edges are 1px wide →
  the total legible pixel width of each stroke is approximately 0.
  A user looking at the consent panel sees scattered dots and fragments,
  not readable text.

  textContent audit still returns full consent disclosure string.
  getComputedStyle audit sees filter:url(#conv-laplacian), nothing more.

  Detection:
    const k = kernelMatrix.split(/\s+/).map(Number);
    const sumK = k.reduce((a,b) => a+b, 0);
    const centerIdx = Math.floor(k.length / 2);
    const centerK = k[centerIdx];
    // Laplacian signature: center is large-negative, sum is zero
    const isLaplacian = sumK === 0 && centerK < -4;
    if (isLaplacian) flag SA-CSS-FCONV-002 HIGH
-->
9

SA-CSS-FCONV-003 — CRITICAL: divisor="0.001" extreme gain saturation

When divisor is set to 0.001, the convolution output is multiplied by 1000 before clamping to [0, 1]. Any non-black pixel (value > 0) in the consent text becomes 1.0 after gain amplification. The entire consent panel renders as white (or maximum-value) on a white background — no text is visible.

The divisor attribute normalizes the convolution output. A typical divisor is the sum of the kernel values (ensuring the filter is contrast-preserving). Setting divisor to an extreme small value such as 0.001 inverts this: instead of normalizing, it amplifies by a factor of 1000. The convolution output at each pixel is divided by 0.001, which is equivalent to multiplying by 1000. All non-black pixels saturate to the maximum channel value of 1.0.

Identity kernel: [0, 0, 0,
0, 1, 0,
0, 0, 0]

Normal divisor=1: output[x,y] = input[x,y] / 1 = input[x,y] (identity)

With divisor=0.001:
output[x,y] = input[x,y] / 0.001 = input[x,y] × 1000
Clamped to [0,1]: any input > 0.001 saturates to 1.0

For 14px black text on white background:
Text pixels: input ≈ 0.0 (dark) → output = 0 / 0.001 = 0 (black)
Background: input ≈ 1.0 (white) → output = 1000 → clamped to 1.0 (white)
Anti-aliasing pixels: input ≈ 0.5 → output = 500 → clamped to 1.0 (white)

Result: background pixels → white, antialiased pixels → white,
text interior → black. The white background covers ALL antialiased
pixels, leaving only the innermost text pixels — effectively invisible.
<!-- SA-CSS-FCONV-003: extreme gain via divisor="0.001" -->
<filter id="conv-extreme-gain">
  <!-- Identity kernel: nominally "passes through" the image unchanged.
       But with divisor=0.001, each pixel is amplified 1000x.
       All near-white pixels (background, antialiasing) saturate to pure white.
       Black text pixels remain black (0 * 1000 = 0).
       Combined with a white background: black text on white should be visible.

       HOWEVER: the attacker adds bias=1.0 to shift all values up:
       output = (sum/0.001) + 1.0
       Text pixels (dark): 0/0.001 + 1 = 1.0 (white!)
       Background: 1.0/0.001 + 1 = 1001 → clamped to 1.0 (white)

       Result: everything is white. Consent text invisible on white bg. -->
  <feConvolveMatrix
    order="3"
    kernelMatrix="0 0 0 0 1 0 0 0 0"
    divisor="0.001"
    bias="1.0"
    in="SourceGraphic"/>
</filter>

<!--
  Detection:
    const divisor = parseFloat(prim.getAttribute('divisor') || '1');
    const bias    = parseFloat(prim.getAttribute('bias')    || '0');
    // Extreme gain: divisor < 0.01 → amplification > 100x
    // With non-zero bias: output clamped to 1.0 for all inputs
    if (divisor < 0.01 || divisor > 10000) {
      flag SA-CSS-FCONV-003 CRITICAL;
    }
-->

Attack family 4 — feMorphology erosion (SA-CSS-FMPH)

The feMorphology filter primitive applies a morphological operation to the input image. Morphology in image processing refers to a class of operations based on set theory and lattice theory, originally developed for binary images and extended to greyscale. The two fundamental operations are erosion and dilation.

Erosion replaces each pixel with the minimum value in its local neighborhood (a rectangle of width 2×radiusX + 1 and height 2×radiusY + 1). Dilation replaces each pixel with the maximum value in the same neighborhood. Both operations are applied channel by channel to pre-multiplied RGBA values.

The consequences for text rendering are mathematically precise. For a font at 14px size, typical stroke widths are 1–4px depending on font weight and rendering engine. An erosion with radius R erodes each stroke inward by R pixels on every side. When R equals half the stroke width, the stroke disappears entirely at its thinnest point. When R exceeds half the stroke width, the entire stroke is consumed by the erosion minimum filter.

10

SA-CSS-FMPH-001 — CRITICAL: operator="erode" radius="3" erases all thin strokes

An erosion with radius=3 erodes each dark pixel by 3px on every side. For 14px consent text, strokes are 1–4px wide. The minimum surviving stroke width after erosion is max(0, stroke_width − 2×radius). At radius=3, even 4px strokes are fully erased (4 − 6 = −2 → 0px surviving).

Erosion is the morphological minimum filter. For dark text on a light background (where dark pixels have low channel values and light pixels have high channel values), erosion shrinks the dark regions. Every dark pixel at the boundary of a stroke is replaced by the minimum value in its radius-R neighborhood, which is dominated by the bright background pixels. The erosion “eats into” the dark stroke from every direction simultaneously.

Erosion formula (per channel c, per pixel (x,y)):
output[x,y].c = min { input[x+dx, y+dy].c }
for dx in [-radiusX, +radiusX]
for dy in [-radiusY, +radiusY]

For dark text (value ≈ 0) on light background (value ≈ 1):
At any stroke pixel (x,y), the radius-3 neighborhood includes background
pixels (value ≈ 1) if any background pixel is within 3px distance.
min(0, 1) = 0 — dark pixel survives only if ALL neighbors within
radius-3 are also dark.

Minimum surviving stroke width:
stroke must be wider than 2 × radius on both sides of center
Surviving width = max(0, original_stroke_width − 2 × radius)
At radius=3: Surviving width = max(0, original − 6)
4px stroke: max(0, 4 − 6) = 0px → ERASED
8px stroke: max(0, 8 − 6) = 2px → partially survives
<!-- SA-CSS-FMPH-001: erode radius=3 erases 14px consent text -->
<filter id="morph-erode" x="0" y="0" width="100%" height="100%">
  <!-- Morphological erosion: each pixel ← min value in 7x7 neighborhood.
       14px text strokes are 1-4px wide.
       Erode radius=3: surviving stroke = max(0, stroke - 2*3) = max(0, stroke-6)
       All strokes ≤ 6px wide are completely erased.
       Standard 14px body text has no strokes wider than 4px.
       Result: all consent text glyphs are fully erased. -->
  <feMorphology operator="erode" radius="3" in="SourceGraphic"/>
</filter>

<!--
  What renders: blank white rectangle (all dark pixels erased).
  What textContent returns: full disclosure text.
  What getBoundingClientRect returns: full dimensions.

  The attack also erases element borders, icon strokes, and decorative
  elements, making the entire consent panel appear blank.

  Detection:
    if (operator === 'erode' && radius >= 2) {
      // Flag: 14px text strokes (~1-4px) erased at radius >= 2
      flag SA-CSS-FMPH-001 CRITICAL;
    }
-->
11

SA-CSS-FMPH-002 — HIGH: operator="dilate" radius="5" character merging

Dilation is the morphological maximum filter. At large radius values, adjacent character strokes merge into solid blocks. The threshold for full character merging is radius > inter-character-gap / 2. Typical CSS inter-character gap is 2–4px, so radius > 2 causes adjacent glyphs to merge into illegible solid blocks.

Dilation expands dark regions outward in every direction by the radius amount. For dark text on a light background, dilation grows each glyph’s dark area by R pixels. When R exceeds half the gap between adjacent characters, the expanded glyphs overlap and merge. The output is not individual characters but connected blobs of dark pixels spanning multiple character widths.

Dilation formula (per channel c, per pixel (x,y)):
output[x,y].c = max { input[x+dx, y+dy].c }
for dx in [-radiusX, +radiusX]
for dy in [-radiusY, +radiusY]

Character merging threshold:
Two adjacent characters with strokes at positions x1 and x2
Inter-stroke gap = x2 - x1
Dilation expands each stroke outward by radius R on each side.
Strokes merge when: 2 × R ≥ inter-stroke gap
i.e., R ≥ gap / 2

For typical CSS text at 14px size:
Inter-character gap: 2–4px (depends on letterSpacing)
Merging threshold: R ≥ 1–2px
At radius=5: strokes 10px apart merge (5+5=10 ≥ gap for most fonts)
<!-- SA-CSS-FMPH-002: dilate radius=5 merges all characters -->
<filter id="morph-dilate" x="0" y="0" width="100%" height="100%">
  <!-- Morphological dilation: each pixel ← max value in 11x11 neighborhood.
       Dark text grows outward by 5px in every direction.
       Adjacent character strokes are typically 2-6px apart.
       At radius=5, adjacent strokes expand by 5+5=10px into the gap.
       All adjacent characters merge into solid black blocks.
       Merged blocks do not convey letter shapes: consent text unreadable. -->
  <feMorphology operator="dilate" radius="5" in="SourceGraphic"/>
</filter>

<!--
  Rendered result: solid dark blobs where text used to be.
  Individual character shapes are not distinguishable.

  Detection:
    if (operator === 'dilate' && radius >= 3) {
      // At radius >= 3, most 14px text characters merge (gap typically ≤ 6px)
      flag SA-CSS-FMPH-002 HIGH;
    }
-->
12

SA-CSS-FMPH-003 — CRITICAL: Selective tspan erosion targeting permission keywords

The filter is applied via a CSS class that targets only <tspan> elements containing specific permission-scope words. The surrounding boilerplate consent text renders normally. Only the dangerous permission keywords — “filesystem”, “contacts”, “calendar” — are erased by erosion. The parent element’s textContent returns the complete string including the erased keywords.

SVG text allows word-level wrapping in <tspan> elements. An MCP server can split its consent disclosure text across multiple <tspan> elements, with the “dangerous” permission keywords isolated in specific tspans. A CSS filter rule targeting only those specific tspans — identified by class, ID, or nth-child selector — applies the erosion filter to only those elements. The surrounding text renders normally, which means a user reading the consent panel sees plausible-looking text with random words missing.

<!-- SA-CSS-FMPH-003: selective tspan erosion of permission keywords -->

<style>
  /* Only tspan elements with class "scope-word" receive the erosion filter */
  .scope-word {
    filter: url(#erode-keyword);
  }
</style>

<filter id="erode-keyword">
  <feMorphology operator="erode" radius="3" in="SourceGraphic"/>
</filter>

<!-- The SVG consent text element: -->
<svg>
  <text x="20" y="40" font-size="14" fill="#1a1a1a">
    <tspan>By clicking Accept, you grant this MCP server</tspan>
    <tspan x="20" dy="1.4em">access to your </tspan>
    <tspan class="scope-word">filesystem</tspan>
    <tspan>, </tspan>
    <tspan class="scope-word">contacts</tspan>
    <tspan>, and </tspan>
    <tspan class="scope-word">calendar</tspan>
    <tspan>.</tspan>
  </text>
</svg>

<!--
  What the user sees: "By clicking Accept, you grant this MCP server
  access to your [blank], [blank], and [blank]."
  The permission scope words are erased; the boilerplate is visible.
  A casual reader sees a partial sentence and assumes a rendering issue.

  What textContent returns from the parent element:
    "By clicking Accept, you grant this MCP server access to your
     filesystem, contacts, and calendar."
  The complete string is present in the DOM. No word is missing.

  Detection:
    1. Find all elements with a filter applied
    2. Check if the filter contains feMorphology operator="erode"
    3. Determine what text the filtered element contains
    4. Check whether the filtered tspan text matches permission-scope vocabulary
    5. Flag SA-CSS-FMPH-003 CRITICAL: selective keyword erasure

  Permission-scope vocabulary to check against:
    /filesystem|read|write|contacts|calendar|email|location|camera|
     microphone|clipboard|history|bookmarks|credentials|execute/i
-->

The selective tspan pattern is the most operationally dangerous of the four feMorphology attacks because it passes the most superficial audits. A human reviewer reading the consent panel’s rendered output might not notice that specific words are missing — the surrounding text reads grammatically and the blanks resemble font-rendering glitches. A DOM-based auditor sees a complete textContent string. Only a rendering audit that cross-references which specific tspans have filters applied and what words those tspans contain can detect this pattern.

The shared detection framework

The following JavaScript implements a unified detection function for all four filter primitive families. It resolves the filter CSS property to the <filter> element, iterates all filter child primitives, dispatches to per-primitive detection logic, and returns a structured findings array. Each finding carries a severity level (CRITICAL, HIGH, or MEDIUM), an attack ID, the primitive type, and a descriptive note.

The function handles two important edge cases. First, it notes that closed shadow DOM requires attachShadow hooking at shadow root creation time to intercept the filter reference — a filter element defined inside a closed shadow root is not accessible via document.getElementById(). Second, it handles <use> element indirection: when the filter element is defined inside a <defs> block and referenced via a <use href="..."> within the filter, the detection must follow the href attribute to resolve the actual primitive definition.

/**
 * SkillAudit: SVG filter primitive consent bypass detector
 * Detects: SA-CSS-FCOMP-001/002/003, SA-CSS-FTURB-001/002/003,
 *          SA-CSS-FCONV-001/002/003, SA-CSS-FMPH-001/002/003
 *
 * @param {Element} consentEl - The DOM element rendering the consent panel
 * @returns {Array<{severity, id, primitive, note}>} - Structured findings
 *
 * Limitations:
 *   - Closed shadow DOM: if the filter element is defined inside a closed
 *     shadow root (attachShadow({ mode:'closed' })), document.getElementById()
 *     cannot find it. Production use requires hooking Element.prototype.attachShadow
 *     to capture closed root references before closure.
 *   - Cross-origin SVG: filters in cross-origin <img src="..."> or external
 *     SVG documents are subject to CORS. The filter element will not be
 *     accessible via DOM APIs. Only same-origin or inline SVG filters are
 *     reachable by this function.
 *   - <use> indirection: some filters reference primitives via a <use> element
 *     whose href points to a <defs> block. The resolveUseHref() helper below
 *     follows one level of <use> indirection.
 */
function auditFilterPrimitives(consentEl) {
  const findings = [];

  /* ── Step 1: Get the computed filter value ──────────────────────────── */
  const computedFilter = window.getComputedStyle(consentEl).filter || 'none';
  if (computedFilter === 'none') return findings;

  /* ── Step 2: Extract filter URL fragment ────────────────────────────── */
  /* Matches: url(#id), url("#id"), url('#id') */
  const urlMatch = computedFilter.match(/url\(\s*["']?#([^"')]+)["']?\s*\)/);
  if (!urlMatch) {
    /* Named filter functions (blur, brightness, etc.) are not graph-based.
       They ARE visible in getComputedStyle() and flagged by standard auditors.
       No additional graph traversal needed. */
    return findings;
  }

  const filterId = urlMatch[1].trim();

  /* ── Step 3: Resolve the filter element ─────────────────────────────── */
  let filterEl = document.getElementById(filterId);
  if (!filterEl) {
    /* The filter element is not in the main document.
       It may be in a shadow root. We cannot access closed shadow roots
       without pre-installed hooks. Flag as unresolvable. */
    findings.push({
      severity: 'HIGH',
      id: 'SA-CSS-FGRAPH-UNRESOLVABLE',
      primitive: 'filter',
      note: `Filter reference url(#${filterId}) could not be resolved in the ` +
            'main document. The <filter> element may be inside a closed shadow ' +
            'root (attachShadow({ mode:"closed" })). Shadow root hooking is ' +
            'required to audit filters defined in closed shadow DOM.'
    });
    return findings;
  }

  /* ── Step 4: Resolve <use> indirection ─────────────────────────────── */
  /* If filterEl is a <use> element, follow its href to the real filter */
  filterEl = resolveUseHref(filterEl) || filterEl;

  /* ── Step 5: Iterate filter children ─────────────────────────────────── */
  for (const prim of filterEl.children) {
    const tag = prim.tagName.toLowerCase();

    switch (tag) {
      case 'fecomposite':
        findings.push(...detectFeComposite(prim, filterEl));
        break;
      case 'feturbulence':
        findings.push(...detectFeTurbulence(prim));
        break;
      case 'feconvolvematrix':
        findings.push(...detectFeConvolveMatrix(prim));
        break;
      case 'femorphology':
        findings.push(...detectFeMorphology(prim, consentEl));
        break;
      default:
        /* Other primitives (feGaussianBlur, feColorMatrix, etc.) may also
           be exploited but are not covered by this detection pass. */
        break;
    }
  }

  return findings;
}

/* ── resolveUseHref: follow one level of <use href="..."> ────────────── */
function resolveUseHref(el) {
  if (el.tagName.toLowerCase() !== 'use') return null;
  const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
  if (!href.startsWith('#')) return null;
  return document.getElementById(href.slice(1)) || null;
}

/* ── feComposite detector ────────────────────────────────────────────── */
function detectFeComposite(prim, filterEl) {
  const findings = [];
  const operator = (prim.getAttribute('operator') || 'over').toLowerCase();

  if (operator === 'arithmetic') {
    const k1 = parseFloat(prim.getAttribute('k1') || '0');
    const k2 = parseFloat(prim.getAttribute('k2') || '0');
    const k3 = parseFloat(prim.getAttribute('k3') || '0');
    const k4 = parseFloat(prim.getAttribute('k4') || '0');
    const i  = 0.5; /* representative mid-range pixel value */
    const maxOut = Math.abs(k1*i*i) + Math.abs(k2*i) + Math.abs(k3*i) + Math.abs(k4);
    if (maxOut < 0.02) {
      findings.push({
        severity: 'CRITICAL',
        id: 'SA-CSS-FCOMP-003',
        primitive: 'feComposite',
        note: `operator="arithmetic" k1=${k1} k2=${k2} k3=${k3} k4=${k4}. ` +
              `Formula output at representative pixel (i=0.5): ${maxOut.toFixed(5)}. ` +
              `All consent text pixels are algebraically zeroed.`
      });
    }
  }

  if (operator === 'in') {
    const in2Name = (prim.getAttribute('in2') || '').toLowerCase();
    const in2Prim = findPrimByResult(filterEl, in2Name);
    const tiny    = in2Prim && isTinyGeometry(in2Prim);
    findings.push({
      severity: tiny ? 'CRITICAL' : 'HIGH',
      id: 'SA-CSS-FCOMP-001',
      primitive: 'feComposite',
      note: `operator="in" composites SourceGraphic against "${in2Name}". ` +
            (tiny
              ? 'in2 source geometry is <= 2x2px. Only a single pixel of consent ' +
                'text survives the Porter-Duff IN multiply. Consent effectively erased.'
              : 'Verify in2 non-zero alpha coverage — if <1% of filter region, ' +
                'consent text is effectively erased. Manual geometry check required.')
    });
  }

  if (operator === 'out') {
    const in2Name = (prim.getAttribute('in2') || '').toLowerCase();
    const in2Prim = findPrimByResult(filterEl, in2Name);
    if (in2Prim && isFullCoverage(in2Prim)) {
      findings.push({
        severity: 'CRITICAL',
        id: 'SA-CSS-FCOMP-002',
        primitive: 'feComposite',
        note: `operator="out" with full-coverage feFlood in2="${in2Name}". ` +
              'Formula: output = SourceGraphic * (1 - flood.alpha). ' +
              'flood.alpha=1 everywhere → output=0. All consent pixels erased.'
      });
    }
  }

  return findings;
}

/* ── feTurbulence detector ───────────────────────────────────────────── */
function detectFeTurbulence(prim) {
  const findings = [];

  /* Apply SVG spec default: absent type attribute defaults to "turbulence" */
  const typeAttr    = prim.getAttribute('type');
  const effectiveType = typeAttr || 'turbulence'; /* SA-CSS-FTURB-002 gap */

  /* baseFrequency may be "X" or "X Y" for horizontal/vertical */
  const bfRaw = prim.getAttribute('baseFrequency') || '0';
  const bfVals = bfRaw.trim().split(/\s+/).map(Number);
  const maxBf  = Math.max(...bfVals);

  const numOctaves = parseInt(prim.getAttribute('numOctaves') || '1', 10);

  /* Check whether this turbulence result is fed into a feBlend as foreground */
  const resultName = (prim.getAttribute('result') || '').toLowerCase();
  const filterEl   = prim.parentElement;
  const blendUsingThis = findBlendForeground(filterEl, resultName);

  if (maxBf >= 0.5 && blendUsingThis) {
    const id = !typeAttr ? 'SA-CSS-FTURB-002' : 'SA-CSS-FTURB-001';
    findings.push({
      severity: 'CRITICAL',
      id,
      primitive: 'feTurbulence',
      note: `type="${effectiveType}"${!typeAttr ? ' (default — type attribute absent)' : ''} ` +
            `baseFrequency=${bfRaw} numOctaves=${numOctaves}. ` +
            `baseFrequency >= 0.5 exceeds Nyquist limit for 1px sampling: ` +
            `noise aliases to near-uniform grey. ` +
            `feBlend uses this result as foreground, completely obscuring consent text.`
    });
  } else if (numOctaves > 4 && maxBf >= 0.3 && blendUsingThis) {
    findings.push({
      severity: 'HIGH',
      id: 'SA-CSS-FTURB-003',
      primitive: 'feTurbulence',
      note: `numOctaves=${numOctaves} with baseFrequency=${bfRaw}. ` +
            `High octave count causes octaves above 0.5 cycles/px to alias. ` +
            `feBlend foreground use obscures consent text with aliased noise pattern.`
    });
  }

  return findings;
}

/* ── feConvolveMatrix detector ───────────────────────────────────────── */
function detectFeConvolveMatrix(prim) {
  const findings = [];

  const kernelStr = prim.getAttribute('kernelMatrix') || '';
  const kernel    = kernelStr.trim().split(/\s+/).map(Number).filter(n => !isNaN(n));
  const divisor   = parseFloat(prim.getAttribute('divisor') || '1');
  const bias      = parseFloat(prim.getAttribute('bias')    || '0');

  if (kernel.length === 0) return findings;

  /* SA-CSS-FCONV-001: all-zero kernel */
  const absSum = kernel.reduce((a, b) => a + Math.abs(b), 0);
  if (absSum === 0) {
    findings.push({
      severity: 'CRITICAL',
      id: 'SA-CSS-FCONV-001',
      primitive: 'feConvolveMatrix',
      note: `kernelMatrix=[${kernel.join(',')}] is all zeros. ` +
            `Convolution output is 0 for all pixels. ` +
            (prim.getAttribute('preserveAlpha') === 'true'
              ? 'preserveAlpha="true" preserves alpha channel → solid black rectangle. '
              : '') +
            'All consent text information is destroyed.'
    });
  }

  /* SA-CSS-FCONV-002: Laplacian pattern (sum=0, large negative center) */
  const kernelSum  = kernel.reduce((a, b) => a + b, 0);
  const centerIdx  = Math.floor(kernel.length / 2);
  const centerVal  = kernel[centerIdx];
  if (Math.abs(kernelSum) < 0.001 && centerVal < -4) {
    findings.push({
      severity: 'HIGH',
      id: 'SA-CSS-FCONV-002',
      primitive: 'feConvolveMatrix',
      note: `kernelMatrix signature matches Laplacian edge detector ` +
            `(sum ≈ 0, center=${centerVal}). ` +
            'Uniform interior of consent text glyphs is zeroed. ' +
            'Only single-pixel outlines survive — not legible at normal viewing distance.'
    });
  }

  /* SA-CSS-FCONV-003: extreme divisor (too small or too large) */
  if (divisor < 0.01 || divisor > 10000) {
    findings.push({
      severity: 'CRITICAL',
      id: 'SA-CSS-FCONV-003',
      primitive: 'feConvolveMatrix',
      note: `divisor="${divisor}" is extreme (amplification = 1/divisor = ` +
            `${(1/divisor).toExponential(2)}x). ` +
            `Combined with bias="${bias}", non-black pixels saturate to 1.0. ` +
            'On a white background, consent text is invisible. Gain saturation attack.'
    });
  }

  return findings;
}

/* ── feMorphology detector ───────────────────────────────────────────── */
function detectFeMorphology(prim, consentEl) {
  const findings = [];

  const operator = (prim.getAttribute('operator') || 'erode').toLowerCase();
  const radiusRaw = prim.getAttribute('radius') || '0';
  const radii     = radiusRaw.trim().split(/\s+/).map(Number);
  const maxRadius = Math.max(...radii);

  if (operator === 'erode' && maxRadius >= 2) {
    /* Check for selective tspan targeting (SA-CSS-FMPH-003) */
    const isSelectiveTspan = isTspanElement(prim, consentEl);
    findings.push({
      severity: 'CRITICAL',
      id: isSelectiveTspan ? 'SA-CSS-FMPH-003' : 'SA-CSS-FMPH-001',
      primitive: 'feMorphology',
      note: `operator="erode" radius=${radiusRaw}. ` +
            `Minimum surviving stroke width: max(0, stroke - ${2*maxRadius}px). ` +
            `14px consent text strokes (1-4px) are fully erased at this radius. ` +
            (isSelectiveTspan
              ? 'Applied to <tspan> element — selective keyword erasure pattern. ' +
                'Parent textContent contains full string; targeted words invisible.'
              : 'Applied to consent element — all text erased.')
    });
  }

  if (operator === 'dilate' && maxRadius >= 3) {
    findings.push({
      severity: 'HIGH',
      id: 'SA-CSS-FMPH-002',
      primitive: 'feMorphology',
      note: `operator="dilate" radius=${radiusRaw}. ` +
            `Adjacent character strokes merge when 2*radius >= inter-character gap. ` +
            `At radius ${maxRadius}, strokes up to ${2*maxRadius}px apart merge into ` +
            'illegible solid blocks. Consent text unreadable.'
    });
  }

  return findings;
}

/* ── Helpers ─────────────────────────────────────────────────────────── */
function findPrimByResult(filterEl, resultName) {
  if (!resultName) return null;
  for (const c of filterEl.children) {
    if ((c.getAttribute('result') || '').toLowerCase() === resultName) return c;
  }
  return null;
}

function isFullCoverage(prim) {
  if (prim.tagName.toLowerCase() !== 'feflood') return false;
  const opacity = parseFloat(prim.getAttribute('flood-opacity') || '1');
  /* Default feFlood geometry covers 100% of filter region */
  return opacity > 0.9 &&
         !prim.getAttribute('x') && !prim.getAttribute('y') &&
         !prim.getAttribute('width') && !prim.getAttribute('height');
}

function isTinyGeometry(prim) {
  const wStr = prim.getAttribute('width')  || '';
  const hStr = prim.getAttribute('height') || '';
  if (!wStr || !hStr || wStr.includes('%') || hStr.includes('%')) return false;
  return parseFloat(wStr) <= 2 && parseFloat(hStr) <= 2;
}

function findBlendForeground(filterEl, resultName) {
  for (const c of filterEl.children) {
    if (c.tagName.toLowerCase() === 'feblend') {
      /* feBlend "in" is the foreground (drawn on top of "in2" background) */
      if ((c.getAttribute('in') || '').toLowerCase() === resultName) return c;
    }
  }
  return null;
}

function isTspanElement(filterPrim, consentEl) {
  /* Check if this filter is applied to a <tspan> inside the consent element */
  const filterId = filterPrim.parentElement?.id;
  if (!filterId) return false;
  return !!consentEl.querySelector(`tspan[style*="${filterId}"], tspan.scope-word`);
}

/* ── Main audit runner ────────────────────────────────────────────────── */
function runFilterPrimitiveAudit() {
  const CONSENT_RE = /consent|permission|grant.*access|agree.*install|
                      disclosure|privacy|filesystem|contacts|calendar/xi;
  const allFindings = [];

  for (const el of document.querySelectorAll('*')) {
    const text = (el.textContent || '').substring(0, 800);
    if (!CONSENT_RE.test(text)) continue;

    const cf = window.getComputedStyle(el).filter || 'none';
    if (!cf.includes('url(')) continue; /* skip named filter functions */

    const findings = auditFilterPrimitives(el);
    allFindings.push(...findings);
  }

  allFindings.forEach(f => {
    const label = `[${f.id}] ${f.severity}`;
    if (f.severity === 'CRITICAL') console.error(label, f.note);
    else if (f.severity === 'HIGH') console.warn(label, f.note);
    else console.info(label, f.note);
  });

  return allFindings;
}

/* Run the audit */
runFilterPrimitiveAudit();

Closed shadow DOM: If the MCP server creates its consent widget using attachShadow({ mode: ‘closed’ }) and defines the <filter> element inside the shadow root, document.getElementById() cannot find the filter element. The auditFilterPrimitives function above will flag this as SA-CSS-FGRAPH-UNRESOLVABLE. A complete audit platform must hook Element.prototype.attachShadow before page script runs to capture closed shadow root references. SkillAudit’s headless audit environment installs this hook via the browser’s DevTools Protocol before any page JavaScript executes.

Canvas rasterization probe: As a complementary check to filter graph traversal, the detection framework can render the consent element to a hidden <canvas> via html2canvas or a purpose-built rasterizer, then sample a grid of pixel coordinates and compute the average brightness. A consent panel whose average rasterized brightness exceeds 0.9 (near-white) or falls below 0.05 (near-black) on a light background warrants investigation regardless of which specific filter primitive is responsible. This technique catches novel primitive combinations that the per-primitive dispatch above does not cover.

Defences and auditor checklist

SVG filter primitive attacks share a common property: they operate entirely within the rendering pipeline, below the level of any DOM property that standard consent auditors observe. The following checklist describes the required steps for a complete filter security audit of an MCP consent element.

1. Traverse the filter graph for every consent element. For each element that contains consent-relevant text, read getComputedStyle(el).filter. If the value contains a url() reference, resolve the URL fragment to a <filter> element in the same document. List all child primitive elements. This traversal must be performed even when all other computed style properties report normal values — the filter graph is invisible to display, visibility, opacity, color, and bounding box checks.

2. Dispatch per-primitive detection logic. For each primitive found, apply the detection rules for its specific primitive type. The four families covered in this post each require different detection logic:

3. Probe pixel brightness with canvas rasterization. Complement the graph-traversal audit with a pixel-level probe. Draw the consent element’s bounding box region to a canvas using drawImage (for same-origin content) or a headless renderer. Sample a uniform grid of pixel coordinates across the text area and compute the mean luminance. A luminance outside the range [0.1, 0.9] on a standard-background page indicates that the rendered text is near-invisible, regardless of which specific filter operation caused it.

4. Audit <tspan> elements within SVG consent text separately. Apply the above checks not just to the top-level consent element but to every <tspan> child element that carries permission-scope vocabulary. A filter applied to a single <tspan> does not affect the parent element’s textContent or computed style — it is only detectable by examining the filter style of the individual <tspan> and cross-referencing its text content with a permission-scope vocabulary list.

5. Re-audit after synthetic interaction events. Some filter attacks are activated dynamically — the filter graph may be benign at page load and modified by JavaScript event handlers before or during user interaction. Fire synthetic mouseenter, mouseover, mousedown, and focus events on the consent element and re-check the computed filter reference after each event. A changed filter URL or a changed primitive attribute in the filter graph indicates dynamic modification.

6. Use SkillAudit for automated detection. The detection framework in this post is the foundation of SkillAudit’s SVG filter security checks. SkillAudit runs a headless browser with pre-installed shadow DOM hooks, installs the filter graph traversal audit on every consent element candidate, fires the full synthetic interaction event sequence, and combines graph-traversal findings with canvas rasterization probes. The results are returned as structured findings with severity levels, attack IDs, and remediation notes. See feComposite consent security, feTurbulence consent security, feConvolveMatrix consent security, and feMorphology consent security for the corresponding SEO reference pages.

ID Severity Primitive Attack pattern Detection trigger
SA-CSS-FCOMP-001 CRITICAL feComposite operator="in" with 1×1px feFlood in2 source geometry ≤ 2×2px
SA-CSS-FCOMP-002 CRITICAL feComposite operator="out" with full-coverage feFlood in2 is full-coverage feFlood, flood-opacity > 0.9
SA-CSS-FCOMP-003 CRITICAL feComposite operator="arithmetic" k1=k2=k3=k4=0 Formula output at i=0.5 < 0.02 threshold
SA-CSS-FTURB-001 CRITICAL feTurbulence type="fractalNoise" baseFrequency="0.9" baseFrequency ≥ 0.5 + feBlend foreground use
SA-CSS-FTURB-002 CRITICAL feTurbulence Absent type attribute (defaults to turbulence) typeAttr === null, treated as "turbulence" per spec default
SA-CSS-FTURB-003 HIGH feTurbulence numOctaves="8" baseFrequency="0.5" numOctaves > 4, baseFrequency ≥ 0.3, feBlend foreground
SA-CSS-FCONV-001 CRITICAL feConvolveMatrix Zero kernel + preserveAlpha="true" Sum of abs(kernelMatrix values) === 0
SA-CSS-FCONV-002 HIGH feConvolveMatrix Laplacian edge detection kernel kernelSum ≈ 0 && center coefficient < −4
SA-CSS-FCONV-003 CRITICAL feConvolveMatrix divisor="0.001" extreme gain + bias divisor < 0.01 or divisor > 10000
SA-CSS-FMPH-001 CRITICAL feMorphology operator="erode" radius="3" operator=erode && radius ≥ 2
SA-CSS-FMPH-002 HIGH feMorphology operator="dilate" radius="5" operator=dilate && radius ≥ 3
SA-CSS-FMPH-003 CRITICAL feMorphology Selective tspan erosion of permission keywords erode applied to tspan containing permission-scope vocabulary

Conclusion

The four SVG filter primitive families documented in this post — feComposite, feTurbulence, feConvolveMatrix, and feMorphology — represent a distinct and largely unaddressed class of MCP consent bypass. Their distinguishing characteristic is that the attack surface exists entirely below the CSSOM API boundary. getComputedStyle(el).filter returns a URL. Everything that determines what the user actually sees is inside the element that URL points to, in a directed acyclic graph of image-processing operations that no standard DOM audit API traverses.

The twelve attack patterns documented here are not exhaustive. Other filter primitives — feColorMatrix with a zero-saturation or zero-luminance matrix, feFlood followed by feComposite operator="over" to blank the entire region, feDisplacementMap to shear consent text into an illegible smear — are equally capable of rendering consent text invisible while all DOM properties report normal values. The filter graph traversal framework in section 6 can be extended to cover these additional primitives by adding cases to the primitive dispatch switch.

The deeper principle is this: as the web platform adds expressive power to CSS and SVG, the rendering pipeline becomes increasingly difficult to audit through DOM APIs alone. Layout is not rendering. Computed style is not rasterized output. textContent is not what the user reads. Each layer of indirection between DOM state and visual perception is a potential location for a bypass attack. SVG filters are one such layer — a complete, Turing-capable image-processing pipeline operating on already-rasterized pixels, invisible to every property that naive consent auditors check.

Closing this audit gap requires three capabilities working together: filter graph traversal to read the DAG structure, per-primitive mathematical analysis to evaluate rendering consequences, and pixel-level canvas rasterization probes to catch patterns that the symbolic analysis misses. SkillAudit integrates all three in an automated headless audit pipeline. For more on related attack surfaces, see the coverage of feComposite compositing attacks, feTurbulence noise overlay attacks, feConvolveMatrix kernel attacks, and feMorphology erosion attacks in SkillAudit’s reference documentation.

SkillAudit detects all twelve SVG filter primitive consent bypass patterns documented in this post — feComposite Porter-Duff operator erasure, feTurbulence noise overlay, feConvolveMatrix zero-kernel and extreme-gain attacks, and feMorphology erosion and selective tspan erasure — through automated headless filter graph traversal combined with canvas rasterization probes. Run a full audit at skillaudit.dev.