Security Guide

MCP server SVG divisor consent security — feConvolveMatrix divisor=0 undefined, large-divisor dark-out, un-normalized white-out, and JS injection

The divisor attribute of <feConvolveMatrix> is the denominator applied to the kernel convolution sum before the result is stored as an output channel value: output = kernel_sum / divisor + bias. The SVG specification notes that the default value of divisor is the sum of all kernel matrix values, ensuring proper normalization. When divisor=0, the spec declares the behavior undefined, producing inconsistent results across browsers. An extremely large divisor effectively collapses all output to near-zero (dark-out), while divisor=1 on a kernel with a coefficient sum greater than 1 amplifies output past the clamp ceiling, forcing all channels to maximum white (white-out).

Attack 1: divisor="0" — undefined SVG spec behavior (SA-CSS-DIV-001)

The SVG 1.1 specification section on feConvolveMatrix states: “If the sum of all of the kernel matrix values is 0 and divisor is not specified, then divisor shall be set to 1. ... If divisor is equal to 0, the behavior is undefined.” Browsers handle this differently: Chromium-based browsers typically produce zero output (all channels = 0, dark-out); Firefox may produce zero or saturate; Safari may use a fallback value. An attacker who knows the target MCP client's rendering engine can select divisor="0" knowing it produces zero output (dark-out) in that specific browser, while the filter validates correctly in a different browser-based scanner that treats the undefined behavior differently.

The cross-browser inconsistency is the attack surface: a security scanner running in Chrome may observe zero output and flag it, but a scanner running as a headless Node process that evaluates the attribute string without rendering may not catch the undefined-behavior implication. The attacker relies on the scanner's static analysis code treating divisor="0" as simply a suspicious value rather than tracking the runtime rendering path in the target browser.

<!-- SA-CSS-DIV-001: divisor=0 — SVG spec: behavior undefined
     Chromium: outputs zero (all channels → black)
     Firefox: implementation-specific (usually zero or NaN propagation)
     Attacker targets specific MCP client browser engine
     Scanner may evaluate string "0" without rendering the filter -->
<filter id="div-zero">
  <feConvolveMatrix
    order="3"
    kernelMatrix="0 0 0  0 1 0  0 0 0"
    divisor="0"
    bias="0"/>
  <!-- Identity kernel but divisor=0: output undefined; dark-out in Chromium -->
</filter>

<div id="consent" style="filter:url(#div-zero)">
  <p>Requesting: shell exec, filesystem write.</p>
  <button id="allow">Allow</button>
</div>

// Detection: always flag divisor=0 regardless of kernel
function auditDivisorZero(feConvolveEl) {
  const div = parseFloat(feConvolveEl.getAttribute('divisor') ?? 'NaN');
  if (!isNaN(div) && div === 0) {
    return { sev: 'HIGH', note: 'feConvolveMatrix divisor=0: SVG spec undefined behavior; Chromium produces zero-output (dark-out); output is browser-engine-dependent and inconsistent across security scanners' };
  }
  // Also check for near-zero (avoids the 0 check)
  if (!isNaN(div) && Math.abs(div) < 1e-6) {
    return { sev: 'HIGH', note: `feConvolveMatrix divisor=${div}: near-zero divisor; kernel_sum/divisor amplified to extremes; SVG spec behavior undefined for divisor=0` };
  }
  return null;
}

HIGH — SA-CSS-DIV-001: divisor="0" is undefined per SVG spec. Chromium-based MCP clients produce zero output (consent text invisible on dark backgrounds). The inconsistency between browser behaviors and static scanner evaluation is the attack surface. Detection: always flag divisor=0 or near-zero on feConvolveMatrix regardless of kernel content.

Attack 2: Very large divisor — dark-out via near-zero output (SA-CSS-DIV-002)

With an extremely large divisor value (e.g., divisor="1000000"), the output formula becomes: output = kernel_sum / 1000000 + bias. For any realistic kernel sum in the range [−9, 9] for a 3×3 kernel with values bounded by 1, the convolution result divided by 1,000,000 produces values in the range [−0.000009, +0.000009] — effectively zero. With bias="0", all output channels are approximately 0.000009, which renders as pure black in integer precision (0/255 after quantization to 8-bit).

The consent text becomes invisible against a dark background. The attack is subtle: a large divisor could appear to be a custom normalization choice (e.g., normalizing to a very small range for a subtle artistic effect). Static analysis that only checks for extreme divisor values may not flag a value like 1000 (which still produces outputs in [−0.009, +0.009] — essentially black). The threshold below which the output is imperceptible depends on the display contrast, but any divisor above approximately 50× the kernel sum ensures near-zero output.

<!-- SA-CSS-DIV-002: Very large divisor — output ≈ 0
     kernel_sum(3×3 ones) = 9; divisor=1000000
     output = 9 * src_avg / 1000000 ≈ 0 (imperceptible)
     bias=0 → solid dark output; consent text invisible on dark UIs -->
<filter id="div-large">
  <feConvolveMatrix
    order="3"
    kernelMatrix="1 1 1  1 1 1  1 1 1"
    divisor="1000000"
    bias="0"/>
  <!-- Looks like an averaging blur with "unusual" normalization -->
</filter>

<div id="consent" style="filter:url(#div-large)">
  <p>Allow: write to ~/.config, export $HOME.</p>
  <button id="allow">Allow</button>
</div>

// Detection: flag divisor that reduces output to near-zero
function auditLargeDivisor(feConvolveEl) {
  const div = parseFloat(feConvolveEl.getAttribute('divisor') ?? '0');
  const bias = parseFloat(feConvolveEl.getAttribute('bias') ?? '0');
  if (isNaN(div) || div === 0) return null;
  const km = (feConvolveEl.getAttribute('kernelMatrix') ?? '').split(/\s+/).map(Number);
  const kmax = km.reduce((a, b) => a + Math.abs(b), 0); // absolute sum
  if (kmax === 0) return null; // zero kernel handled separately
  const maxOutput = kmax / Math.abs(div) + Math.abs(bias);
  if (maxOutput < 0.05 && Math.abs(bias) < 0.05) {
    return { sev: 'HIGH', note: `feConvolveMatrix divisor=${div} with kernel abs-sum=${kmax}: max output ≈ ${maxOutput.toFixed(4)} — near-zero dark-out; consent text invisible at normal display contrast` };
  }
  return null;
}

HIGH — SA-CSS-DIV-002: Extremely large divisor values reduce all channel outputs to near-zero regardless of the kernel, producing a dark-out that hides consent text on dark-themed clients. Detection: compute the maximum possible output as |kernel_abs_sum| / |divisor| + |bias|; flag if below a visibility threshold (approximately 0.05).

Attack 3: divisor="1" + un-normalized kernel — white-out via clamp (SA-CSS-DIV-003)

The SVG specification default for divisor is the sum of all kernel matrix values — this ensures a normalized convolution where the output has the same mean as the input (e.g., a 3×3 box blur with all coefficients = 1 and divisor = 9). When divisor="1" is explicitly set with a kernel whose sum is substantially greater than 1, the output is amplified: output = kernel_sum × src_avg / 1. For a 3×3 all-ones kernel (sum = 9), the output is approximately 9 × src_avg — for any non-black pixel, this exceeds 1.0 and is clamped to 1.0. The entire consent element becomes white (or near-white) when rendered.

This is a white-out via overflow-and-clamp. The attack uses what appears to be a standard blur kernel (common, legitimate-looking coefficient values) but with an explicit divisor="1" that bypasses the default normalization. The divisor is a small, non-suspicious-looking integer; the kernel coefficients are all small positive integers; only the combination with divisor="1" creates the problematic output. Scanners that check divisor in isolation (looking for zero or extreme values) miss this attack entirely.

<!-- SA-CSS-DIV-003: divisor=1 + kernel sum=9 — output = 9 * src → clamp to 1.0 (white)
     All-ones 3×3 kernel: sum = 9. divisor=1 (not the default 9).
     For any pixel with src_R = 0.1 (dark text): output_R = 9 * 0.1 / 1 = 0.9 → not white
     For any pixel with src_R = 0.2: output_R = 9 * 0.2 = 1.8 → clamped to 1.0 (white)
     At src_R ≥ 0.112, output clamps. Light text and mid-tones white-out. -->
<filter id="div-amp">
  <feConvolveMatrix
    order="3"
    kernelMatrix="1 1 1  1 1 1  1 1 1"
    divisor="1"
    bias="0"/>
  <!-- "blur" kernel but with divisor=1 instead of 9: amplify × 9, clamp → white -->
</filter>

<div id="consent" style="filter:url(#div-amp)">
  <p>Granting: network access, secrets vault read.</p>
  <button id="allow">Allow</button>
</div>

// Detection: check ratio of kernel sum to divisor
function auditDivisorAmplification(feConvolveEl) {
  const div = parseFloat(feConvolveEl.getAttribute('divisor') ?? '0');
  if (isNaN(div) || div === 0) return null;
  const km = (feConvolveEl.getAttribute('kernelMatrix') ?? '').split(/\s+/).map(Number);
  const ksum = km.reduce((a, b) => a + b, 0);
  const amplification = ksum / div;
  // If amplification * typical_src_value > 1, output clamps to 1.0 (white)
  // For src ≥ 0.3 (medium grey): amplification > 3.3 → clamp
  if (amplification > 3) {
    return { sev: 'HIGH', note: `feConvolveMatrix divisor=${div} with kernel sum=${ksum}: amplification ratio = ${amplification.toFixed(1)}×; most non-dark pixels clamp to 1.0 (white); consent text white-out on light UIs` };
  }
  return null;
}

HIGH — SA-CSS-DIV-003: divisor="1" with a kernel whose coefficient sum is substantially greater than 1 (e.g., a 3×3 all-ones kernel with sum=9) amplifies pixel values so they clamp to 1.0 (white). Consent text becomes invisible on light-themed MCP clients. Detection: compute the amplification ratio kernel_sum / divisor and flag if greater than approximately 3.

Attack 4: JS mousedown injects divisor change — click-time amplification (SA-CSS-DIV-004)

JavaScript changes the divisor attribute to a value near zero (e.g., "0.001") at mousedown capture phase. The initial state uses a normalized divisor (e.g., divisor="9" for a 3×3 all-ones kernel) — consent text is correctly rendered as a slightly blurred but visible version of the original. On mousedown, the divisor is changed from "9" to "0.001": the amplification becomes 9/0.001 = 9000, which drives all channels to 1.0 (white-out) regardless of input. The click fires on the now-invisible consent element.

<!-- SA-CSS-DIV-004: JS mousedown changes divisor from 9 (normalized) to 0.001
     Amplification at click time: 9 / 0.001 = 9000 → all channels clamp to 1.0 (white)
     Initial state: divisor=9 → normal blur appearance (legitimate)
     At mousedown: divisor=0.001 → white-out; "Allow" button invisible and clicked -->
<filter id="div-dynamic">
  <feConvolveMatrix id="div-cm"
    order="3"
    kernelMatrix="1 1 1  1 1 1  1 1 1"
    divisor="9"
    bias="0"/>
</filter>

<div id="consent" style="filter:url(#div-dynamic)">
  <p>Allow access: read private keys, export credentials.</p>
  <button id="allow">Allow</button>
</div>
<script>
document.getElementById('consent').addEventListener('mousedown', () => {
  document.getElementById('div-cm').setAttribute('divisor', '0.001');
}, { capture: true });
</script>

// Detection: MutationObserver on divisor attribute
const cmEl = document.getElementById('div-cm');
new MutationObserver(muts => {
  for (const m of muts) {
    if (m.attributeName === 'divisor') {
      const div = parseFloat(cmEl.getAttribute('divisor') ?? '1');
      if (Math.abs(div) < 0.01 || div === 0) {
        console.warn('[SA-CSS-DIV-004] divisor changed to near-zero:', div, '— amplification extreme; white-out likely');
      }
      const km = (cmEl.getAttribute('kernelMatrix') ?? '').split(/\s+/).map(Number);
      const ksum = km.reduce((a, b) => a + b, 0);
      if (Math.abs(div) > 0 && Math.abs(ksum / div) > 10) {
        console.warn('[SA-CSS-DIV-004] divisor changed to amplifying value; ksum/div =', (ksum/div).toFixed(0));
      }
    }
  }
}).observe(cmEl, { attributes: true, attributeFilter: ['divisor', 'kernelMatrix', 'bias'] });

HIGH — SA-CSS-DIV-004: JS mousedown injection of a near-zero divisor (e.g., 0.001) amplifies the kernel output by a factor of thousands, clamping all channels to white and erasing the consent text at click time. Initial divisor is a legitimate normalized value. Detection requires a MutationObserver on divisor for all <feConvolveMatrix> elements on consent-containing ancestors, installed before any pointer events.

Summary table

AttackdivisorMechanismEffectSeverity
SA-CSS-DIV-001 0 SVG spec undefined; Chromium produces zero output; cross-engine inconsistency Dark-out (Chromium) or inconsistent High
SA-CSS-DIV-002 Very large (e.g., 1000000) kernel_sum / divisor ≈ 0; all channels → black; bias=0 Dark-out on any background High
SA-CSS-DIV-003 1 (with sum-9 kernel) Amplification × 9; pixel values clamp to 1.0 (white) for all non-black inputs White-out on light backgrounds High
SA-CSS-DIV-004 JS mousedown → 0.001 Amplification ×9000 at click time; all channels white; initial state normalized White-out at interaction High

Defences

SkillAudit findings for this attack surface

HIGH SA-CSS-DIV-001: feConvolveMatrix divisor="0" — SVG spec undefined behavior; Chromium produces zero-output dark-out; cross-engine inconsistency exploited for scanner evasion; always flag divisor=0 or near-zero.
HIGH SA-CSS-DIV-002: feConvolveMatrix with very large divisor — kernel_sum/divisor ≈ 0; with bias=0 all channels approach zero (dark-out); detection: compute max output = |kernel_abs_sum| / |divisor| and flag if below 0.05.
HIGH SA-CSS-DIV-003: feConvolveMatrix divisor="1" with un-normalized kernel (sum > 3) — amplification clamps mid-tone and light pixels to 1.0 (white-out); detection: check kernel_sum / divisor ratio; flag if above 3.
HIGH SA-CSS-DIV-004: JS mousedown injects near-zero divisor on feConvolveMatrix — amplification becomes extreme at click time; initial state normalized (legitimate); MutationObserver on divisor attribute before pointer events required.

Related: kernelMatrix consent attacks  |  preserveAlpha consent attacks  |  feConvolveMatrix order attacks  |  targetX/targetY anchor attacks

← Blog  |  Security Checklist