SVG feConvolveMatrix Attribute Attacks on MCP Consent: preserveAlpha, divisor, order, targetX/targetY, and fePointLight
Five convolution and lighting-filter attributes — each one an independent consent-bypass attack vector — and why evaluating their rendered output is the only detection strategy that works across all of them.
Introduction
The feConvolveMatrix SVG filter primitive applies a kernel convolution to its input image. Its public interface is dominated by kernelMatrix — the array of coefficients — but the primitive's behaviour is controlled by five additional attributes: preserveAlpha, divisor, order, targetX/targetY, and the light-source family that includes fePointLight. Each of these is a separate attack surface, because each controls a different dimension of what the convolution actually produces on screen.
This post synthesises the SA-CSS-PAL, SA-CSS-DIV, SA-CSS-CMO, SA-CSS-TXY, and SA-CSS-FPL finding families, covering twenty attack patterns discovered while scanning community MCP skills and consent dialogs. The common thread across all five is a property shared with the broader SVG filter attack class: the computed DOM values are never wrong. getComputedStyle returns the declared CSS color. getBoundingClientRect returns the layout dimensions. textContent returns the full consent string. The only signal that something is wrong is the rendered output — and most scanners never look there.
Scope: This research covers SVG filter consent attacks within MCP skills that render consent UI via embedded SVG or HTML+SVG compositing. All findings were reproduced on current Chromium and Gecko. Attack codes follow the SA-CSS-<family> convention established in earlier posts.
1. preserveAlpha — separating RGB zeroing from alpha-channel zeroing
The preserveAlpha boolean attribute controls whether the alpha channel participates in the convolution or is copied verbatim from the source. Its default value is false, meaning alpha is convolved along with RGB. Setting it to true routes alpha around the kernel and copies it directly to output.
The attack geometry
With a zero-kernel (all coefficients 0) and bias="0", feConvolveMatrix sets every output channel to zero: RGBA = (0,0,0,0). The element becomes fully transparent. Click events still fire — the element is there, laid out at its normal position, passing all DOM checks. This is SA-CSS-PAL-003 (CRITICAL).
Setting preserveAlpha="true" with the same zero kernel changes which channels are zeroed. The alpha channel is preserved — copied from source — so the element is fully opaque. RGB channels are zero-convolved. The result is RGBA = (0,0,0,source_alpha): a solid black rectangle. On a dark UI this is invisible (SA-CSS-PAL-001, HIGH). Adding bias="1" shifts the clamped RGB output to (1,1,1): pure white, invisible on light UIs (SA-CSS-PAL-002, HIGH).
<!-- SA-CSS-PAL-003: preserveAlpha=false (default), zero kernel → fully transparent -->
<filter id="pal3">
<feConvolveMatrix order="3"
kernelMatrix="0 0 0 0 0 0 0 0 0"
divisor="1" bias="0"
preserveAlpha="false"/>
</filter>
<!-- Result: RGBA=(0,0,0,0) — invisible; click events fire normally -->
<!-- SA-CSS-PAL-001: preserveAlpha=true, zero kernel → opaque black -->
<filter id="pal1">
<feConvolveMatrix order="3"
kernelMatrix="0 0 0 0 0 0 0 0 0"
divisor="1" bias="0"
preserveAlpha="true"/>
</filter>
<!-- Result: RGBA=(0,0,0,source_alpha) — solid black, invisible on dark UIs -->
The SA-CSS-PAL-004 mousedown variant toggles preserveAlpha from true to false at click time, reducing alpha from source to zero. The consent text renders correctly until the user attempts to click; at that moment it becomes fully transparent. This exploits the MutationObserver gap — many scanners add observers for kernelMatrix and bias but not for boolean attribute changes on the same primitive.
Detection
The correct check is not "is preserveAlpha present?" but "what is the output RGBA for a representative foreground pixel?". Evaluate: if preserveAlpha=false, compute output alpha from the kernel; if preserveAlpha=true, output alpha = source alpha, but RGB must still be evaluated. In both cases flag when computed contrast against background falls below WCAG 4.5:1. Also add preserveAlpha to MutationObserver attribute filters on any feConvolveMatrix element found in an ancestor filter of a consent node.
2. divisor — controlling post-convolution amplitude
After applying the kernel, feConvolveMatrix computes: output_channel = (Σ kernel[i]*pixel[i]) / divisor + bias. The divisor attribute therefore controls the amplitude of the convolution result. Its default value per the SVG spec is the sum of kernel coefficients (or 1 if that sum is zero). Attackers can override it to force any desired output magnitude.
Attack mechanics
divisor=0 (SA-CSS-DIV-001): The SVG spec does not define behaviour for a zero divisor. Chromium produces zero output (black) for all channels. Gecko may differ. This cross-browser inconsistency is the exploit: a skill tested in one browser passes, deployed in another it dark-outs.
Very large divisor (SA-CSS-DIV-002): A divisor of 1,000,000 reduces any reasonable kernel sum to a value indistinguishable from zero. With a 3×3 all-ones kernel (sum=9) and divisor=1,000,000, the output is 9/1,000,000 ≈ 0 per channel before bias. Dark-out. The detection rule is: compute |kernel_abs_sum| / |divisor|; flag when this ratio is below a threshold (e.g., 0.01 — a ratio that would produce sub-1% of full scale).
Small divisor with large kernel sum (SA-CSS-DIV-003): A 3×3 all-ones kernel has sum=9. With divisor=1, the per-channel formula amplifies each pixel by 9. Any pixel with a non-zero channel clamps to 1.0. The result is a white-out: every pixel in the consent text area becomes (1,1,1). With lighting-color effects this is a particularly clean attack. Detection: flag kernel_sum / divisor > 3.
<!-- SA-CSS-DIV-003: sum=9, divisor=1 → ×9 amplification → white-out via clamping -->
<filter id="div3">
<feConvolveMatrix order="3"
kernelMatrix="1 1 1 1 1 1 1 1 1"
divisor="1" bias="0"/>
</filter>
<!-- All non-black pixels: channel × 9 → clamped to 1.0 → white; text invisible on white backgrounds -->
<!-- SA-CSS-DIV-004: JS mousedown changes divisor from 9 to 0.001 → ×9000 amplification -->
element.addEventListener("mousedown", () => {
filter.querySelector("feConvolveMatrix").setAttribute("divisor", "0.001");
});
Key detection ratio: always compute |kernel_abs_sum| / |divisor|. Values < 0.01 indicate near-zero output (dark-out risk). Values > 3 indicate overflow clamping (white-out risk). Neither check is a simple attribute string compare.
3. order — kernel dimension mismatch and undefined territory
The order attribute sets the kernel dimensions (orderX × orderY values expected in kernelMatrix). When the declared order requires N coefficients but the kernelMatrix attribute contains fewer, the SVG spec marks the behaviour as undefined. Browser implementations diverge: Gecko zero-fills the missing positions (creating a near-zero kernel for large mismatches), while Chromium may accept or reject the mismatch differently.
Attack patterns
Large order with small kernelMatrix (SA-CSS-CMO-001): Declaring order="100" requires 10,000 kernel values. Providing only 9 creates a 9,991-position mismatch. In Gecko, zero-filling produces a kernel where 9,991/10,000 = 99.9% of values are zero. The effective convolution is nearly an all-zero kernel — dark-out in all browsers that zero-fill.
order=1 minimal zero-kernel (SA-CSS-CMO-002, CRITICAL): This is the minimal valid SVG filter that zero-outs consent text. A 1×1 kernel with value 0 passes all length validation — the kernel has exactly one value for a 1×1 order. With bias="0", output is zero for all channels. There is no attribute-value mismatch to detect. The only signal is that a 1×1 zero kernel has no legitimate use in a consent UI.
<!-- SA-CSS-CMO-002: minimal valid all-zero kernel — order=1, kernelMatrix=0 -->
<filter id="cmo2">
<feConvolveMatrix order="1"
kernelMatrix="0"
divisor="1" bias="0"/>
</filter>
<!-- All RGBA channels → 0. Fully transparent. Passes all length checks. -->
<!-- SA-CSS-CMO-003: asymmetric large orderX + edgeMode=none -->
<feConvolveMatrix orderX="321" orderY="3"
kernelMatrix="... (9 values) ..."
edgeMode="none"/>
<!-- Boundary zone width = (321-1)/2 = 160px each side → entire 320px banner in zero zone -->
Asymmetric large orderX with edgeMode=none (SA-CSS-CMO-003): When edgeMode="none", pixels within (orderX-1)/2 pixels of the left and right edges receive zero output. With orderX=321, both boundary zones are 160px wide. A 320px consent banner is entirely covered. Text in the boundary zone is zeroed by the edge treatment, not the kernel — the kernel itself can be a valid identity matrix. This evades all kernel-value checks.
4. targetX and targetY — shifting boundary erasure zones
targetX and targetY set the anchor position of the kernel relative to the pixel being computed. Default values are floor(orderX/2) and floor(orderY/2) — the center. Non-default values shift where the boundary-zero zone is positioned.
Asymmetric boundary zones
With a default center anchor and edgeMode="none", boundary zones appear symmetrically on both sides. With targetX=0 (left anchor), the convolution reads pixels to the right of the current pixel for all kernel offsets. This means the right-side boundary zone is (orderX-1) pixels wide, and the left-side zone is zero — all boundary erasure moves to the right edge (SA-CSS-TXY-001).
For a horizontally-oriented consent banner, this allows an attacker to choose which side of the text is erased. For a multi-line consent dialog, targetY=0 (SA-CSS-TXY-002) shifts the full vertical boundary zone to the bottom rows — erasing the critical final row of the consent text where "Allow access to your filesystem" ends. targetX=0, targetY=0 (SA-CSS-TXY-003) combines both shifts for corner-targeted erasure covering a precise rectangular region.
<!-- SA-CSS-TXY-001: targetX=0 → right boundary zone = (orderX-1) px wide -->
<feConvolveMatrix order="51" targetX="0" targetY="25"
kernelMatrix="... identity kernel ..."
edgeMode="none"/>
<!-- Right boundary zone: 50px. Any text within 50px of right edge → zeroed. -->
<!-- SA-CSS-TXY-004: JS mousedown shifts targetX from center to 0 -->
element.addEventListener("mousedown", () => {
const fecm = filter.querySelector("feConvolveMatrix");
fecm.setAttribute("targetX", "0"); // shifts entire right boundary zone at click
});
The SA-CSS-TXY-004 mousedown variant changes targetX from its default center value to 0 at click time, doubling the right boundary zone from (orderX-1)/2 to (orderX-1) pixels. This moves the boundary erasure to cover the "Accept" button label or the end of the consent scope line at the moment the user taps it.
Why standard detectors miss this
The kernel itself can be a valid 3-point identity (e.g., order="51" kernelMatrix="0 0 ... 1 ... 0 0" with the 1 at position targetX). A check that only evaluates the kernel coefficients will find nothing suspicious. The attack is in the geometry of the boundary zone relative to the element dimensions — which requires knowing the rendered element width, the order, and the targetX to evaluate.
5. fePointLight z-coordinate — replicating feDistantLight elevation geometry
The SA-CSS-FDL family (covered in the feDistantLight post) documents consent attacks where elevation=0 produces zero diffuse output and elevation=90 with high specularConstant produces white-out. Detectors that implement these rules check for specific attribute values on feDistantLight. The fePointLight family replicates the same optical geometry via a different code path those detectors never reach.
fePointLight z geometry
For a fePointLight positioned at coordinates (x, y, z) relative to the element, the light direction vector from any pixel to the light is L = normalize(lightX-pixelX, lightY-pixelY, z). For a point light directly above the center of the consent element (lightX ≈ element.cx, lightY ≈ element.cy), the horizontal components approach zero as z increases — the vector approaches (0, 0, 1), identical to feDistantLight with elevation=90.
SA-CSS-FPL-002 (HIGH): fePointLight z="10000" placed above the consent element center → L ≈ (0,0,1) for all pixels. With feSpecularLighting specularConstant="20" and lighting-color="white", specular output = 20 × 1^expo × white, clamped to (1,1,1). feBlend mode="screen" composites the all-white specular onto the consent text → white-out. An elevation-specific detector finds nothing wrong — there is no feDistantLight in this filter.
SA-CSS-FPL-001 (HIGH): fePointLight z="0" places the light in the plane of the element. For a pixel anywhere except directly at (lightX, lightY), the light direction vector is L = normalize(dx, dy, 0) — a horizontal vector. The half-vector H = normalize(L + V) where V = (0,0,1) gives H.z = 1/sqrt(2). For specularExponent=100, dot(H,N)^100 = (1/sqrt(2))^100 ≈ 6×10-17. Specular output is effectively zero. Dark-out. Same geometry as feDistantLight elevation=0, different primitive.
SA-CSS-FPL-003 (HIGH): Negative z places the light behind the surface plane. The SVG spec does not define this case. In practice, specular output approaches zero (dark-out), and the inconsistency across Chromium and Gecko creates an additional scanner-evasion dimension: a skill may behave differently in the test environment versus the target deployment environment.
<!-- SA-CSS-FPL-001: in-plane light → specular dark-out -->
<filter id="fpl1">
<feSpecularLighting specularConstant="20" specularExponent="100" lighting-color="white">
<fePointLight x="50%" y="50%" z="0"/>
</feSpecularLighting>
<feBlend in="SourceGraphic" in2="result" mode="multiply"/>
</filter>
<!-- z=0: light horizontal; dot(H,N)^100 ≈ 0; specular ≈ 0 → multiply by ~0 → dark-out -->
<!-- SA-CSS-FPL-002: overhead point light → specular white-out -->
<filter id="fpl2">
<feSpecularLighting specularConstant="20" specularExponent="10" lighting-color="white">
<fePointLight x="50%" y="50%" z="10000"/>
</feSpecularLighting>
<feBlend in="SourceGraphic" in2="result" mode="screen"/>
</filter>
<!-- z=10000: L ≈ (0,0,1); dot(H,N) ≈ 1; specular = 20×1 → clamped white; screen → white-out -->
Unified detection strategy
All five attribute families share the same root cause: they manipulate a dimension of the convolution or lighting computation that changes the rendered output without being detectable from attribute values alone. The unified detection approach must evaluate the rendered output of the filter, not the configuration attributes.
| Attribute | Attack dimension | Attribute-value check sufficient? | Required detection |
|---|---|---|---|
preserveAlpha |
Which channels are zeroed (RGB vs RGBA) | No — must know background to determine visibility | Simulate output RGBA; compute contrast vs background |
divisor |
Output amplitude (dark-out, white-out, amplification) | No — must compute |kernel_sum/divisor| |
Flag ratio < 0.01 (dark-out) or > 3 (white-out clamping) |
order |
Kernel length mismatch → zero-fill → effective zero kernel | No — must check declared order vs actual kernelMatrix length | Flag order² != len(kernelMatrix) + evaluate effective kernel |
targetX/targetY |
Boundary zone position relative to element dimensions | No — must compute geometric coverage of boundary zone | Compute boundary zone widths; flag if overlap with consent text region |
fePointLight z |
Lighting geometry (replicates feDistantLight elevation) | No — must evaluate specular/diffuse formula | Flag z=0, negative z, z ≫ element dimensions with high specularConstant |
The practical implementation of unified detection requires four steps:
- Identify all filter primitives in the ancestor filter chain of every consent element in the DOM.
- Resolve parameter values for each primitive: evaluate the convolution formula, the divisor ratio, the boundary zone geometry, and the lighting vector components.
- Simulate rendered output on a representative pixel set from the consent element — at minimum the text-center pixel and each corner.
- Compute WCAG 2.1 contrast between the simulated output RGB and the background color for each pixel. Flag if any section of the consent text falls below the 4.5:1 threshold.
Additionally, add MutationObserver observers on all five attributes (preserveAlpha, divisor, order, targetX, targetY) plus fePointLight x/y/z for any filter found in a consent ancestor chain. The SA-CSS-PAL-004, SA-CSS-DIV-004, SA-CSS-CMO-004, SA-CSS-TXY-004, and SA-CSS-FPL-004 mousedown variants all change exactly one attribute to trigger the attack at click time.
Complete findings list (SA-CSS-PAL / SA-CSS-DIV / SA-CSS-CMO / SA-CSS-TXY / SA-CSS-FPL)
preserveAlpha="true" + zero kernel + bias="0" — RGB zeroed; alpha preserved; element opaque black; invisible on dark-themed UIs; getComputedStyle.opacity = 1.preserveAlpha="true" + zero kernel + bias="1" — RGB = 1.0 (white); alpha preserved; element opaque white; invisible on white/light UIs.preserveAlpha="false" (default) + zero kernel + bias="0" — all RGBA zeroed; element fully transparent; click events fire; CSS opacity/visibility unaffected; highest-severity variant.preserveAlpha true→false — alpha drops to zero at click time; initial state renders correctly; MutationObserver must include preserveAlpha attribute.divisor="0" — SVG-spec undefined; Chromium zero output (dark-out); cross-browser inconsistency; flag any zero divisor regardless of kernel.divisor (e.g., 1,000,000) — |kernel_abs_sum| / |divisor| ≈ 0; all channels dark-out; flag ratio < 0.01.divisor="1" + all-ones 3×3 kernel (sum=9) — ×9 amplification; all non-black pixels clamp to white; white-out; flag kernel_sum/divisor > 3.divisor from 9 to 0.001 — ×9000 amplification at click; white-out; MutationObserver must include divisor attribute.order="100" + 9 kernelMatrix values — 9,991-position mismatch; Gecko zero-fill → near-zero kernel → dark-out; flag declared_order² ≠ len(kernelMatrix).order="1" + kernelMatrix="0" — minimal valid all-zero kernel; no mismatch; all RGBA zeroed; passes all length checks; flag 1×1 zero kernels on consent elements.orderX="321" + edgeMode="none" — boundary zone 160px each side; entire 320px consent banner in zero zone; kernel can be valid identity.order from 3→100 — creates declared/actual mismatch at click time; undefined output; MutationObserver must include order attribute.targetX="0" + large orderX + edgeMode="none" — right boundary zone = (orderX-1) px; erases button label text; detect from (orderX - targetX - 1) boundary coverage vs element width.targetY="0" + large orderY + edgeMode="none" — bottom boundary zone = (orderY-1) px; erases final consent text row; check boundary coverage vs element height.targetX="0" targetY="0") — maximal asymmetric boundary zone covers specific rectangular region; targeted erasure of any on-element text region.targetX from center to 0 — right boundary zone doubles at click time; MutationObserver must include targetX and targetY.fePointLight z="0" + specularExponent > 20 — in-plane light; specular ≈ 0; dark-out; mirrors feDistantLight elevation=0 via different primitive; elevation-specific detector misses.fePointLight z very large (z ≫ element dimensions) + specularConstant > 5 + white lighting-color — overhead-equivalent; all-white specular; screen blend → white-out; mirrors feDistantLight elevation=90.fePointLight z — light behind surface; specular ≈ 0; SVG spec undefined; cross-engine inconsistency exploited for scanner evasion.fePointLight z from normal to 0 or negative — in-plane dark-out at click time; MutationObserver on x, y, z of all fePointLight in consent-ancestor filters required.Key takeaways
These five attribute families illustrate a pattern that runs through the entire SVG filter attack landscape: each attribute controls a specific dimension of the rendered output that is orthogonal to the kernel coefficients. An attacker who wants to evade a kernel-only checker can set divisor to achieve any output amplitude with a legitimate-looking kernel. An attacker who wants to evade an edge-mode checker can use targetX to shift the boundary zone without changing edgeMode. An attacker who wants to evade a feDistantLight elevation detector can replicate the same geometry with fePointLight z.
The correct mental model is not "check each attribute for suspicious values" but "simulate the rendered output for the consent element against its background." SkillAudit's scanner implements this by evaluating the filter pipeline programmatically — computing the effective transformation from input pixel values to output pixel values — and then computing WCAG contrast. This approach is attributeagnostic and catches all twenty patterns in this family regardless of which attribute carries the attack.