Security Guide

MCP server SVG feConvolveMatrix consent security — zero kernel black output, Laplacian edge-only rendering, extreme divisor channel saturation, and CSSOM blind spot

The SVG feConvolveMatrix filter primitive applies a discrete convolution kernel to the input image. Every output pixel is computed as a weighted sum of nearby input pixels: result[x,y] = Σ(kernel[i,j] × input[x+i-kx, y+j-ky]) / divisor + bias, where kx and ky are the kernel center offsets. An MCP server can manipulate the kernelMatrix, divisor, and bias attributes to produce consent-obscuring outputs: a zero kernel produces uniformly black output; a Laplacian edge-detection kernel strips glyph fills leaving thin unreadable outlines; an extreme low divisor amplifies all channels to white saturation. Critically, feConvolveMatrix is entirely invisible to CSS computed style queries — getComputedStyle(el).filter returns only the url(#filter-id) reference. The kernel matrix, divisor, and bias values are SVG element attributes that must be read by traversing the filter graph directly. This page covers four consent-specific attack patterns distinct from general convolution security.

Attack 1: kernelMatrix="0 0 0 0 0 0 0 0 0" — zero kernel zeros all output pixels, consent text rendered black (SA-CSS-FCONV-001)

The convolution formula at every output pixel for a 3×3 zero kernel is result = (0×p1 + 0×p2 + ... + 0×p9) / divisor + bias. With all kernel weights zero, the summation is always zero regardless of the input pixel values. At the default bias="0" the output is 0 / divisor = 0 for every pixel and every channel. When color-interpolation-filters="sRGB", all RGB channels are zero and the alpha channel behavior depends on preserveAlpha: with preserveAlpha="false" (default), the alpha channel is also zeroed — every output pixel is fully transparent; with preserveAlpha="true", the input alpha is preserved but RGB channels are zeroed, producing uniformly black opaque pixels.

An MCP server choosing preserveAlpha="true" with a zero kernel produces a solid black rectangle covering the consent text — the text is hidden under black rather than transparent. The element occupies its full layout space with real dimensions. textContent returns the full consent string. The black rendering is visible (the element is not transparent) but the text is not readable against the black background. This is distinct from simply setting color: black; background: black (detectable by CSS computed style) because the zeroing happens at the filter rendering stage.

/* SA-CSS-FCONV-001: kernelMatrix="0 0 0 0 0 0 0 0 0" — all weights zero
   Convolution formula: result[x,y] = Σ(0×pixel) / divisor + bias = 0
   With preserveAlpha="true": alpha preserved, RGB channels → 0 = black
   With preserveAlpha="false": all channels → 0 = transparent
   In both cases: consent text glyphs are not visible */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="zero-kernel" color-interpolation-filters="sRGB">
      <feConvolveMatrix
        order="3"
        kernelMatrix="0 0 0
                      0 0 0
                      0 0 0"
        divisor="1"
        bias="0"
        preserveAlpha="true"
        result="zeroed"/>
      <!-- With preserveAlpha=true: output is black opaque rectangle
           Text is hidden under uniform black (not transparent — element still visible) -->
    </filter>
  </defs>
</svg>

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

// DOM checks that MISS this attack:
// el.textContent → full text (MISS)
// getComputedStyle(el).color → "rgb(0,0,0)" — but filter is post-rendering (MISS for CSS)
// getComputedStyle(el).filter → "url(#zero-kernel)" — filter present but no kernel details

// --- Detection: read kernelMatrix from feConvolveMatrix elements ---
function detectZeroKernel(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 conv of filterEl.querySelectorAll('feConvolveMatrix')) {
    const kernelStr = conv.getAttribute('kernelMatrix') || '';
    const kernel    = kernelStr.trim().split(/\s+/).map(Number);

    // Check if all kernel values are zero
    const allZero     = kernel.every(v => v === 0);
    const kernelSum   = kernel.reduce((a, b) => a + b, 0);
    const maxAbsValue = Math.max(...kernel.map(Math.abs));

    // Check divisor and bias
    const divisor  = parseFloat(conv.getAttribute('divisor') || '1');
    const bias     = parseFloat(conv.getAttribute('bias')    || '0');
    const preserveA = conv.getAttribute('preserveAlpha') === 'true';

    // Maximum possible output with these kernel values:
    // If all weights ≤ 0 and bias = 0: output ≤ 0 → black/transparent
    // If kernelSum > 0: some pixels may survive at kernelSum/divisor intensity
    const maxOutputIntensity = maxAbsValue > 0 ? maxAbsValue / Math.abs(divisor) : 0;

    let severity = null;
    let note     = '';

    if (allZero) {
      severity = 'CRITICAL';
      note     = `All kernel weights are zero; output = 0 at every pixel regardless of input; preserveAlpha=${preserveA}`;
    } else if (maxOutputIntensity < 0.1) {
      severity = 'HIGH';
      note     = `Max output intensity = ${maxOutputIntensity.toFixed(3)} (${(maxOutputIntensity * 100).toFixed(1)}% of input); consent text substantially dimmed`;
    }

    if (severity) {
      findings.push({
        order:    conv.getAttribute('order'),
        kernel,
        kernelSum,
        divisor,
        bias,
        preserveAlpha: preserveA,
        maxOutputIntensity,
        severity,
        note
      });
    }
  }

  return findings.length ? findings : null;
}

CRITICAL — SA-CSS-FCONV-001: feConvolveMatrix kernelMatrix="0 0 0 0 0 0 0 0 0" preserveAlpha="true" zeros all RGB channels of every consent text pixel. With preserveAlpha="true", the element renders as a solid black rectangle with no visible text. With preserveAlpha="false", all channels are zeroed and the element renders as transparent. In both cases the consent text is not visible. textContent is intact. getComputedStyle reveals only the filter URL. Detection requires SVG filter graph traversal: read the kernelMatrix attribute, parse all kernel weights, and flag any kernel where all weights are zero or the maximum absolute weight is below 0.1 (producing less than 10% of input intensity in output).

Attack 2: Laplacian kernel "1 1 1 1 -8 1 1 1 1" — edge-detection erases glyph fills, leaving only thin unreadable outlines (SA-CSS-FCONV-002)

The Laplacian kernel [1 1 1 1 -8 1 1 1 1] is a standard image-processing edge-detection operator. It computes the discrete Laplacian of the image: at uniform-color regions, the sum is approximately zero (the central −8 cancels the surrounding eight +1 values). At edges — transitions between different pixel values — the sum is non-zero. For rendered text: the interior of a glyph (the solid fill area, far from the glyph edge) produces near-zero Laplacian output — the pixels inside the glyph all have the same color and their neighbors cancel each other. Only the pixels at the boundary between the glyph fill and the background produce non-zero output. The result is text rendered as thin single-pixel outlines on a black background — the glyph shapes are technically present but the fills are gone. Individual characters at typical reading sizes (14–16px) rendered as single-pixel outlines are not legible at normal viewing distances.

The Laplacian edge-detection attack is particularly deceptive because the output is visually non-trivial: the user sees something that looks like text rendering artifacts (thin outlines on black) rather than a blank screen. A user might interpret this as a font rendering failure, a contrast issue, or a broken theme. The consent panel appears to have attempted to render the text but failed. The Allow button is still in the DOM and responsive. Permission scope outlines may be barely visible under magnification but are not readable at normal viewing distances.

/* SA-CSS-FCONV-002: Laplacian kernel — edge-detection strips glyph fills
   Kernel: [1  1  1 ]
           [1 -8  1 ]
           [1  1  1 ]

   At uniform glyph interior (all pixels same color): Σ(kernel × pixels)
     = 8 × pixel_value + (-8) × pixel_value = 0 → output ≈ 0 (black)

   At glyph edges (transition between glyph and background):
     = non-zero → thin edge outline visible

   Result: consent text rendered as thin single-pixel outlines on black background
   Characters are technically present but not legible at normal viewing distances */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="laplacian-edge" color-interpolation-filters="sRGB">
      <feConvolveMatrix
        order="3"
        kernelMatrix="1  1  1
                      1 -8  1
                      1  1  1"
        divisor="1"
        bias="0"
        preserveAlpha="true"
        edgeMode="duplicate"
        result="edges"/>
    </filter>
  </defs>
</svg>

// --- Detection: identify known consent-attack kernels ---
function detectMaliciousConvolutionKernel(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 = [];

  // Known malicious kernel patterns:
  const KNOWN_ATTACK_KERNELS = {
    'zero_3x3':      { pattern: v => v.length === 9 && v.every(x => x === 0),       note: 'all-zero kernel → black output' },
    'laplacian':     { pattern: v => {
                         // Laplacian: center is negative, surrounding positive, sum near 0
                         if (v.length !== 9) return false;
                         const center = v[4];
                         const surrounding = [v[0],v[1],v[2],v[3],v[5],v[6],v[7],v[8]];
                         const allPosSurr = surrounding.every(x => x > 0);
                         const centerNeg  = center < 0;
                         const sumNearZero = Math.abs(v.reduce((a,b) => a+b, 0)) < 1;
                         return allPosSurr && centerNeg && sumNearZero;
                       }, note: 'Laplacian-class kernel → edge-only rendering, glyph fills erased' },
    'high_pass':     { pattern: v => {
                         const sum = v.reduce((a,b) => a+b, 0);
                         const center = v[Math.floor(v.length/2)];
                         return center < -2 && sum < 0.5 && sum > -0.5;
                       }, note: 'high-pass/edge sharpening → reduces glyph interior visibility' }
  };

  for (const conv of filterEl.querySelectorAll('feConvolveMatrix')) {
    const kernelStr = conv.getAttribute('kernelMatrix') || '';
    const kernel    = kernelStr.trim().split(/\s+/).map(Number);
    const divisor   = parseFloat(conv.getAttribute('divisor') || '1');

    for (const [name, { pattern, note }] of Object.entries(KNOWN_ATTACK_KERNELS)) {
      if (pattern(kernel)) {
        findings.push({
          kernelType:  name,
          kernelMatrix: kernel,
          divisor,
          severity:    name === 'zero_3x3' ? 'CRITICAL' : 'HIGH',
          note
        });
        break;
      }
    }

    // Generic check: center weight negative and large relative to surroundings
    if (kernel.length >= 9) {
      const center     = kernel[Math.floor(kernel.length / 2)];
      const nonCenter  = kernel.filter((_, i) => i !== Math.floor(kernel.length / 2));
      const maxSurr    = Math.max(...nonCenter.map(Math.abs));
      if (center < 0 && Math.abs(center) > 4 * maxSurr && findings.length === 0) {
        findings.push({
          kernelType:   'strong_high_pass',
          centerWeight: center,
          maxSurrWeight: maxSurr,
          severity:     'HIGH',
          note:         `Large negative center weight (${center}) relative to surroundings (max=${maxSurr}) — likely edge-detection or sharpening that erases glyph fills`
        });
      }
    }
  }

  return findings.length ? findings : null;
}

HIGH — SA-CSS-FCONV-002: The Laplacian kernel [1 1 1 1 -8 1 1 1 1] applied to the consent text element performs edge detection: glyph interior pixels cancel out (all neighbors same color → Σ= 0 → black), leaving only single-pixel edge outlines at glyph boundaries. Text characters at typical consent font sizes (14–16px) rendered as single-pixel outlines on black are not legible at normal viewing distances. The attack appears as a font rendering failure. Detection: identify Laplacian-class kernels by checking for a large negative center weight with positive surroundings and a kernel sum near zero; also match other known edge-detection patterns (Sobel, Prewitt, high-pass variants).

Attack 3: divisor="0.001" extreme gain amplification — all consent channels saturate to white (SA-CSS-FCONV-003)

The divisor attribute divides the accumulated convolution sum before clamping: result = kernelSum / divisor + bias. With a near-zero divisor such as 0.001, even a modest weighted sum is divided by an extremely small number, producing an astronomically large intermediate value. After division, this value is clamped to the [0.0, 1.0] range per the SVG specification. At divisor="0.001", any non-zero input pixel will produce a sum that, after division, is clamped to 1.0 (maximum white or fully opaque). For a white background consent panel with dark text: the dark text pixels have low channel values (<0.3) but even a small non-zero weighted sum divided by 0.001 exceeds 1.0 and is clamped to white. The entire output is saturated to white regardless of whether the input pixel is dark (text) or light (background). Both the text and the background become the same white color, making the text invisible.

The divisor attack is subtle because it requires a non-trivial understanding of the convolution formula. A scan that checks only for zero or near-zero kernel values will miss a normal-looking kernel with an anomalously small divisor. The kernel itself can appear completely normal (e.g., the identity kernel [0 0 0 0 1 0 0 0 0]) while the tiny divisor causes extreme gain that saturates all output. SkillAudit checks both the kernel values and the divisor for anomalous combinations.

/* SA-CSS-FCONV-003: divisor="0.001" — extreme gain saturates all channels to white
   Formula: result = kernelSum / divisor + bias
   At divisor=0.001: result = kernelSum / 0.001 = kernelSum × 1000
   Any non-zero input pixel: small weighted sum × 1000 >> 1.0 → clamped to white
   Even dark text pixels (RGB ≈ 0.1) produce sum/0.001 = 100× white saturation
   Output: entire filter region saturated to white — consent text invisible on white bg */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="extreme-gain" color-interpolation-filters="sRGB">
      <!-- Identity kernel — looks innocuous; the attack is in the divisor -->
      <feConvolveMatrix
        order="3"
        kernelMatrix="0 0 0
                      0 1 0
                      0 0 0"
        divisor="0.001"
        bias="0"
        preserveAlpha="false"/>
      <!-- Output: every pixel = input_value / 0.001 = input × 1000 → clamped to 1.0
           Dark text pixels (0.1): 0.1 / 0.001 = 100 → clamped to 1.0 (white)
           Light background (0.97): 0.97 / 0.001 = 970 → clamped to 1.0 (white)
           Entire output is uniform white — text invisible against white bg -->
    </filter>
  </defs>
</svg>

// --- Detection: check divisor for extreme gain ---
function detectExtremeDivisor(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 conv of filterEl.querySelectorAll('feConvolveMatrix')) {
    const kernelStr = conv.getAttribute('kernelMatrix') || '';
    const kernel    = kernelStr.trim().split(/\s+/).map(Number);
    const divisor   = parseFloat(conv.getAttribute('divisor') || '1');
    const bias      = parseFloat(conv.getAttribute('bias')    || '0');

    // Compute the gain factor = maxKernelWeight / abs(divisor)
    const maxKernelWeight = Math.max(...kernel.map(Math.abs));
    if (maxKernelWeight === 0) continue; // all-zero kernel handled in FCONV-001

    const gainFactor = maxKernelWeight / Math.abs(divisor);

    // Identify extreme gain scenarios:
    // gainFactor > 100: even dark pixels (0.01 input) produce output ≥ 1.0 → clamped white
    // gainFactor > 10: significant brightening/saturation

    let severity = null;
    let note     = '';

    if (gainFactor > 500) {
      severity = 'CRITICAL';
      note     = `divisor=${divisor}; gain=${gainFactor.toFixed(0)}×; all non-zero pixels saturated to white/clamped`;
    } else if (gainFactor > 100) {
      severity = 'HIGH';
      note     = `divisor=${divisor}; gain=${gainFactor.toFixed(0)}×; dark pixels (text) significantly brightened, likely bleaching consent text`;
    } else if (gainFactor > 10) {
      severity = 'MEDIUM';
      note     = `divisor=${divisor}; gain=${gainFactor.toFixed(1)}×; substantial brightening may obscure low-contrast consent text`;
    }

    if (severity) {
      findings.push({
        divisor, bias,
        maxKernelWeight,
        gainFactor,
        bias,
        severity,
        note
      });
    }
  }

  return findings.length ? findings : null;
}

// detectExtremeDivisor(el) →
// [{
//   divisor:         0.001,
//   maxKernelWeight: 1,
//   gainFactor:      1000,
//   severity:        "CRITICAL",
//   note:            "divisor=0.001; gain=1000×; all non-zero pixels saturated to white/clamped"
// }]

CRITICAL — SA-CSS-FCONV-003: feConvolveMatrix divisor="0.001" with an identity or near-identity kernel produces a gain factor of 1000×. The convolution formula clamps output to [0, 1]: any pixel value above 0.001 saturates to 1.0 (white). On a standard white-background consent panel with dark text, all output pixels — both dark text pixels and light background pixels — are saturated to white. The consent text becomes invisible against the white background. The kernel itself may look completely normal. Detection requires checking the gain factor: maxKernelWeight / |divisor| above 100 is HIGH; above 500 is CRITICAL on a consent element.

Attack 4: feConvolveMatrix as a CSSOM blind spot — shadow DOM concealment and getAttribute-only detection (SA-CSS-FCONV-004)

CSS computed style queries (getComputedStyle) on an element with a filter return the filter URL string (e.g., "url(#my-filter)") but provide no information about the primitives inside the filter element. The kernelMatrix, divisor, bias, order, and preserveAlpha attributes of feConvolveMatrix are SVG element attributes, not CSS properties. They are not exposed in the CSSOM. An auditor using only getComputedStyle cannot determine whether the filter contains a malicious convolution kernel without additional DOM traversal. This blind spot is amplified by two compounding factors: (1) the filter element may be defined inside a closed shadow DOM, making it inaccessible to document.querySelector; (2) the consent element’s filter may use an indirect reference (a <use> element referencing a <symbol> that contains the filter definition), requiring multi-level indirection to resolve.

A closed shadow DOM attachment (attachShadow({mode: 'closed'})) prevents external script access to shadow DOM internals. If the SVG filter definition lives inside a closed shadow root, document.getElementById(filterId) returns null even when the filter is actively applied to a consent element. SkillAudit detects this by installing a hook on Element.prototype.attachShadow to capture shadow root references before closure, enabling later traversal. Without the hook, a closed shadow DOM filter is undetectable via standard DOM APIs.

/* SA-CSS-FCONV-004: CSSOM blind spot — feConvolveMatrix kernel values not in computed style
   getComputedStyle(el).filter → "url(#my-filter)" only — no kernel details
   Must resolve URL to <filter> element, then read feConvolveMatrix attributes directly

   Additional concealment: filter defined in closed shadow DOM */

// CSSOM MISS — standard audit approach that fails:
const el  = document.getElementById('consent');
const cs  = getComputedStyle(el);
console.log(cs.filter); // → "url(#zero-kernel)" — no kernel info
// Cannot determine kernel from this alone

// Correct approach — SVG filter graph traversal:
function auditConvolveMatrixFilters(consentEl) {
  // Step 1: get filter URL from computed style or attribute
  const filterVal = getComputedStyle(consentEl).filter
                  || consentEl.getAttribute('filter')
                  || '';

  const matches = [...filterVal.matchAll(/url\(["']?(#[\w-]+)["']?\)/g)];
  if (!matches.length) return [];

  const findings = [];

  for (const match of matches) {
    const filterId = match[1];

    // Step 2: try to find filter element in main document
    let filterEl = document.querySelector(filterId);

    // Step 3: if null, the filter may be in a shadow DOM
    if (!filterEl) {
      // Check shadow roots we've collected via attachShadow hook
      for (const shadowRoot of (window.__capturedShadowRoots || [])) {
        const candidate = shadowRoot.querySelector(filterId);
        if (candidate) { filterEl = candidate; break; }
      }
    }

    if (!filterEl) {
      findings.push({
        filterId,
        filterResolved: false,
        severity: 'MEDIUM',
        note: `Filter ${filterId} not found in document — may be in closed shadow DOM; cannot inspect kernel values`
      });
      continue;
    }

    // Step 4: iterate feConvolveMatrix elements and read attributes
    for (const conv of filterEl.querySelectorAll('feConvolveMatrix')) {
      const kernelStr   = conv.getAttribute('kernelMatrix') || '';
      const kernel      = kernelStr.trim().split(/\s+/).map(Number);
      const divisor     = parseFloat(conv.getAttribute('divisor') || '1');
      const bias        = parseFloat(conv.getAttribute('bias')    || '0');
      const order       = conv.getAttribute('order') || '3';
      const preserveA   = conv.getAttribute('preserveAlpha') === 'true';

      // Basic analysis
      const kernelSum   = kernel.reduce((a, b) => a + b, 0);
      const maxAbs      = Math.max(...kernel.map(Math.abs));
      const allZero     = kernel.every(v => v === 0);
      const gainFactor  = maxAbs / Math.abs(divisor);

      findings.push({
        filterId,
        filterResolved: true,
        order, kernelSize: kernel.length,
        kernelSum, maxAbsWeight: maxAbs,
        divisor, bias, preserveAlpha: preserveA,
        gainFactor,
        allZero,
        severity:
          allZero || gainFactor > 500 ? 'CRITICAL' :
          gainFactor > 100            ? 'HIGH' :
          gainFactor > 10 || kernelSum < -0.5 ? 'MEDIUM' :
          'INFO',
        note: allZero
          ? 'All-zero kernel — output is black/transparent'
          : `Kernel sum=${kernelSum.toFixed(2)}; gain=${gainFactor.toFixed(0)}×`
      });
    }
  }

  return findings;
}

// --- Shadow DOM hook (install at audit start) ---
(function() {
  window.__capturedShadowRoots = [];
  const origAttach = Element.prototype.attachShadow;
  Element.prototype.attachShadow = function(init) {
    const root = origAttach.call(this, init);
    window.__capturedShadowRoots.push(root);
    return root;
  };
})();

MEDIUM — SA-CSS-FCONV-004: feConvolveMatrix kernel values are SVG element attributes with no CSSOM representation. getComputedStyle(el).filter returns only the filter URL. Detection requires resolving the filter ID to the <filter> DOM element and reading kernelMatrix, divisor, and bias attributes directly. If the filter is defined inside a closed shadow DOM (attachShadow({mode: 'closed'})), document.querySelector returns null and kernel analysis is impossible without a pre-installed attachShadow hook. SkillAudit installs this hook before consent rendering to ensure closed shadow DOM filter definitions are accessible for inspection.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FCONV-001: kernelMatrix all-zero kernel All kernel weights are zero; convolution sum = 0 at every pixel; with preserveAlpha="true": black opaque output; with preserveAlpha="false": fully transparent; algebraically guaranteed; textContent intact; CSSOM blind spot All consent text and visual elements; with preserveAlpha=true: consent panel appears as black rectangle; with preserveAlpha=false: transparent; Allow button still in DOM and responsive Critical
SA-CSS-FCONV-002: Laplacian kernel edge-only rendering Laplacian kernel [1 1 1 1 -8 1 1 1 1] zeroes glyph interior pixels (all same-color neighbors cancel); preserves only edge transition pixels; consent text rendered as thin single-pixel outlines on black background; not legible at normal viewing distances Glyph fill areas (all solid-colored regions of consent text); edge outlines technically present but unreadable; appears as font rendering failure; user may not recognize it as an obstruction High
SA-CSS-FCONV-003: divisor="0.001" extreme gain bleaching Gain factor = maxKernelWeight / 0.001 = 1000×; any non-zero input value > 0.001 produces output clamped to 1.0 (white); both dark text and light background saturate to white; text invisible against white background; kernel may appear normal (identity) All consent text characters on white-background consent panels; text bleached to same white as background; gain-based attack undetectable by kernel-value checks alone Critical
SA-CSS-FCONV-004: CSSOM blind spot and closed shadow DOM Kernel values not in CSSOM; filter URL is the only CSS exposure; filter element may be in closed shadow DOM inaccessible without attachShadow hook; detection requires full filter graph traversal + shadow root hook All feConvolveMatrix attacks are invisible to CSS computed style audits; detection gap exploitable by any feConvolveMatrix pattern Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FCONV-001: feConvolveMatrix kernelMatrix="0 0 0 0 0 0 0 0 0" preserveAlpha="true" on the consent element — all kernel weights are zero; convolution output is 0 at every pixel; preserveAlpha="true" preserves input alpha but zeroes RGB → solid black rectangle; textContent returns full text; CSSOM exposes only the filter URL; detection: traverse filter graph, read kernelMatrix, check all weights are zero; flag as CRITICAL.
HIGH SA-CSS-FCONV-002: Laplacian kernel feConvolveMatrix kernelMatrix="1 1 1 1 -8 1 1 1 1" on the consent element — standard image-processing edge-detection operator; zeroes uniform glyph interior pixels (same-color neighbors cancel); preserves only single-pixel edge outlines; consent text at 14–16px rendered as thin outlines on black background is not legible; detection: identify Laplacian-class kernels by center weight < −4, positive surroundings, sum ≈ 0.
CRITICAL SA-CSS-FCONV-003: feConvolveMatrix divisor="0.001" with identity kernel on the consent element — gain factor = 1000×; any pixel value above 0.001 saturates to 1.0 (white); both dark consent text (RGB ≈ 0.1) and light background (RGB ≈ 0.97) saturate to uniform white; text invisible against white background; kernel appears normal; only divisor check detects it; flag maxKernelWeight / |divisor| > 100 on consent elements as CRITICAL.
MEDIUM SA-CSS-FCONV-004: consent element has a filter referencing an ID not found in the document — filter may be defined inside a closed shadow DOM; getComputedStyle().filter confirms the filter is applied but the filter element cannot be located; kernel values cannot be inspected without an attachShadow hook; SkillAudit installs the hook at audit initialization to capture shadow roots before closure; flag unresolvable filter IDs as MEDIUM pending shadow root inspection.

Related: feComposite operator attacks on consent  |  feMorphology erode/dilate consent attacks  |  feTurbulence noise overlay consent attacks  |  feFlood flood-opacity consent attacks

← Blog  |  Security Checklist