MCP server CSS feConvolveMatrix security: saturating kernel divisor attack, bias offset whitewash, zero-sum alpha zeroing via preserveAlpha=false, and large-order blur bypass

Published 2026-10-01 — SkillAudit Research

The SVG feConvolveMatrix filter primitive applies a two-dimensional matrix convolution to each channel of the input image. For each output pixel at coordinates (x, y), the output channel value is computed as the weighted sum of input channel values in a neighborhood defined by the kernel size, divided by a normalization factor, plus an optional bias term:

C'(x,y) = (∑ kernelMatrix[i,j] × C(x - targetX + i, y - targetY + j)) / divisor + bias

The key parameters are order (kernel dimensions, e.g., "3 3" for a 3×3 kernel), kernelMatrix (the n² coefficient values as a space-separated list), divisor (the normalization factor — defaults to the sum of absolute kernel values if that sum is non-zero, else 1), bias (a constant added to every output pixel per channel), preserveAlpha (if "true", the alpha channel bypasses the convolution), and edgeMode (how boundary pixels are handled: duplicate, wrap, or none).

Post-filter vs. pre-filter color: getComputedStyle(el).fill reports the element's CSS fill property — the color before the filter pipeline modifies it. feConvolveMatrix alters pixel values in the rendered output. The DOM check sees the source fill; the user sees the convolved output. The divisor default also changes based on kernel content — if all kernel values are identical, the divisor default equals order² × kernelValue, normalizing the output correctly. An explicitly set divisor="1" overrides this default, causing saturation.

Convolution output formula

For a 3×3 kernel with all entries equal to 1.0 and divisor="1": each output pixel accumulates 9 input pixel values and divides by 1, producing values up to 9.0. Channel values are clamped to [0.0, 1.0] after computation — so 9.0 clamps to 1.0 (white). The default divisor of 9 would normalize this to an average, but divisor="1" eliminates normalization, guaranteeing saturation.

Attack findings

CRITICAL
All-positive kernelMatrix with divisor="1" saturates all channel values to white
A 3×3 kernel with all entries equal to 1.0 and divisor="1" computes a sum of 9 adjacent pixel channel values (each in [0,1]) divided by 1. The minimum possible sum for any pixel with non-zero neighboring values is near zero only at sharp edges in zero-alpha backgrounds; interior pixels of a solid-color consent text element sum to 9 × channel_value, clamped to 1.0. For dark text (#1a1a1a, channel value ≈ 0.1): sum = 9 × 0.1 = 0.9, divided by 1 = 0.9 output — near-white. Increasing kernel size to 5×5 (divisor="1", all entries 1.0): sum = 25 × 0.1 = 2.5, clamped to 1.0 — fully white. All DOM property checks pass: getBoundingClientRect() positive, getComputedStyle(el).fill returns the original dark fill, opacity = 1. Only inspecting the kernelMatrix values and computing the output clamp reveals the attack.
<filter id="f1">
  <feConvolveMatrix order="5" kernelMatrix="1 1 1 1 1  1 1 1 1 1  1 1 1 1 1  1 1 1 1 1  1 1 1 1 1"
    divisor="1" preserveAlpha="false"/>
</filter>
CRITICAL
bias parameter adds constant offset to every channel value — dark text pushed to near-white
The bias parameter is added to every output pixel value after the kernel sum and divisor. A bias="0.92" with a near-identity kernel (center=1, all other entries=0, divisor=1) produces output: C'(x,y) = C(x,y) + 0.92. For dark consent text channel value 0.1: output = 0.1 + 0.92 = 1.02, clamped to 1.0 — white. Against a white background: contrast 1:1. The identity kernel preserves spatial structure (no blurring), making the rendered output look like the content is there but uniformly white. The bias attack is especially hard to detect because the kernel itself (identity) looks legitimate — only the bias attribute value reveals the manipulation.
<filter id="f1">
  <feConvolveMatrix order="3"
    kernelMatrix="0 0 0  0 1 0  0 0 0"
    divisor="1" bias="0.92" preserveAlpha="false"/>
</filter>
HIGH
Zero-sum kernel with preserveAlpha="false" zeros alpha channel of uniform-alpha regions
A Laplacian-of-Gaussian kernel (or any kernel where the sum of all entries equals zero) applied to a uniform-alpha input produces zero output. A consent text element with constant alpha = 1.0 across all pixels: the Laplacian kernel sums 1.0 × (kernel entries), which equals 0 because the kernel is zero-sum. Divided by divisor: 0. Output alpha = 0 + bias (if bias=0) = 0. The alpha channel of every interior pixel is zeroed — the consent text becomes fully transparent. Edge pixels near the element boundary see incomplete neighborhoods; edgeMode="none" treats missing pixels as 0, producing a non-zero Laplacian at the boundary, leaving a 1-pixel outline. All DOM checks pass: layout box is non-zero, fill is reported as the original fill color (pre-filter), opacity=1. Detection requires: identifying zero-sum kernels (sum of kernelMatrix entries ≤ epsilon) AND preserveAlpha="false" (or absent, since default is false).
<!-- Laplacian kernel: sum = 0 → alpha of uniform region → 0 -->
<filter id="f1">
  <feConvolveMatrix order="3"
    kernelMatrix="0 1 0  1 -4 1  0 1 0"
    divisor="1" preserveAlpha="false"/>
</filter>
MEDIUM
Large-order Gaussian approximation kernel destroys glyph spatial frequency without triggering feGaussianBlur checks
A 9×9 kernel containing Gaussian-distributed weights (approximating a Gaussian blur with σ≈3) produces the same visual effect as feGaussianBlur stdDeviation="3" — character spatial frequency is destroyed, consent text is illegible at standard viewing distances — but uses feConvolveMatrix, which is a different element name. Audit rules that check specifically for feGaussianBlur with large stdDeviation do not fire. The divisor is set to the kernel sum (correct normalization), producing a true average, so no pixel saturation occurs — only spatial frequency destruction. Detection requires independently evaluating feConvolveMatrix effective blur radius from the kernel weight distribution, not just checking for feGaussianBlur.

Detection algorithm

function detectFeConvolveMatrixAttacks(consentEl) {
  const style = getComputedStyle(consentEl);
  const filterVal = style.filter;
  if (!filterVal || filterVal === 'none') return null;

  const filterId = filterVal.match(/url\(["']?#([^"')]+)["']?\)/)?.[1];
  if (!filterId) return null;

  const svgRoot = consentEl.closest('svg') || document;
  const filter = svgRoot.querySelector(`filter#${filterId}`);
  if (!filter) return null;

  const fillColor = style.fill || style.color || 'rgb(26,26,26)';
  const [srcR, srcG, srcB] = parseCSSColor(fillColor);

  const findings = [];

  for (const cm of filter.querySelectorAll('feConvolveMatrix')) {
    const orderAttr = cm.getAttribute('order') || '3';
    const [orderX, orderY] = orderAttr.trim().split(/\s+/).map(Number);
    const n = (orderX || 3) * (orderY || orderX || 3);

    const kernelStr = cm.getAttribute('kernelMatrix') || '';
    const kernel = kernelStr.trim().split(/\s+/).map(Number);
    if (kernel.length !== n) continue;

    const kernelSum = kernel.reduce((a, b) => a + b, 0);
    const kernelAbsSum = kernel.reduce((a, b) => a + Math.abs(b), 0);
    const divisorAttr = cm.getAttribute('divisor');
    const divisor = divisorAttr ? parseFloat(divisorAttr) :
      (kernelAbsSum > 0 ? kernelAbsSum : 1);
    const bias = parseFloat(cm.getAttribute('bias') ?? '0');
    const preserveAlpha = cm.getAttribute('preserveAlpha') === 'true';

    // Check bias attack: constant offset pushes dark text to white
    if (bias > 0.5) {
      const worstCase = srcR + bias; // assuming center pixel dominates
      if (worstCase >= 0.9) {
        findings.push({ severity: 'critical', el: cm,
          issue: `feConvolveMatrix bias="${bias}" pushes dark channel values (${srcR.toFixed(2)}) to ${Math.min(1, worstCase).toFixed(2)} — near-white output, contrast <1.5:1 against white` });
      }
    }

    // Check saturation attack: all-positive kernel with divisor=1 or small divisor
    const allPositive = kernel.every(v => v >= 0);
    if (allPositive && kernelSum > 0) {
      const maxOutput = kernelSum / divisor + bias;
      if (maxOutput > 0.9) {
        findings.push({ severity: 'critical', el: cm,
          issue: `feConvolveMatrix: all-positive kernel sum=${kernelSum.toFixed(1)}, divisor=${divisor.toFixed(1)} → max output=${maxOutput.toFixed(2)}; interior pixels saturate to white` });
      }
    }

    // Check zero-sum alpha zeroing via preserveAlpha=false
    if (!preserveAlpha && Math.abs(kernelSum) < 0.01 && bias === 0) {
      findings.push({ severity: 'high', el: cm,
        issue: `feConvolveMatrix: zero-sum kernel (sum=${kernelSum.toFixed(4)}) with preserveAlpha=false — uniform-alpha consent text regions output alpha=0, element becomes transparent` });
    }

    // Check Gaussian blur approximation (wide kernel, positive entries, normalized)
    if (n >= 25) { // 5×5 or larger
      const centerWeight = kernel[Math.floor(n / 2)] / divisor;
      if (centerWeight < 0.3) {
        findings.push({ severity: 'medium', el: cm,
          issue: `feConvolveMatrix: large kernel (${Math.sqrt(n).toFixed(0)}×${Math.sqrt(n).toFixed(0)}) with low center weight ${centerWeight.toFixed(3)} — Gaussian blur approximation destroys glyph spatial frequency; not detected by feGaussianBlur checks` });
      }
    }
  }

  return findings.length ? findings : null;
}

function parseCSSColor(css) {
  const m = css.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
  return m ? [parseInt(m[1])/255, parseInt(m[2])/255, parseInt(m[3])/255] : [0.1, 0.1, 0.1];
}

Remediation

ControlHow it helps
For every feConvolveMatrix on a consent element's filter, compute the effective bias: read the bias attribute and check whether it alone pushes the element's CSS fill color above a contrast threshold; flag any bias value that, when added to the source channel values, produces output above 0.85 per channel Bias offset attack — the bias parameter is added unconditionally to every pixel; checking bias against the source fill color is sufficient to detect the attack without simulating the full convolution
Compute the theoretical maximum output value as (sum of positive kernel entries) / divisor + bias; if this value exceeds 0.9 for any channel given the source fill color, flag as potential saturation whitewash; explicitly-set divisor values override the SVG default, so always read the attribute rather than computing the default Kernel saturation attack — the divisor default is based on kernel content, but an explicit divisor="1" eliminates normalization; audits that assume the default divisor will miss this attack
Check for zero-sum kernels (sum(kernelMatrix) ≈ 0) combined with preserveAlpha="false" or absent; zero-sum spatial kernels produce zero alpha output on uniform-alpha regions; this is distinct from color-channel manipulation and requires checking the kernel sum rather than output color Zero-sum alpha zeroing — Laplacian and edge-detection kernels have zero sum by design; applied to a constant-alpha background, they zero the alpha channel; preserveAlpha=false (the SVG default) allows this; only checking for alpha-specific feFuncA misses this vector
For large kernels (order ≥ 5×5), independently evaluate the effective blur radius by computing the kernel's spatial frequency response; if the effective blur destroys glyph-frequency content (spatial frequencies above 0.05 cycles/pixel are attenuated by >50%), flag as a blur attack regardless of element name Gaussian blur bypass — feConvolveMatrix can approximate any spatial filter including feGaussianBlur; name-based checks (looking for feGaussianBlur) miss this; only kernel-frequency analysis provides type-agnostic detection

SkillAudit evaluates feConvolveMatrix kernel parameters — divisor, bias, kernel sum, and effective frequency response — against the consent element's actual fill color, detecting saturation, bias offset, alpha zeroing, and blur-bypass attacks. Run a free audit on any MCP server GitHub URL to surface feConvolveMatrix manipulation in the full SVG filter consent attack surface.