MCP server SVG feColorMatrix cascade consent security
Four SA-CSS-CMC consent attack patterns that chain two or more feColorMatrix elements inside a single SVG filter — each primitive passes per-element WCAG and semantic checks in isolation, but the composition of their matrices produces a consent-defeating output that individual per-element scanners cannot detect.
A single SVG <filter> element can contain multiple feColorMatrix primitives. Each primitive receives the output of the previous one, either via explicit in="result_name" / result="result_name" attribute pairs or via the default implicit chaining rule where each primitive without an in attribute uses the output of its immediate predecessor. The cascade attack exploits this chaining: a first matrix M1 transforms the input to a visually acceptable but structurally altered intermediate; a second matrix M2 then transforms that intermediate to a consent-defeating final output. Because each matrix is evaluated in isolation by naïve per-element scanners, the destructive effect is split across the chain and neither individual element triggers a flag.
SVG filter chaining and the implicit pipeline
Within a <filter> element, primitives execute in document order. If the first primitive does not specify in, it receives the SourceGraphic. Each subsequent primitive without an explicit in attribute receives the output of its immediate predecessor. The result attribute assigns a named intermediate for later re-use. In cascade attacks, the attacker relies on the implicit chain — no result or in attributes are needed, making the filter look structurally minimal to a static reviewer.
<!-- Implicit two-stage cascade — no result/in attributes needed --> <filter id="cmc-example"> <!-- M1: receives SourceGraphic implicitly --> <feColorMatrix type="matrix" values="...M1..."/> <!-- M2: receives output of M1 implicitly --> <feColorMatrix type="saturate" values="0.1"/> </filter> <!-- Explicit equivalent using result/in --> <filter id="cmc-explicit"> <feColorMatrix type="matrix" values="...M1..." result="step1"/> <feColorMatrix type="saturate" values="0.1" in="step1"/> </filter>
Matrix composition: M2 × M1 and the 4×5 augmented matrix trick
Each feColorMatrix type="matrix" element encodes a 4×5 matrix M operating on the augmented colour vector [R, G, B, A, 1]T. To compose two such matrices M1 (applied first) and M2 (applied second), we treat each 4×5 matrix as a 5×5 matrix by appending the implicit fifth row [0, 0, 0, 0, 1]. The effective single matrix is then the standard 5×5 product M2_aug × M1_aug, and the top 4 rows of the result are the 4×5 composite matrix. This means the full chain of any number of feColorMatrix type="matrix" elements reduces to a single effective matrix, computable at analysis time.
/* 4x5 to 5x5 augmented form (appending implicit row [0,0,0,0,1]) */
function augment(m4x5) {
// m4x5 is a flat 20-element array, row-major
// Returns a flat 25-element 5x5 array
const r = new Float64Array(25);
for (let row = 0; row < 4; row++)
for (let col = 0; col < 5; col++)
r[row * 5 + col] = m4x5[row * 5 + col];
r[24] = 1; // fifth row: [0,0,0,0,1]
return r;
}
function mul5x5(A, B) {
// 5x5 matrix product C = A × B, both flat 25-element arrays
const C = new Float64Array(25);
for (let r = 0; r < 5; r++)
for (let c = 0; c < 5; c++)
for (let k = 0; k < 5; k++)
C[r * 5 + c] += A[r * 5 + k] * B[k * 5 + c];
return C;
}
/* Compose all feColorMatrix type="matrix" elements in a filter into one
effective 4x5 matrix, then apply existing single-matrix detection rules. */
function composeFilterChain(filterEl) {
const primitives = filterEl.querySelectorAll('feColorMatrix[type="matrix"]');
// Start from identity 5x5
let composite = Float64Array.from([1,0,0,0,0, 0,1,0,0,0, 0,0,1,0,0, 0,0,0,1,0, 0,0,0,0,1]);
for (const prim of primitives) {
const vals = (prim.getAttribute('values') || '').trim().split(/\s+/).map(Number);
if (vals.length !== 20) continue;
composite = mul5x5(augment(vals), composite);
}
// Return the top-4-rows (20 values) effective matrix
return Array.from(composite).slice(0, 20);
}
Cascade evasion principle: Per-element scanners that evaluate each feColorMatrix in isolation will miss cascade attacks. The correct detection strategy is to compose the entire filter chain into a single effective matrix first, then apply all single-matrix checks to that composite. A chain of N individually-benign matrices may produce a composite that violates every single-matrix rule.
SA-CSS-CMC-001: sepia then 90% desaturation — individual pass, composition fail
The first matrix M1 is the standard sepia transform. Applied to near-black consent text (#111111, linear R≈G≈B≈0.017), it produces a warm brownish colour with L≈0.35. WCAG contrast against white: 1.05/(0.35+0.05) = 2.63:1 — this already fails AA. But when applied to a green consent badge (#22C55E), the sepia output produces L≈0.35, and the WCAG contrast = 1.05/(0.35+0.05) = 2.63:1. Wait — the sepia type shorthand maps to a specific 4×5 matrix: M1_sepia. Per-element scanners testing type="saturate" or type="hueRotate" may heuristically accept sepia as an artistic effect rather than an attack. The second matrix M2 is type="saturate" values="0.1", reducing saturation by 90%. M2 alone on original dark text leaves it near-black (achromatic text has no saturation to remove, so M2 alone is nearly a no-op on dark text — WCAG stays near 21:1). The composition sepia then 90%-desaturation produces near-monochrome brownish gray at L≈0.31, WCAG = 1.05/(0.31+0.05) = 2.92:1 — clear AA fail. Critically, both the green safe-badge and the red danger-badge collapse to near-identical brownish-gray after M1 followed by M2, yielding pair-contrast between the two badges of approximately 1.05:1 — the severity distinction becomes impossible.
<filter id="cmc001">
<!-- M1: sepia type shorthand -->
<feColorMatrix type="saturate" values="0"/>
<!-- Actually sepia is not a standard type; use the matrix form:
sepia = 0.393 0.769 0.189 0 0
0.349 0.686 0.168 0 0
0.272 0.534 0.131 0 0
0 0 0 1 0
-->
<feColorMatrix type="matrix"
values="0.393 0.769 0.189 0 0
0.349 0.686 0.168 0 0
0.272 0.534 0.131 0 0
0 0 0 1 0"/>
<!-- M2: desaturate by 90%; alone on original dark text this is ~no-op -->
<feColorMatrix type="saturate" values="0.1"/>
</filter>
/* Per-element scanner results:
M1 (sepia matrix) alone: brown output, L≈0.35, WCAG≈2.63:1 on white → FAIL
(but sepia may be heuristically allowlisted as "artistic effect")
M2 (saturate 0.1) alone on original dark text: near-black → WCAG≈21:1 → pass
Composition: L≈0.31, WCAG≈2.92:1 → clear AA fail
Badge pair-contrast: green badge ≈ red badge ≈ brownish-gray → ~1.05:1 → severity-erasure */
Severity erasure, not just contrast failure: SA-CSS-CMC-001 does not merely reduce overall contrast. It collapses two semantically distinct badge colors (green safe, red danger) to near-identical brownish-gray, making it impossible to distinguish permission severity — even if the user squints hard enough to read the badge text, the color-coded severity signal is destroyed.
SA-CSS-CMC-002: mild hueRotate then mild desaturate — below individual thresholds, combined fail
Matrix M1 is type="hueRotate" values="30", rotating all hues by 30°. Red consent-danger badges (#EF4444) shift to orange. The WCAG contrast of the resulting orange against white is approximately 3.5:1 — below the 4.5:1 AA threshold but close enough that an automated scanner using a rounded threshold or a “minor hue rotation is not an attack” heuristic may not flag it. Matrix M2 is type="saturate" values="0.7", reducing saturation by a further 30%. Alone, M2 produces only a minor saturation reduction on the original red badge; the output is still clearly red and passes WCAG. The composition shifts the already-orange output of M1 to a desaturated, muted orange-tan at approximately 3.2:1 WCAG — a clear fail, and now far enough below 4.5:1 to defeat the “borderline tolerance” heuristics some scanners apply.
<filter id="cmc002"> <!-- M1: 30-degree hue rotation; red→orange; borderline 3.5:1 WCAG --> <feColorMatrix type="hueRotate" values="30"/> <!-- M2: 30% saturation reduction; alone on original: minor, WCAG passes --> <feColorMatrix type="saturate" values="0.7"/> </filter> /* Per-element scanner results: M1 (hueRotate 30) alone: orange at ~3.5:1 → below AA but may pass "minor rotation" heuristic M2 (saturate 0.7) alone on original red: still ~4.1:1 → passes (just below AA but close) Composition: muted orange-tan at ~3.2:1 → clear WCAG AA fail Evasion: neither individual element triggers a hard block; cascade does. Detection: expand hueRotate and saturate types to their 4x5 matrix forms, compose via M2_aug × M1_aug, then check composite against all WCAG thresholds. */
SA-CSS-CMC-003: channel-swap then desaturate — severity pair-contrast erasure
M1 is a type="matrix" R↔G channel-swap (values="0 1 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0"). This is already a HIGH attack on its own — it reverses the semantic meaning of traffic-light badges. M2 is type="saturate" values="0.1", a 90% desaturation. M2 alone is a moderate attack. Together, the channel-swap first interchanges red and green badge channels, then the deep desaturation crushes both to near-identical gray at roughly 15% of original saturation. The pair-contrast between what was the green safe-badge and what was the red danger-badge collapses to approximately 1.05:1 — both are now nearly the same gray. The individual checks on M1 flag it as HIGH; the individual checks on M2 flag it as moderate. But neither check alone reveals the combined effect: total severity-pair erasure where no chromatic difference remains to distinguish “safe” from “dangerous” at all.
<filter id="cmc003">
<!-- M1: R↔G channel swap — already a HIGH attack alone -->
<feColorMatrix type="matrix"
values="0 1 0 0 0
1 0 0 0 0
0 0 1 0 0
0 0 0 1 0"/>
<!-- M2: 90% desaturation — moderate attack alone -->
<feColorMatrix type="saturate" values="0.1"/>
</filter>
/* Badge color trace through the cascade:
Green safe-badge #22C55E (linear R=0.016, G=0.557, B=0.116):
After M1 R↔G swap: R'=0.557, G'=0.016, B'=0.116 (appears red)
After M2 saturate=0.1: luminance preserved, saturation ×0.1
L = 0.2126×0.557 + 0.7152×0.016 + 0.0722×0.116 = 0.137
Near-gray at 10% saturation ≈ (0.138, 0.136, 0.137)
Red danger-badge #EF4444 (linear R=0.871, G=0.057, B=0.057):
After M1 R↔G swap: R'=0.057, G'=0.871, B'=0.057 (appears green)
After M2 saturate=0.1: L = 0.2126×0.057 + 0.7152×0.871 + 0.0722×0.057 = 0.639
Near-gray at 10% saturation ≈ (0.630, 0.645, 0.630)
Pair-contrast: 1.05/(0.639+0.05) / 1.05/(0.137+0.05) ≈ 1.52:1 → L gap visible
But both are gray — zero chromatic cue distinguishes safe from dangerous.
Severity-erasure is complete: the user cannot tell which badge means what. */
Compounding individual attacks: SA-CSS-CMC-003 demonstrates that combining two individually-flagged attacks can produce a qualitatively worse outcome than either alone. The channel-swap swaps semantic meaning; the desaturation then erases the remaining chromatic difference. A scanner that detects and blocks each individual attack independently will still miss the composed effect if it evaluates elements in isolation rather than composing the chain.
SA-CSS-CMC-004: JS DOM insertion of second matrix at mousedown
The filter element initially contains only a single identity feColorMatrix. Static DOM analysis at page load sees a single benign primitive. On mousedown, a script appends a second feColorMatrix into the filter via appendChild, instantly establishing the two-stage cascade that was absent during static analysis. The consent panel renders correctly until the moment the user clicks; the DOM mutation at click time creates the cascade before the browser delivers the click event, so the user sees the cascaded output only on the confirmation screen.
<svg style="position:absolute;width:0;height:0">
<defs>
<filter id="cmc004">
<!-- Static analysis sees only identity: no threat -->
<feColorMatrix 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 id="consent" style="filter:url(#cmc004)">
<span class="badge safe">config read: SAFE</span>
<span class="badge danger">shell execution: CRITICAL</span>
I confirm the above permissions.
</div>
<script>
document.getElementById("consent").addEventListener("mousedown", () => {
const filter = document.getElementById("cmc004");
const m2 = document.createElementNS("http://www.w3.org/2000/svg", "feColorMatrix");
m2.setAttribute("type", "saturate");
m2.setAttribute("values", "0.1"); // 90% desaturation: severity-erasure
filter.appendChild(m2); // cascade now active; browser re-renders
// User confirmation fires milliseconds later; consent is already cascaded
});
</script>
/* Detection: MutationObserver on the filter element's childList.
On any childList mutation, re-collect all feColorMatrix children,
recompose the chain, and re-run the full detection suite.
A mutation on a filter that is an ancestor of a consent element
during a user interaction event = CRITICAL; block confirmation. */
const filterEl = document.getElementById("cmc004");
new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.type === "childList" && mut.addedNodes.length) {
const composed = composeFilterChain(filterEl);
if (detectCascadeAttack(composed)) {
blockConsentConfirmation();
}
}
}
}).observe(filterEl, { childList: true, subtree: false });
Static analysis is insufficient: SA-CSS-CMC-004 is invisible to any analysis that examines the DOM only at page load or DOMContentLoaded. The cascade is injected dynamically at interaction time. Runtime monitoring via MutationObserver — watching for childList mutations on all filter elements that are ancestors of consent panel elements — is the only reliable defence.
Detection: composing the chain and checking the composite
The unified detection algorithm for cascade attacks has three steps. First, expand all feColorMatrix primitives in the filter to their 4×5 matrix forms: type="saturate", type="hueRotate", and type="luminanceToAlpha" each have closed-form 4×5 matrix representations. Second, augment each to 5×5 and multiply them in pipeline order (M_n × … × M_2 × M_1) to produce the single composite 4×5 effective matrix. Third, apply all single-matrix detection rules to the composite: WCAG contrast check for representative input colors (#22C55E green badge, #EF4444 red badge, #2563EB blue scope label, #111111 dark body text), badge pair-contrast check (the luminance difference between the green-badge output and the red-badge output must remain sufficient to convey severity), and channel-permutation check (is the 3×3 RGB submatrix a non-identity permutation). The composite passes through all single-matrix detection rules if and only if the composed chain is safe — this is the key correctness property of the composition approach.
function detectCascadeAttack(filterEl) {
// Step 1: collect all feColorMatrix primitives, any type
const prims = Array.from(filterEl.querySelectorAll('feColorMatrix'));
if (prims.length === 0) return null;
// Step 2: compose into one effective 5x5 augmented matrix
let composite = identityAug5x5();
for (const prim of prims) {
const m4x5 = feColorMatrixTo4x5(prim); // handles all type= values
composite = mul5x5(augment(m4x5), composite);
}
const eff = composite.slice(0, 20); // effective 4x5 composite
// Step 3: run single-matrix checks on the composite
const representativeInputs = [
{ name: 'green-badge', srgb: [0.133, 0.773, 0.369, 1] }, // #22C55E
{ name: 'red-badge', srgb: [0.937, 0.267, 0.267, 1] }, // #EF4444
{ name: 'blue-label', srgb: [0.145, 0.388, 0.922, 1] }, // #2563EB
{ name: 'dark-text', srgb: [0.067, 0.067, 0.067, 1] }, // #111111
];
const outputs = representativeInputs.map(inp => ({
name: inp.name,
out: applyMatrix4x5(eff, srgbToLinear(inp.srgb))
}));
// Check WCAG contrast of each output against white background
for (const { name, out } of outputs) {
const L = relativeLuminance(out);
const wcag = (1.05) / (L + 0.05);
if (wcag < 4.5 && name !== 'blue-label') // AA threshold
return { risk: 'HIGH', reason: `Composite WCAG fail on ${name}: ${wcag.toFixed(2)}:1` };
}
// Check badge pair-contrast: green-badge output vs red-badge output
const Lg = relativeLuminance(outputs[0].out);
const Lr = relativeLuminance(outputs[1].out);
const [hi, lo] = Lg > Lr ? [Lg, Lr] : [Lr, Lg];
const pairContrast = (hi + 0.05) / (lo + 0.05);
if (pairContrast < 1.5)
return { risk: 'CRITICAL', reason: `Badge pair-contrast ${pairContrast.toFixed(2)}:1 — severity erasure` };
// Check channel-permutation on composite 3x3 RGB submatrix
if (isNonIdentityPermutation(eff))
return { risk: 'CRITICAL', reason: 'Composite channel-swap detected' };
return null; // composite is clean
}
Attack findings summary
| ID | Chain | M1 alone | M2 alone | Composition effect | Severity |
|---|---|---|---|---|---|
| SA-CSS-CMC-001 | sepia matrix + saturate 0.1 | Brownish output, WCAG≈2.63:1 (fail; may be allowlisted as artistic) | Near-no-op on dark text, WCAG≈21:1 (pass) | L≈0.31, WCAG≈2.92:1 fail; green+red badges both collapse to near-identical brownish-gray; pair-contrast≈1.05:1 | Critical |
| SA-CSS-CMC-002 | hueRotate 30 + saturate 0.7 | Orange at ≈3.5:1 (borderline; may pass “minor rotation” heuristic) | Slightly desaturated red at ≈4.1:1 (below AA but close) | Muted orange-tan at ≈3.2:1; clear WCAG AA fail; below borderline tolerance of either individual element | High |
| SA-CSS-CMC-003 | R↔G channel-swap + saturate 0.1 | Semantic reversal (HIGH); green badge appears red, red badge appears green | Deep desaturation (moderate); 90% saturation removed | Both badges collapse to near-identical gray; pair-contrast≈1.05:1; complete chromatic severity erasure on top of semantic reversal | Critical |
| SA-CSS-CMC-004 | Identity (static) + any matrix (DOM-injected at mousedown) | Identity: no threat during static analysis | N/A (injected dynamically) | Cascade created at click time; consent renders correctly until user confirms; MutationObserver on filter childList required | Critical |
SkillAudit findings for this attack surface
saturate values="0.1" — M1 (sepia) may be heuristically allowlisted as artistic; M2 alone is near-no-op on dark text; composition produces L≈0.31 (WCAG≈2.92:1) and collapses green+red severity badges to indistinguishable brownish-gray at pair-contrast≈1.05:1; per-element scanning misses entirely; cascade composition detection required.hueRotate values="30" chained with saturate values="0.7" — each individual element may escape heuristic thresholds (borderline 3.5:1 and 4.1:1 respectively); composition reaches 3.2:1, a clear WCAG AA fail; expand both types to their 4×5 matrix forms, compose, and apply WCAG checks to the composite to detect.saturate values="0.1" — two individually-detectable attacks combine to produce complete severity-pair erasure; both consent badges collapse to near-identical gray with pair-contrast≈1.05:1; composite 3×3 RGB submatrix check plus badge pair-contrast check on the composed output required.mousedown appends second feColorMatrix into filter via appendChild — static analysis sees only identity; cascade active only at click time; MutationObserver on consent-ancestor filter elements watching for childList mutations; recompose chain and re-run detection suite on every mutation during user interaction.