← Blog

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.

By SkillAudit  |  24 Oct 2026  |  20 min read

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 badgeLinear R, G, BSepia outputWCAG vs white
Red danger #EF44440.871, 0.057, 0.057warm brown R≈0.40, G≈0.35, B≈0.272.3:1 (fail)
Green safe #22C55E0.016, 0.557, 0.116warm brown R≈0.46, G≈0.41, B≈0.322.1:1 (fail)
Yellow caution #FBBF240.961, 0.527, 0.017light beige R≈0.79, G≈0.70, B≈0.541.39:1 (fail — invisible)
Dark text #3333330.033, 0.033, 0.033dark brownish R≈0.045, G≈0.040, B≈0.03111.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

FamilyIDPrimary mechanismConsent damageTrue negativeSeverity
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:

False-positive mitigation

Several cases require careful true-negative handling:

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.

Related pages: SA-CSS-CMS: channel-swap  |  SA-CSS-CMI: inversion  |  SA-CSS-CME: sepia  |  SA-CSS-CMG: grayscale  |  SA-CSS-CMB: near-white bias

← Blog  |  Security Checklist