MCP server SVG feConvolveMatrix preserveAlpha consent security
Four SA-CSS-CVP consent attack patterns using feConvolveMatrix preserveAlpha="true" to apply convolution kernels exclusively to RGB channels while leaving alpha unchanged — blurring consent text to illegibility while evading alpha-channel detection rules that check for reduced opacity.
The SVG feConvolveMatrix filter primitive includes an attribute called preserveAlpha that, when set to "true", fundamentally changes how the convolution kernel is applied. Normally (preserveAlpha="false", the default), the kernel is applied independently to all four channels: R, G, B, and A. With preserveAlpha="true", the kernel is applied only to the R, G, and B channels; the alpha channel of each output pixel is copied verbatim from the corresponding input pixel, bypassing the convolution entirely. This channel-selective behaviour is the basis of the SA-CSS-CVP attack class: an attacker can apply a strongly blurring, edge-detecting, or smearing kernel to consent text while the alpha channel remains exactly as authored, causing any detection mechanism that inspects alpha values (opacity, alpha transparency) to see no anomaly while the text itself is rendered illegible.
Alpha-channel detection is blind to preserveAlpha attacks: A common detection heuristic checks whether a consent element or its text pixels have reduced alpha (e.g., opacity < 1, computed alpha < 0.9, or any pixel with alpha < 255 in a rasterised snapshot). With preserveAlpha="true", every output pixel from the feConvolveMatrix primitive inherits its alpha directly from the source without modification. On a white opaque background with dark text, alpha is 1.0 throughout both source and output. The alpha check returns zero findings. The only signal of the attack is the spatial distribution of RGB luminance values across glyph pixels — a detector must perform spatial/contrast analysis, not opacity analysis.
What preserveAlpha does: the SVG specification formula
The SVG specification defines two modes for feConvolveMatrix output. Understanding the formula difference is essential to understand why alpha-channel detection fails entirely against preserveAlpha attacks.
/* feConvolveMatrix output — preserveAlpha="false" (default):
For each channel C ∈ {R, G, B, A}:
result_C(x,y) = (Σ_{i,j} kernelMatrix[orderY-j-1][orderX-i-1]
× src_C(x+i-targetX, y+j-targetY)) / divisor + bias
All four channels pass through the convolution kernel.
A blur kernel applied to a dark-on-white opaque source:
Alpha channel: white background (A=1), dark text (A=1) → blurred alpha ≈ 1.0
(no visible alpha difference here either, but the mechanism is symmetric)
feConvolveMatrix output — preserveAlpha="true":
For channels C ∈ {R, G, B}:
result_C(x,y) = (Σ_{i,j} kernelMatrix[...] × src_C(...)) / divisor + bias
For the alpha channel:
result_A(x,y) = src_A(x,y) ← copied directly, kernel NOT applied
Consequence: any kernel that would normally modify alpha is neutered.
A 9×9 blur kernel with preserveAlpha=true on dark text (R=G=B=0, A=1)
surrounded by white background (R=G=B=1, A=1):
Output R/G/B at glyph edge: ≈ 0.7–0.9 (blurred toward white)
Output A at glyph edge: = 1.0 (copied from source, unchanged)
The pixel is near-white in colour but fully opaque in alpha.
A detector checking alpha sees: A=1 everywhere → no finding.
A human sees: illegible blurred smear on white background. */
The separation between RGB convolution and alpha passthrough is the core property that makes preserveAlpha="true" uniquely suited to consent attacks on opaque backgrounds. On a standard consent dialog with a white or near-white background and dark text, all source pixels have alpha = 1.0. After any convolution with preserveAlpha="true", all output pixels still have alpha = 1.0. The RGB values may be completely scrambled by the kernel, but alpha is untouched. Detection strategies that do not analyse RGB spatial distribution are entirely bypassed.
SA-CSS-CVP-001: 9×9 Gaussian blur with preserveAlpha=true — RGB-only blur, alpha intact
A 9×9 Gaussian-approximate kernel with divisor="81" (uniform average) and preserveAlpha="true" spreads each RGB channel value over a 9-pixel radius while leaving alpha untouched. For dark text (R=G=B≈0, A=1) on a white background (R=G=B=1, A=1): the kernel averages approximately 81 pixels, most of which are the white background. A glyph edge pixel that was pure dark (L=0) in the source becomes a mixture of dark stroke and white background. At a character stroke width of 2 pixels and a 9-pixel blur radius, the stroke is overwhelmed by background pixels: output luminance at the glyph edge ≈ 0.7–0.9. The character shape becomes unreadable at body text sizes (16px). Alpha values at the same pixels remain 1.0 throughout, since preserveAlpha="true" routes alpha around the kernel entirely. An alpha-channel detector checking whether any pixel has alpha < 1 reports zero anomalies.
<filter id="cvp001">
<feConvolveMatrix order="9"
kernelMatrix="1 1 1 1 1 1 1 1 1
1 2 2 2 2 2 2 2 1
1 2 3 3 3 3 3 2 1
1 2 3 4 4 4 3 2 1
1 2 3 4 5 4 3 2 1
1 2 3 4 4 4 3 2 1
1 2 3 3 3 3 3 2 1
1 2 2 2 2 2 2 2 1
1 1 1 1 1 1 1 1 1"
divisor="196"
preserveAlpha="true"
edgeMode="duplicate"/>
</filter>
<!-- preserveAlpha="true": kernel applied to R, G, B only.
Alpha channel copied directly from source — bypasses convolution.
Dark text (RGB≈0, A=1) on white background (RGB=1, A=1):
Output RGB at glyph edge after 9×9 blur: ≈ 0.7–0.9 (near-white smear)
Output A at glyph edge: 1.0 (unchanged — alpha passthrough)
Text is illegible; alpha check returns no anomaly.
WCAG luminance contrast check on raw computed color still returns
the original dark text color from getComputedStyle() — CSSOM blind spot.
Alpha detector: checks A < 1 → no pixels found → no finding.
Spatial RGB legibility detector: required to catch this attack. -->
/* Detection algorithm — classifying preserveAlpha blur on consent ancestors */
function detectPreserveAlphaBlur(consentEl) {
/* Walk up to collect all filters applied to ancestors */
let el = consentEl;
while (el && el !== document.body) {
const style = getComputedStyle(el);
const filterVal = style.filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(#([^)]+)\)/);
if (urlMatch) {
const filterId = urlMatch[1];
const filterEl = document.getElementById(filterId);
if (filterEl) {
for (const m of filterEl.querySelectorAll('feConvolveMatrix')) {
const pa = (m.getAttribute('preserveAlpha') || 'false').toLowerCase();
if (pa === 'true') {
/* preserveAlpha=true: alpha bypass confirmed; now classify the kernel */
const vals = (m.getAttribute('kernelMatrix') || '').trim()
.split(/\s+/).map(Number);
const div = parseFloat(m.getAttribute('divisor') || '1');
const kernelSum = vals.reduce((a, b) => a + b, 0);
const allNonNeg = vals.every(v => v >= 0);
const order = parseInt(m.getAttribute('order') || '3');
const centreIdx = Math.floor(vals.length / 2);
const isBlur = allNonNeg && Math.abs(kernelSum / div - 1.0) < 0.05 && order >= 5;
const isEdge = vals[centreIdx] > 4 && vals.filter(v => v < 0).length > vals.length / 2;
if (isBlur) return { risk: 'HIGH', type: 'preserve-alpha-blur', order };
if (isEdge) return { risk: 'CRITICAL', type: 'preserve-alpha-edge-detect' };
/* Non-identity kernel with preserveAlpha=true is always suspicious */
const isIdentity = vals[centreIdx] === 1 && vals.filter(v => v !== 0).length === 1;
if (!isIdentity) return { risk: 'MEDIUM', type: 'preserve-alpha-unknown-kernel' };
}
}
}
}
el = el.parentElement;
}
return null;
}
SA-CSS-CVP-002: Laplacian edge-detection with preserveAlpha=true — glyph interior to mid-gray, alpha=1
The Laplacian 3×3 edge-detection kernel (-1 -1 -1 / -1 8 -1 / -1 -1 -1) with divisor="1", bias="0.5", and preserveAlpha="true" applies the high-pass filter exclusively to R, G, B. The alpha channel passes through unchanged. For dark text on a white opaque background, the effect on each pixel type is:
- White background pixel (all neighbours also white, RGB=1.0): kernel output = 8×1.0 − 8×1.0 + 0.5 = 0.5 (mid-gray). Alpha = 1.0 (passthrough).
- Dark text interior pixel (all neighbours also dark, RGB=0): kernel output = 8×0 − 8×0 + 0.5 = 0.5 (mid-gray). Alpha = 1.0 (passthrough).
- Text edge pixel (centre dark RGB=0, 6 of 8 neighbours white RGB=1.0): kernel output = 8×0 − (6×1.0 + 2×0) + 0.5 = −5.5 → 0 (clamped). Alpha = 1.0 (passthrough).
The result: both background and text interior become uniform mid-gray (L≈0.5); text edges become dark (L≈0) but only 1 pixel wide. The consent text is unreadable: filled characters become indistinguishable gray blobs, with only 1-pixel-wide outlines at character boundaries. WCAG contrast for the background area: (1.05)/(0.5+0.05)≈1.91:1 (fails AA 4.5:1). Alpha across all pixels: 1.0 throughout. An alpha-based detection check returns no finding.
<filter id="cvp002">
<feConvolveMatrix order="3"
kernelMatrix="-1 -1 -1
-1 8 -1
-1 -1 -1"
divisor="1"
bias="0.5"
preserveAlpha="true"
edgeMode="duplicate"/>
</filter>
<!-- Laplacian edge-detection with preserveAlpha=true.
RGB kernel output:
White background: 8×1.0 - 8×1.0 + 0.5 = 0.5 (mid-gray)
Dark text interior: 8×0 - 8×0 + 0.5 = 0.5 (mid-gray)
Text edge pixels: 8×0 - 6×1.0 - 2×0 + 0.5 = -5.5 → 0 (dark, clamped)
Alpha output: src_A passed through untouched (preserveAlpha=true).
All output pixels: A=1.0 regardless of RGB content.
Background area WCAG: 1.05/(0.5+0.05) = 1.91:1 ← WCAG AA FAIL
Text edge outline: 1px dark on mid-gray — illegible at body text sizes.
Alpha detector: A=1 everywhere → no finding.
Kernel classifier: centre=8 > 4, majority of values < 0 → CRITICAL flag. -->
SA-CSS-CVP-003: Horizontal motion-blur kernel with preserveAlpha=true — text smeared, alpha intact
A horizontal motion-blur kernel of order 15 (a 15×15 matrix where only the middle row contains non-zero values, each equal to 1, with divisor=15) applies a 15-pixel horizontal smear to RGB channels while preserveAlpha="true" keeps every alpha value at its source value. For a consent dialog label like “I Agree to grant persistent access”: each character’s RGB pixels are averaged with the 7 pixels to the left and 7 pixels to the right. Adjacent characters bleed into each other. At 16px font size where a character glyph is approximately 8–10px wide, the 15-pixel smear spans 1.5 full character widths. Individual character shapes are indistinguishable. The text is unreadable. Alpha at every pixel remains equal to the source alpha (1.0 for an opaque background element). A check for opacity, computed alpha, or alpha-channel transparency detects nothing.
<filter id="cvp003">
<feConvolveMatrix order="15"
kernelMatrix="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"
divisor="15"
preserveAlpha="true"
edgeMode="duplicate"/>
</filter>
<!-- Horizontal motion-blur kernel (15px): only middle row non-zero.
Each output RGB pixel = average of the 15 horizontally adjacent source pixels.
preserveAlpha="true": alpha channel copied from source, not convolved.
For 16px body text with ~9px-wide character glyphs:
15px horizontal smear = 1.67× character width
Each character pixel averages in ~6 adjacent background pixels
and ~6 pixels from the neighbouring character.
Characters bleed: 'I' merges with 'A', 'A' merges with 'g', etc.
Resulting rendered text: continuous horizontal luminance gradient,
no distinct character boundaries visible. Consent text illegible.
Alpha: 1.0 everywhere (opaque source, preserveAlpha=true).
getComputedStyle().opacity = 1. Alpha detector: no finding. -->
SA-CSS-CVP-004: JS mousedown — identity (preserveAlpha=false) to blur (preserveAlpha=true)
The initial DOM state has a 3×3 identity kernel (kernelMatrix="0 0 0 0 1 0 0 0 0") with preserveAlpha="false". The identity kernel passes every pixel through unchanged: the centre coefficient is 1, all others are 0, so output = source with no modification to any channel including alpha. This state passes static analysis. On mousedown (the event that fires when the user presses the consent confirmation button), two attributes are set atomically: setAttribute("preserveAlpha", "true") and setAttribute("kernelMatrix", <9×9 Gaussian blur kernel>). The order attribute is also updated to 9. At the instant the user’s finger or cursor contacts the button, the consent text blurs to illegibility. The click confirmation (mouseup) fires with the text already unreadable. Static scanners that snapshot the DOM at page load see only the identity kernel and preserveAlpha="false"; they report no finding. A MutationObserver watching both the preserveAlpha and kernelMatrix attributes is required to catch this transition.
<svg style="position:absolute;width:0;height:0">
<defs>
<filter id="cvp004">
<feConvolveMatrix id="cvp004K" order="3"
kernelMatrix="0 0 0 0 1 0 0 0 0"
divisor="1"
preserveAlpha="false"/>
</filter>
</defs>
</svg>
<div style="filter:url(#cvp004)">
<p>Grant this MCP server read access to your file system.</p>
<button id="consentBtn">I Agree</button>
</div>
<script>
const BLUR_9X9 =
"1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 1 1 2 3 3 3 3 3 2 1 " +
"1 2 3 4 4 4 3 2 1 1 2 3 4 5 4 3 2 1 1 2 3 4 4 4 3 2 1 " +
"1 2 3 3 3 3 3 2 1 1 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1";
document.getElementById("consentBtn").addEventListener("mousedown", () => {
const k = document.getElementById("cvp004K");
k.setAttribute("order", "9");
k.setAttribute("kernelMatrix", BLUR_9X9);
k.setAttribute("divisor", "196");
k.setAttribute("preserveAlpha", "true");
/* Consent text instantly becomes illegible at click confirmation moment.
preserveAlpha=true ensures alpha=1 throughout — no alpha anomaly.
mouseup (click completion) fires with blurred consent text displayed.
User has "confirmed" content they cannot read at the confirmation instant. */
});
</script>
/* MutationObserver defence — watch preserveAlpha and kernelMatrix on filter descendants */
function guardConsentFilter(filterId, onViolation) {
const filterEl = document.getElementById(filterId);
if (!filterEl) return;
const mo = new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.type !== 'attributes') continue;
const target = mut.target;
if (!(target instanceof SVGFEConvolveMatrixElement)) continue;
const attrName = mut.attributeName;
if (attrName === 'preserveAlpha' || attrName === 'kernelMatrix'
|| attrName === 'order' || attrName === 'divisor') {
const pa = (target.getAttribute('preserveAlpha') || 'false').toLowerCase();
const vals = (target.getAttribute('kernelMatrix') || '').trim()
.split(/\s+/).map(Number);
const div = parseFloat(target.getAttribute('divisor') || '1');
const allNonNeg = vals.every(v => v >= 0);
const kernelSum = vals.reduce((a, b) => a + b, 0);
const order = parseInt(target.getAttribute('order') || '3');
const isBlur = allNonNeg && Math.abs(kernelSum / div - 1.0) < 0.05 && order >= 5;
const centreIdx = Math.floor(vals.length / 2);
const isEdge = vals[centreIdx] > 4 && vals.filter(v => v < 0).length > vals.length / 2;
if ((pa === 'true') && (isBlur || isEdge)) {
onViolation({ type: 'preserve-alpha-mutation', attribute: attrName, isBlur, isEdge });
}
}
}
});
mo.observe(filterEl, { subtree: true, attributes: true,
attributeFilter: ['preserveAlpha', 'kernelMatrix', 'order', 'divisor'] });
return mo;
}
Why spatial RGB analysis is required
Standard consent-legibility checks operate on one of two data sources: (1) computed style properties via CSSOM (getComputedStyle().color, opacity, visibility), or (2) alpha channel values in a rasterised snapshot. Neither source reveals a preserveAlpha="true" attack. The CSSOM reports the original authored colour and opacity, with no visibility into SVG filter post-processing. Alpha-channel analysis of a rasterised snapshot shows alpha = 1.0 at every pixel (because preserveAlpha="true" copies source alpha to output). The only correct detection strategy is spatial luminance analysis over glyph pixels: compare the luminance distribution of pixels within the text glyph area against the luminance distribution of the same pixels without the filter applied. A blur attack will show that glyph interior luminance has shifted toward background luminance (high-variance region of the source has collapsed toward a mean). An edge-detection attack will show that glyph interior has shifted from low luminance to mid luminance (0.5). Both signatures are invisible to alpha-only checks.
Practical detection gap: Most MCP server consent audit tools that flag feGaussianBlur or opacity-reducing feColorMatrix do not flag feConvolveMatrix with preserveAlpha="true". The filter primitive name is different from feGaussianBlur, evading name-based rules; the alpha channel is untouched, evading alpha-based rules; and the CSSOM colour is unchanged, evading style-based rules. A complete audit must classify the kernelMatrix attribute of every feConvolveMatrix element in any filter applied to a consent ancestor, and separately check preserveAlpha to determine whether an alpha anomaly would even be visible.
Attack summary
| ID | Kernel type | preserveAlpha | Effect on consent text | WCAG impact | Severity |
|---|---|---|---|---|---|
| SA-CSS-CVP-001 | 9×9 Gaussian blur | true | Stroke spreads over 9px, text unreadable; alpha=1 throughout | Glyph edge L≈0.7–0.9; alpha detector: no finding | High |
| SA-CSS-CVP-002 | 3×3 Laplacian edge-detect | true | Solid text → 1px outlines on mid-gray; alpha=1 throughout | Background WCAG 1.91:1 (fail); alpha detector: no finding | Critical |
| SA-CSS-CVP-003 | 15px horizontal motion-blur | true | Characters smeared horizontally 1.67× glyph width; alpha=1 | Characters bleed together; WCAG 1.5–2:1; alpha: no finding | High |
| SA-CSS-CVP-004 | Identity (static) → 9×9 blur (mousedown) | false→true (mousedown) | Correct at load; illegible at click confirmation moment | Static analysis: benign; MutationObserver required | Critical |
SkillAudit findings for this attack surface
feConvolveMatrix preserveAlpha="true" with order=9 Gaussian-approximate kernel (all-positive coefficients, divisor=sum) on consent ancestor — kernel applied to RGB channels only; alpha unchanged at 1.0 throughout; glyph edge pixels L≈0.7–0.9 after 9px blur radius; human legibility fails before WCAG single-point threshold; alpha-channel detectors return no anomaly; detection requires kernel coefficient classification (all-positive, sum≈divisor, order≥5).feConvolveMatrix preserveAlpha="true" with Laplacian edge-detection kernel (order=3, kernelMatrix=‘-1 -1 -1 -1 8 -1 -1 -1 -1’, divisor=1, bias=0.5) on consent ancestor — kernel applied to RGB only; removes solid text fill (interior pixels → mid-gray L=0.5), converts text to 1px dark outlines; WCAG for background area: 1.91:1 (fail); alpha=1 everywhere; detection: centre coefficient > 4 and majority of values negative = edge-detect signature, classify CRITICAL.feConvolveMatrix preserveAlpha="true" with 15px horizontal motion-blur kernel (middle row all-ones, all other rows zero, divisor=15) on consent ancestor — RGB channels smeared 15 pixels horizontally; characters bleed into adjacent characters and whitespace; at 16px body text, smear spans 1.67 character widths; consent text illegible; alpha=1 throughout; evades all alpha and CSSOM contrast checks.feConvolveMatrix from identity kernel + preserveAlpha="false" to 9×9 Gaussian blur + preserveAlpha="true" simultaneously; consent text illegible at click confirmation moment; static DOM shows identity/no-effect kernel; MutationObserver on preserveAlpha, kernelMatrix, and order attributes required; on any mutation: re-classify kernel and block confirmation if kernel transitions to blur or edge-detect with preserveAlpha=true.