Security Guide
MCP server SVG feConvolveMatrix kernelMatrix consent security — all-zero dark-out, extreme-value white-out, sharpen overflow at glyph edges, and JS mousedown injection
The kernelMatrix attribute of <feConvolveMatrix> defines the convolution weights applied to each pixel's neighborhood. The filter formula is: C_out = bias + Σ(kernelMatrix[i][j] × input[x+i][y+j]) / divisor, with output clamped to [0,1]. All-zero kernelMatrix makes the sum zero: output equals bias (default 0) for all pixels — solid black. All-large positive values overflow the sum far above 1.0, clamping every pixel to white. Extreme sharpen coefficients cause predictable overflow or underflow at glyph edges. Four attack patterns follow.
Attack 1: kernelMatrix all zeros — convolution sum = 0, output = bias (SA-CSS-KMX-001)
A convolution with all-zero weights computes: C_out = bias + 0/divisor = bias. With the default bias="0", every output pixel for every channel is 0 — solid black. The consent text becomes invisible against dark-themed backgrounds. The element has non-zero BCR dimensions, non-empty textContent, and visibility:visible in CSSOM — every DOM presence check passes. Only rendering simulation or kernelMatrix inspection reveals the attack.
This attack differs from the general feConvolveMatrix coverage: it focuses on a specific degenerate kernel pattern (all-zero) rather than arbitrary invalid kernels. The all-zero pattern is easily generated dynamically (all values initialized to 0) and has a simple detection rule: if sum(kernelMatrix) === 0 and bias === 0, the output is a constant-value image, not a convolution of the source.
<!-- SA-CSS-KMX-001: kernelMatrix all zeros — convolution sum = 0, C_out = bias = 0
order=3 → 9 kernel values: "0 0 0 0 0 0 0 0 0"
C_out = 0 + (0*p00 + 0*p01 + ... + 0*p22) / 1 = 0 for all channels
Result: solid black image; consent text invisible on dark backgrounds -->
<filter id="kmx-zero">
<feConvolveMatrix order="3"
kernelMatrix="0 0 0
0 0 0
0 0 0"
divisor="1" bias="0" preserveAlpha="false"/>
</filter>
<div id="consent" style="filter:url(#kmx-zero)">
<p>Requesting: filesystem access and credential read.</p>
<button id="allow">Allow</button>
</div>
// Detection: check if kernelMatrix sum and bias produce constant output
function auditKernelMatrix(convEl) {
const kmStr = convEl.getAttribute('kernelMatrix') || '';
const km = kmStr.trim().split(/\s+/).map(Number);
const bias = parseFloat(convEl.getAttribute('bias') || '0');
const divisor = parseFloat(convEl.getAttribute('divisor') || '1');
const kernelSum = km.reduce((a, b) => a + b, 0);
// If all kernel weights are zero, output = bias for all pixels
if (km.every(v => v === 0)) {
if (Math.abs(bias) < 0.05) {
return { sev: 'HIGH', note: 'kernelMatrix all-zeros, bias=0: output=0 (solid black) for all pixels' };
}
if (bias > 0.9) {
return { sev: 'HIGH', note: `kernelMatrix all-zeros, bias=${bias}: output=${bias.toFixed(2)} (near-white) for all pixels` };
}
}
return null;
}
HIGH — SA-CSS-KMX-001: kernelMatrix all-zero values with default bias="0" produces solid-black output for all pixels. All DOM visibility checks pass (non-zero BCR, non-empty textContent, visibility:visible). Detection requires computing the convolution sum or checking if all kernel values are zero.
Attack 2: kernelMatrix all large positive values — sum overflow, clamp to white (SA-CSS-KMX-002)
If all kernel weights are large positive values (e.g., 500 in a 3×3 kernel with divisor="1"), the convolution sum is: C_out = bias + 500 × (p00 + p01 + ... + p22). For any non-zero pixel (which most consent text pixels are), the sum is enormous. Even for a very dark pixel (C_in = 0.066, corresponding to near-black #111): 500 × 9 × 0.066 = 297, which clamps to 1.0. Every pixel output is 1.0 (maximum white) for all channels. The consent text becomes invisible against white page backgrounds.
The attack is equivalent to a feFlood with flood-color="white" but is disguised as a "sharpening" or "enhancement" convolution. The large positive kernel values evade naive sanity checks that only look for negative kernel weights or extreme patterns. Detection requires computing the minimum possible sum (even for the darkest input, the sum exceeds 1.0 for any kernel weight large enough).
<!-- SA-CSS-KMX-002: kernelMatrix all large values — sum overflow → clamp to white
For 3×3 kernel, each weight=500, divisor=1:
C_out = 500*(p[0]+p[1]+...+p[8]) ≥ 500*9*0 = 0 (transparent), but
for any non-zero pixel: 500*9*0.066 ≈ 297 >> 1, clamped to 1.0
Result: all output pixels = white; consent text invisible on white bg -->
<filter id="kmx-whiteout">
<feConvolveMatrix order="3"
kernelMatrix="500 500 500
500 500 500
500 500 500"
divisor="1" bias="0" preserveAlpha="false"/>
</filter>
// Detection: compute minimum possible output for a representative dark pixel
function checkKernelOverflow(convEl) {
const km = (convEl.getAttribute('kernelMatrix') || '').trim().split(/\s+/).map(Number);
const divisor = parseFloat(convEl.getAttribute('divisor') || '1');
const bias = parseFloat(convEl.getAttribute('bias') || '0');
const order = parseInt(convEl.getAttribute('order') || '3');
const nKernel = order * order;
// For a representative dark pixel (0.07) in a uniform region:
const typicalPixel = 0.07;
const minSum = km.reduce((a, v) => a + Math.max(0, v) * typicalPixel, 0) / divisor + bias;
if (minSum > 0.95) {
return { sev: 'HIGH', note: `kernelMatrix sum for dark pixel (0.07): ${minSum.toFixed(2)} — clamps to white; white-out attack` };
}
// Also check max possible for negative values:
const maxNeg = km.reduce((a, v) => a + Math.min(0, v) * typicalPixel, 0) / divisor + bias;
if (maxNeg < 0.05) {
return { sev: 'HIGH', note: `kernelMatrix produces near-black output for dark pixels: ${maxNeg.toFixed(2)}` };
}
return null;
}
HIGH — SA-CSS-KMX-002: All-large-positive kernelMatrix values (e.g., all 500 with divisor="1") produce near-infinite convolution sums that clamp to 1.0 (white) for any non-black pixel. Consent text is invisible against white backgrounds. Detection requires computing the expected sum for a representative dark-pixel input, not checking the kernel pattern visually.
Attack 3: Extreme sharpen kernel — uniform-text interior goes to zero (SA-CSS-KMX-003)
A standard 3×3 sharpen kernel has the form: center-weight large positive, eight surrounding weights large negative, such that a uniform region (same color in all 9 positions) produces output equal to the center weight minus 8 times the surrounding weight, divided by the divisor. For a typical sharpen kernel like 0 -100 0 / -100 401 -100 / 0 -100 0 (divisor=1), on a uniform dark region: C_out = (401 × C_in + 4 × (-100) × C_in) / 1 = (401 - 400) × C_in = C_in. This is exactly the identity — uniform regions are unchanged.
However, with different weights: 0 -200 0 / -200 401 -200 / 0 -200 0 (divisor=1), on a uniform region: C_out = (401 - 800) × C_in = -399 × C_in. For C_in=0.066 (near-black): -399 × 0.066 ≈ -26.3, clamped to 0. The interior of text glyphs goes to black. For C_in=1.0 (white background): -399 × 1.0 = -399, clamped to 0. Both text color and background become 0 (black). On dark backgrounds, this produces zero contrast and text is invisible.
The sharpen-like kernel disguises itself as a legitimate image enhancement filter. The key distinguishing attribute is when the sum of all kernel weights multiplied by a uniform-region pixel value produces near-zero output, not the identity.
<!-- SA-CSS-KMX-003: Extreme sharpen kernel — uniform text interior → near zero
Kernel: 0 -200 0 / -200 401 -200 / 0 -200 0, divisor=1
Sum of weights: 401 + 4×(-200) = 401 - 800 = -399
For uniform dark text pixel (0.066): -399 × 0.066 ≈ -26.3 → clamped to 0
Text interior: black; glyph edges: some positive values; net: blurry dark outline
Against dark background: nearly invisible -->
<filter id="kmx-sharpen-extreme">
<feConvolveMatrix order="3"
kernelMatrix="0 -200 0
-200 401 -200
0 -200 0"
divisor="1" bias="0" preserveAlpha="true"/>
</filter>
// Detection: compute output for uniform dark region (all pixels same value)
function checkUniformRegionOutput(convEl) {
const km = (convEl.getAttribute('kernelMatrix') || '').trim().split(/\s+/).map(Number);
const divisor = parseFloat(convEl.getAttribute('divisor') || '1');
const bias = parseFloat(convEl.getAttribute('bias') || '0');
const uniformColor = 0.066; // near-black text
const uniformOutput = (km.reduce((a, v) => a + v * uniformColor, 0)) / divisor + bias;
const clampedOutput = Math.min(1, Math.max(0, uniformOutput));
// If uniform dark region maps to near-zero, text interior becomes black
if (clampedOutput < 0.02) {
return { sev: 'HIGH', note: `kernelMatrix uniform-region output=${clampedOutput.toFixed(3)} for dark pixel — text interior collapses to black` };
}
return null;
}
HIGH — SA-CSS-KMX-003: An extreme sharpen kernel where the sum of all weights is large negative (e.g., 401 − 4×200 = −399) maps uniform dark regions to near-zero output. Text glyph interiors collapse to black. Against dark backgrounds, the consent text becomes invisible. Detection requires computing the kernel sum for a uniform-color input, not inspecting the kernel pattern visually.
Attack 4: JS mousedown injects kernelMatrix all-zeros (SA-CSS-KMX-004)
JavaScript replaces the consent element's feConvolveMatrix kernelMatrix attribute with all-zero values at mousedown capture phase. The initial state uses a valid kernel (e.g., identity or mild sharpen). On mousedown, the kernel becomes all-zero — output is 0 (black) for all pixels. The consent text disappears at the exact moment the user clicks. Because the filter state is valid at page load and during passive viewing, pre-interaction static analysis alone cannot catch this variant.
<!-- SA-CSS-KMX-004: JS mousedown injects kernelMatrix all-zeros
Initial state: identity kernel "0 0 0 0 1 0 0 0 0" — normal display
On mousedown: kernelMatrix set to "0 0 0 0 0 0 0 0 0" — output=black -->
<filter id="kmx-dynamic">
<feConvolveMatrix id="kmx-conv" order="3"
kernelMatrix="0 0 0 0 1 0 0 0 0"
divisor="1" bias="0"/>
</filter>
<div id="consent" style="filter:url(#kmx-dynamic)">
<p>Allow SkillCheck: read filesystem, access API credentials.</p>
<button id="allow">Allow</button>
</div>
<script>
document.getElementById('consent').addEventListener('mousedown', () => {
document.getElementById('kmx-conv').setAttribute('kernelMatrix', '0 0 0 0 0 0 0 0 0');
}, { capture: true });
</script>
// Detection: MutationObserver on feConvolveMatrix for kernelMatrix and bias changes
const convEl = document.getElementById('kmx-conv');
new MutationObserver(muts => {
for (const m of muts) {
if (m.attributeName === 'kernelMatrix' || m.attributeName === 'bias') {
const km = (convEl.getAttribute('kernelMatrix') || '').trim().split(/\s+/).map(Number);
const bias = parseFloat(convEl.getAttribute('bias') || '0');
if (km.every(v => v === 0) && Math.abs(bias) < 0.05) {
console.warn('[SA-CSS-KMX-004] kernelMatrix changed to all-zeros during interaction');
}
}
}
}).observe(convEl, {
attributes: true,
attributeFilter: ['kernelMatrix', 'bias', 'divisor', 'order']
});
HIGH — SA-CSS-KMX-004: JS mousedown injection of kernelMatrix all-zeros makes the consent element black at click time. Initial state is valid. MutationObserver required on all <feConvolveMatrix> elements watching kernelMatrix and bias attribute changes, with post-mutation kernel evaluation.
Summary table
| Attack | Kernel pattern | Output | Background required | Severity |
|---|---|---|---|---|
| SA-CSS-KMX-001 | All zeros (e.g., 9-value all-0) | bias (default 0) → solid black | Dark-themed backgrounds | High |
| SA-CSS-KMX-002 | All large positive (e.g., all 500, divisor=1) | Overflow → clamp to 1.0 → solid white | White/light page backgrounds | High |
| SA-CSS-KMX-003 | Extreme sharpen (center+, edges very negative, sum<<0) | Uniform dark regions → 0 (black); glyph edges partially visible | Dark-themed backgrounds | High |
| SA-CSS-KMX-004 | JS mousedown → all-zeros injection | Output=black at click time; initial state valid | Dark-themed; MutationObserver required | High |
Defences
- Evaluate kernel against representative inputs — compute the convolution output for a uniform dark pixel (C_in=0.066) and a uniform white pixel (C_in=1.0). Flag outputs below 0.05 or above 0.95 for degenerate kernel patterns.
- Check if all kernel values are zero — this is the simplest and most common degenerate case. Flag immediately if
sum(kernelMatrix) === 0and all values are 0. - Check for large-value overflow — if
sum(kernelMatrix) / divisor × 0.07 + bias > 0.9, the kernel will produce near-white output for non-black pixels. Flag as potential white-out. - Pre-interaction MutationObserver — observe all
<feConvolveMatrix>elements forkernelMatrix,bias, anddivisorchanges before any user interaction events fire. Re-evaluate the kernel on any attribute change.
SkillAudit findings for this attack surface
kernelMatrix all-zeros with bias="0" — output=0 (solid black) for all pixels; all DOM checks pass; invisible on dark backgrounds.kernelMatrix all large-positive values (e.g., all 500, divisor=1) — sum overflows for any non-black pixel; output clamps to 1.0 (white); invisible on white backgrounds.kernelMatrix all-zeros injection — attack window ~50ms; initial state valid; MutationObserver on kernelMatrix/bias/divisor attributes required.