SVG feColorMatrix type=matrix Consent Attacks: Channel-Swap, Inversion, Sepia, Grayscale, and Near-White Bias
Five SVG feColorMatrix type="matrix" attack families — channel-swap reverses traffic-light permission badge severity, inner-span inversion makes dark text white-on-white, sepia collapses red and green badge colors into indistinguishable brown, grayscale via type=matrix evades type=saturate detection rules, and near-white bias forcing with offset 0.85 evades the exact-1.0 detection that catches SA-CSS-CMM-003 — synthesized into a unified detection algorithm covering five SA-CSS patterns.
The feColorMatrix SVG filter primitive is one of the most flexible attack surfaces in the consent-bypass category. We have previously documented alpha-zeroing (SA-CSS-CMM-001), fractional alpha scaling (SA-CSS-CMM-002), exact-white-forcing (SA-CSS-CMM-003), and click-time matrix swap (SA-CSS-CMM-004). But type="matrix" is a 4×5 matrix with 20 degrees of freedom. The four SA-CSS-CMM patterns cover only a tiny corner of the attack space — the corner that produces obvious output (transparent or pure white). Five additional families of attacks use the matrix’s remaining degrees of freedom to produce attacks that are subtler, more semantically deceptive, or specifically engineered to evade detection rules written to catch the obvious cases.
This post synthesizes the five new families. Unlike feBlend mode attacks (where each mode is a distinct formula) or feComponentTransfer subtypes (where each subtype has a different syntax), all five attacks here use the same SVG element, the same type="matrix" attribute, and the same 20-value values string. A single unified detection function can handle all five families, provided it evaluates the matrix numerically rather than matching strings.
The 4×5 matrix structure
/* feColorMatrix type=matrix: 4×5 mapping [R,G,B,A,1] → [R',G',B',A'] values = 20 space-separated numbers, row-major: [a00 a01 a02 a03 a04] R' = a00×R + a01×G + a02×B + a03×A + a04 [a10 a11 a12 a13 a14] G' = a10×R + a11×G + a12×B + a13×A + a14 [a20 a21 a22 a23 a24] B' = a20×R + a21×G + a22×B + a23×A + a24 [a30 a31 a32 a33 a34] A' = a30×R + a31×G + a32×B + a33×A + a34 Identity (no change): [1 0 0 0 0] [0 1 0 0 0] [0 0 1 0 0] [0 0 0 1 0] Attack families by which matrix components they exploit: SA-CSS-CMS: off-diagonal 1s in the 3×3 RGB submatrix (channel swap) SA-CSS-CMI: negative diagonal coefficients (channel inversion) SA-CSS-CME: cross-channel mixing with sepia ratios SA-CSS-CMG: equal rows with non-identity coefficients (grayscale) SA-CSS-CMB: all-zero input coefficients with non-zero offset column */
Family 1: Channel-swap (SA-CSS-CMS) — semantic severity reversal
The channel-swap attack permutes the R, G, B channels without changing luminance significantly. For achromatic input (R=G=B=v, as in black text or white background), any permutation matrix is the identity — the output is unchanged. The attack exclusively targets chromatic elements: colored permission badges.
R↔G swap: traffic-light inversion
values="0 1 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0"
The most dangerous channel-swap attack. Green safe-permission badges (#22C55E, linear R=0.016, G=0.557, B=0.116) become visually red (R’=0.557, G’=0.016, B’=0.116) — appearing as a danger indicator. Red danger-permission badges (#EF4444, linear R=0.871, G=0.057, B=0.057) become visually green (R’=0.057, G’=0.871, B’=0.057) — appearing safe. The user sees green for a CRITICAL permission and red for a SAFE one. textContent still reads “CRITICAL” and “SAFE” correctly; the badge color is wrong. getComputedStyle().color reports the pre-filter color.
/* Green safe badge → appears red: visually = "CRITICAL" color = "SAFE" label Red danger badge → appears green: visually = "SAFE" color = "CRITICAL" label Black text → unchanged (R=G=B=0 → still 0 after any swap) White background → unchanged (R=G=B=1 → still 1 after any swap) Detection: check 3×3 RGB submatrix for non-identity permutation pattern. A permutation matrix has exactly one 1 per row and per column, rest near-0. Any non-identity permutation on consent element = HIGH; if panel has chromatic badges = CRITICAL (semantic reversal risk). */
R↔B swap: blue text to orange
values="0 0 1 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 1 0"
Blue consent scope labels (#2563EB, linear R=0.016, G=0.127, B=0.828) become orange/amber (R’=0.828, G’=0.127, B’=0.016). WCAG contrast on white: 1.05/(0.318)=3.30:1 — fails AA. Against an orange-tinted background (common in “elevated permission” warning states), the text matches the background and is invisible.
Family 2: Color inversion (SA-CSS-CMI) — filter scope determines attack or no-op
The full-inversion matrix has negative diagonal coefficients and compensating offsets: R’=1−R, G’=1−G, B’=1−B. The critical attack property is scope dependency: the same inversion matrix is an attack when applied to a text element and a no-op when applied to the container that includes the background.
Inner-span inversion: dark text → near-white on white background
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0" applied to the inner <span>, not the parent <div>
Dark consent text (#111111, linear≈0.017) inverts to near-white (0.983). The parent container’s CSS background-color: white is rendered by the layout engine before filter processing and is not subject to the filter on the child span. Result: WCAG 1.02:1 — invisible. When the filter is on the entire container (including background), both text (dark→near-white) and background (white→near-black) are inverted. WCAG is preserved at ~20:1. Not an attack.
/* Attack condition: filter on INNER ELEMENT where parent has white background.
Detection:
1. Find feColorMatrix with all negative diagonal (a00 < -0.5, a11 < -0.5, a22 < -0.5)
and large positive offsets (a04 > 0.9, a14 > 0.9, a24 > 0.9)
2. Determine which element carries the filter (walk DOM up from consent text)
3. If filtered element is inline/text (SPAN, P, LABEL, A) and parent has
background-color: white or near-white: CRITICAL (dark text→near-white on white)
4. If filtered element is block container with its own background-color:
both text and bg are inverted → contrast preserved → not an attack. */
Family 3: Sepia (SA-CSS-CME) — badge-color collapse to indistinguishable brown
The sepia matrix applies a fixed warm-tone transform where all output rows have the same channel weight ratios (≈2:4:1 for R:G:B inputs). The output hue is always warm brownish regardless of input; only the luminance varies. This destroys the traffic-light color-severity signal by mapping different-hue inputs to the same-hue output.
Red and green badges → indistinguishable warm brown
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"
| Input badge | Linear R, G, B | Sepia output | WCAG vs white |
|---|---|---|---|
| Red danger #EF4444 | 0.871, 0.057, 0.057 | warm brown R≈0.40, G≈0.35, B≈0.27 | 2.3:1 (fail) |
| Green safe #22C55E | 0.016, 0.557, 0.116 | warm brown R≈0.46, G≈0.41, B≈0.32 | 2.1:1 (fail) |
| Yellow caution #FBBF24 | 0.961, 0.527, 0.017 | light beige R≈0.79, G≈0.70, B≈0.54 | 1.39:1 (fail — invisible) |
| Dark text #333333 | 0.033, 0.033, 0.033 | dark brownish R≈0.045, G≈0.040, B≈0.031 | 11.5:1 (pass) |
/* Detection: check column weight ratios. In sepia, all three RGB output rows have weights in ratio ≈2:4:1 (R:G:B). R' row: [0.393, 0.769, 0.189] → ratio 0.393:0.769:0.189 ≈ 2.08:4.07:1 G' row: [0.349, 0.686, 0.168] → ratio 2.08:4.08:1 (same) B' row: [0.272, 0.534, 0.131] → ratio 2.08:4.08:1 (same) Structural signature: all three RGB rows have the same column weight ratio. Alternative (robust): apply matrix to known red and green badge colors; compute output hue distance; if hue distance < 20° (from 180°+ original), flag as CRITICAL (color-convergence attack). */
Family 4: Grayscale via type=matrix (SA-CSS-CMG) — detection-path evasion
A feColorMatrix type="saturate" values="0" is the standard grayscale shorthand. A scanner checking for type="saturate" with low values correctly flags it. But the same grayscale effect can be implemented as type="matrix" with BT.709 weights — different SVG element type and attribute values, identical pixel output.
Three grayscale code paths requiring three detection rules
/* Path 1 (covered by existing detection): */
<feColorMatrix type="saturate" values="0"/>
/* → type=saturate check catches this */
/* Path 2 (SA-CSS-CMG-001 — evades type=saturate check): */
<feColorMatrix type="matrix"
values="0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"/>
/* → Numerically identical; type=saturate check misses this */
/* Path 3 (SA-CSS-CMG-002 — evades BT.709 coefficient check): */
<feColorMatrix type="matrix"
values="0.333 0.333 0.333 0 0
0.333 0.333 0.333 0 0
0.333 0.333 0.333 0 0
0 0 0 1 0"/>
/* → Different coefficients; only structural check catches this */
/* Structural check (catches all three):
Equal-rows pattern: all three RGB output rows (rows 0,1,2) have the same values.
If values[0:3] ≈ values[5:8] ≈ values[10:13] → grayscale or similar → CRITICAL
The exact coefficient values don't matter; the equal-rows structure is the key. */
After BT.709 grayscale via type=matrix: green badge (#22C55E) → gray L=0.409, WCAG 2.29:1. Yellow badge (#FBBF24) → gray L=0.582, WCAG 1.66:1. Black text → gray L=0, unchanged. The badge-color destruction is identical to type=saturate values=0 but via a different code path.
Family 5: Near-white bias (SA-CSS-CMB) — constant gray evading exact-1.0 threshold
SA-CSS-CMM-003 (already documented) sets all RGB input coefficients to zero and the offset column to 1.0, forcing all pixels to pure white. Detection rules checking for exact offset=1.0 correctly flag it. Near-white bias uses offset values in the range 0.6–0.99 — producing constant light-gray outputs that fail WCAG AA against white while evading the exact-1.0 check.
Attack range: any bias α in (0.183, 0.999) fails WCAG AA
/* WCAG contrast of constant gray (L=α) vs white (L=1.0): contrast = (1.0 + 0.05) / (α + 0.05) = 1.05 / (α + 0.05) Fails WCAG AA (4.5) when: α > 0.183 α = 0.999: contrast = 1.05/1.049 = 1.00:1 — nearly invisible (almost pure white) α = 0.85: contrast = 1.05/0.90 = 1.17:1 — CRITICAL α = 0.70: contrast = 1.05/0.75 = 1.40:1 — CRITICAL α = 0.50: contrast = 1.05/0.55 = 1.91:1 — CRITICAL α = 0.30: contrast = 1.05/0.35 = 3.00:1 — HIGH α = 0.20: contrast = 1.05/0.25 = 4.20:1 — HIGH (just below AA) α = 0.183: contrast = 1.05/0.233 = 4.5:1 — exactly at AA threshold α = 0.10: contrast = 1.05/0.15 = 7.0:1 — passes; not an attack Detection: for any RGB output row, if input coefficients (indices 0-3) are all < 0.01 and offset (index 4) is in range (0.183, 0.999): flag as CRITICAL or HIGH. */
Unified detection algorithm
function analyzeColorMatrix(filterEl, consentEl) {
const results = [];
for (const m of filterEl.querySelectorAll('feColorMatrix')) {
const type = m.getAttribute('type') || 'matrix';
const vals = (m.getAttribute('values') || '').trim().split(/\s+/).map(Number);
if (type !== 'matrix' || vals.length !== 20) {
// Non-matrix types covered by separate rules (saturate, hueRotate, luminanceToAlpha)
continue;
}
// Extract 3×3 RGB input submatrix and offsets
const row = (r) => ({ r: vals[r*5], g: vals[r*5+1], b: vals[r*5+2], a: vals[r*5+3], k: vals[r*5+4] });
const [R, G, B] = [row(0), row(1), row(2)];
// --- SA-CSS-CMM: alpha attacks (existing) ---
const alphaRow = row(3);
const maxAlpha = Math.max(alphaRow.r, 0)*1 + Math.max(alphaRow.g, 0)*1 +
Math.max(alphaRow.b, 0)*1 + Math.max(alphaRow.a, 0)*1 + alphaRow.k;
if (maxAlpha < 0.05) results.push({ id: 'SA-CSS-CMM-001/2', sev: 'CRITICAL', note: 'alpha-zero row' });
// --- SA-CSS-CMB: near-white bias ---
for (const [id, row_] of [['R',R],['G',G],['B',B]]) {
if ([row_.r, row_.g, row_.b, row_.a].every(c => Math.abs(c) < 0.01)) {
const bias = row_.k;
if (bias > 0.183 && bias < 0.999)
results.push({ id: 'SA-CSS-CMB', sev: bias > 0.5 ? 'CRITICAL' : 'HIGH',
note: `channel ${id} forced to constant gray ${bias.toFixed(2)}; WCAG ${(1.05/(bias+0.05)).toFixed(2)}:1` });
if (bias >= 0.999)
results.push({ id: 'SA-CSS-CMM-003', sev: 'CRITICAL', note: `channel ${id} forced to white (offset=${bias})` });
}
}
// --- SA-CSS-CMG: grayscale via equal-rows ---
const rowsEqual = (a, b) => ['r','g','b','a','k'].every(k => Math.abs(a[k]-b[k]) < 0.02);
if (rowsEqual(R, G) && rowsEqual(G, B)) {
// All three RGB rows are equal — grayscale-like transformation
const rowSum = R.r + R.g + R.b;
if (rowSum > 0.8 && rowSum < 1.2 && Math.abs(R.a) < 0.05 && Math.abs(R.k) < 0.05)
results.push({ id: 'SA-CSS-CMG', sev: 'CRITICAL', note: 'equal-rows grayscale pattern' });
}
// --- SA-CSS-CME: sepia (cross-channel mixing) ---
// Sepia structural: all three RGB rows have same column weight ratio ≈ 2:4:1
function rowRatio(row_) {
const sum = row_.r + row_.g + row_.b;
return sum > 0.5 ? [row_.r/sum, row_.g/sum, row_.b/sum] : null;
}
const [ratioR, ratioG, ratioB] = [rowRatio(R), rowRatio(G), rowRatio(B)];
if (ratioR && ratioG && ratioB) {
const ratioDiff = [0,1,2].map(i => Math.max(
Math.abs(ratioR[i]-ratioG[i]), Math.abs(ratioG[i]-ratioB[i])
));
if (ratioDiff.every(d => d < 0.05))
results.push({ id: 'SA-CSS-CME', sev: 'HIGH', note: 'sepia-like: equal column weight ratios across all RGB rows' });
}
// --- SA-CSS-CMS: channel-swap (permutation matrix) ---
const mat3 = [[R.r,R.g,R.b],[G.r,G.g,G.b],[B.r,B.g,B.b]];
const isPermMatrix = mat3.every(row_ => {
const ones = row_.filter(v => Math.abs(v-1) < 0.05);
const zeros = row_.filter(v => Math.abs(v) < 0.05);
return ones.length === 1 && zeros.length === 2;
});
if (isPermMatrix) {
const isIdentity = mat3[0][0] > 0.95 && mat3[1][1] > 0.95 && mat3[2][2] > 0.95;
if (!isIdentity)
results.push({ id: 'SA-CSS-CMS', sev: 'HIGH',
note: 'channel-swap permutation matrix — check if consent panel has chromatic badges' });
}
// --- SA-CSS-CMI: inversion (negative diagonal) ---
const negDiag = mat3.filter((row_, i) => row_[i] < -0.5).length;
const posOffset = [R.k, G.k, B.k].filter(k => k > 0.9).length;
if (negDiag >= 1 && posOffset >= 1) {
// Inversion detected: check scope (inner element vs container)
const isInnerElement = ['SPAN','P','A','LABEL','H1','H2','H3','H4','H5','H6']
.includes(consentEl.tagName);
const sev = isInnerElement ? 'CRITICAL' : 'MEDIUM';
results.push({ id: 'SA-CSS-CMI', sev,
note: `inversion matrix on ${consentEl.tagName}; ${isInnerElement ? 'inner element — dark text → near-white on white bg' : 'container — contrast likely preserved'}` });
}
}
return results;
}
MutationObserver: catching click-time matrix swaps
All five families have click-time variants (SA-CSS-CMS-004, SA-CSS-CMI-004, SA-CSS-CME-004, SA-CSS-CMG-004, SA-CSS-CMB-004). The initial filter is the identity matrix; the attack matrix replaces it on mousedown. A runtime MutationObserver on the feColorMatrix values attribute re-runs analyzeColorMatrix() on every change. If the new matrix is flagged as any SA-CSS-CM* pattern, the confirmation action is blocked.
/* Attach to all feColorMatrix elements in consent filter graphs */
function watchConsentFilters(consentEl) {
const filterUrl = getComputedStyle(consentEl).filter;
const filterId = filterUrl.match(/url\(#([^)]+)\)/)?.[1];
if (!filterId) return;
const filterDef = document.getElementById(filterId);
if (!filterDef) return;
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.attributeName === 'values') {
const findings = analyzeColorMatrix(filterDef, consentEl);
if (findings.length > 0) {
console.warn('[SA-CSS] feColorMatrix attack detected at interaction time:', findings);
blockConsentConfirmation();
}
}
}
});
for (const m of filterDef.querySelectorAll('feColorMatrix')) {
observer.observe(m, { attributes: true, attributeFilter: ['values', 'type'] });
}
}
Attack family comparison
| Family | ID | Primary mechanism | Consent damage | True negative | Severity |
|---|---|---|---|---|---|
| Channel-swap | SA-CSS-CMS | Off-diagonal 1s in RGB submatrix | Semantic: badge severity reversed (safe=red, danger=green) | Achromatic text (black, white) | Critical |
| Color inversion | SA-CSS-CMI | Negative diagonal + compensating offset | Dark text → near-white on white (inner span only) | Filter on container root (both text and bg inverted) | Critical |
| Sepia | SA-CSS-CME | Cross-channel mixing with fixed warm-tone ratios | Red and green badges both → same brown; yellow badges invisible | Achromatic body text (slight luminance shift, WCAG passes) | Critical |
| Grayscale via type=matrix | SA-CSS-CMG | Equal RGB rows summing to ≈1.0 | All chromatic badge colors reduced to gray; green WCAG 2.29:1; yellow 1.66:1 | Black text (L=0 unchanged) | Critical |
| Near-white bias | SA-CSS-CMB | All-zero input coefficients; offset α in (0.183, 0.999) | All pixels → constant gray; WCAG α=0.85: 1.17:1 on white | Bias α < 0.183 (output dark enough to pass WCAG) | Critical |
What the existing SA-CSS-CMM rules miss
The original four SA-CSS-CMM rules check for: alpha-zero rows (CMM-001), fractional alpha scaling (CMM-002), exact RGB offset=1.0 (CMM-003), and identity→attack matrix swap (CMM-004). Together these miss all five families in this post:
- SA-CSS-CMS — the alpha row is unchanged; the CMM-001/002 check does not examine the RGB input coefficient permutation pattern.
- SA-CSS-CMI — negative diagonal coefficients do not produce a near-zero alpha row; CMM-001/002 alpha checks pass. CMM-003 offset check looks for offset≈1.0 but the inversion offset column is 1.0 for each inverted channel. Wait — actually for the inversion matrix
"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0", the offset IS 1.0 per row. So the CMM-003 rule might partially catch the offset=1.0 but NOT check the negative diagonal coefficients, which change the interpretation. CMM-003 checks if all coefficients are zero + offset=1; inversion has coefficient=-1 (not zero), so CMM-003 does not match. The inversion attack slips through all CMM rules. - SA-CSS-CME — sepia has non-zero RGB input coefficients; CMM-003 (all-zero-coefficients) does not match. The CMM-002 fractional-alpha check does not examine the RGB rows.
- SA-CSS-CMG — non-zero input coefficients that sum to ≈1.0; CMM-003 looks for all-zero inputs, which does not match BT.709 or 1/3 average.
- SA-CSS-CMB — offset α < 1.0 by design; CMM-003 exact-1.0 check produces false negative.
False-positive mitigation
Several cases require careful true-negative handling:
- Channel-swap (SA-CSS-CMS): If the consent panel uses only achromatic colors (black text on white), any channel swap is a true negative. Verify presence of chromatic elements before escalating to CRITICAL.
- Color inversion (SA-CSS-CMI): Filter on a block container that includes the background — both text and background inverted, contrast preserved. Check whether the filtered element’s own
background-coloris non-transparent. If yes, lower severity to MEDIUM. - Sepia (SA-CSS-CME): Dark body text through sepia gains luminance slightly but remains readable (WCAG 11.5:1). Only chromatic badge elements trigger CRITICAL. Check whether chromatic elements exist in the consent panel.
- Grayscale (SA-CSS-CMG): Same as sepia — black text is unchanged. Chromatic elements are the attack target.
- Near-white bias (SA-CSS-CMB): If the consent panel’s background is also gray (e.g., a gray modal dialog), the constant-gray output may still have sufficient contrast vs the background. Compute WCAG against the actual background color, not assumed white.
Conclusion
The feColorMatrix type="matrix" attack surface is larger than the four original CMM patterns suggest. Channel-swap attacks can reverse the entire permission-severity color signal without touching alpha or producing invisible text. Color inversion attacks depend on filter scope in a way that requires DOM-structure analysis, not just filter-graph analysis. Sepia and grayscale attacks implement color-badge convergence — destroying the information value of permission severity colors rather than hiding text outright. Near-white bias attacks exploit the gap between exact-1.0 detection and the full range of WCAG-failing constant-gray outputs.
A robust feColorMatrix scanner must evaluate the 4×5 matrix numerically, applying it to known consent-panel colors (typical badge colors + body text), and check both WCAG contrast of the output and semantic preservation of the permission-severity color scheme. String matching on type or exact coefficient values is insufficient — the attack space is the full 20-dimensional matrix, not just a handful of named configurations.