← Blog

SVG feColorMatrix Alpha-Channel and Combined Attacks: Column Injection, Mixed Alpha+RGB, Low Saturation, and Non-180° Hue Rotation

Four new feColorMatrix attack families exploit the parts of the 4×5 matrix that existing detection tools never check: the alpha column, joint alpha+RGB manipulation, the 0.01–0.05 saturation evasion band, and non-180° hue rotation angles. Each family individually defeats single-axis scanners. Together, they establish a new category: multi-axis consent attacks that require unified output-based detection to catch.

By SkillAudit  |  24 Oct 2026  |  20 min read

Introduction: twenty degrees of freedom, four neglected attack axes

The feColorMatrix SVG filter primitive with type="matrix" is a 4×5 affine transform on the RGBA color vector. Its 20 scalar parameters give it extraordinary flexibility: the same element can transparentize, saturate, invert, permute channels, bias toward white, or perform any composition of these operations. Previous research on feColorMatrix consent attacks (the SA-CSS-CMM family) targeted the most obvious corner of this space — attacks that produce clearly transparent or pure-white output. The SA-CSS-CMM synthesis extended coverage to channel-swap, inversion, sepia, grayscale, and near-white bias, all of which operate entirely within the 3×3 RGB submatrix and the offset column.

Four axes remained unexamined: (1) the alpha column (column index 3 in each RGB output row), (2) joint manipulation of both RGB rows and the alpha output row simultaneously, (3) the saturation evasion band between 0.01 and 0.05 where type=saturate values are not zero but still fail WCAG AA, and (4) non-180° hue rotation angles that cause semantic reversals at least as destructive as 180°. Single-axis detection tools — those that check only RGB input coefficients, or only alpha output, or only the saturation parameter in isolation — miss all four families entirely. This post synthesizes the four new families and presents a unified multi-axis detection algorithm that covers all of them.

The 4×5 matrix: a map of what prior rules check

/* feColorMatrix type="matrix" — full structure
   20 values, row-major, applied as:

   [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
    ^^^^^^^^^^^   ^^^   ^^^
    3×3 RGB       alpha  offset
    submatrix     col    col

   What previous detection rules checked:
   ✓  3×3 RGB submatrix (a00–a22): channel swap, inversion, sepia, grayscale
   ✓  offset column (a04, a14, a24): near-white bias
   ✗  alpha column (a03, a13, a23): NEVER checked in RGB rows
   ✗  alpha output row (a30–a34): treated in isolation, not jointly with RGB
   ✗  type=saturate evasion band 0.01–0.05: threshold at 0.01 is too tight
   ✗  type=hueRotate angles ≠ 180°: only 180° was flagged */

Key insight: Detection tools that check v[0], v[5], v[10] (the RGB diagonal) and the offset column are blind to any attack routed through the alpha column. An attacker who sets a03=a13=a23=1 and zeros out the entire 3×3 RGB submatrix has produced a pixel-whitening attack that passes every existing RGB-coefficient check.

Family 1: Alpha-column injection (SA-CSS-CZA)

The alpha column (column 3, indices a03, a13, a23) controls how much of the source pixel’s alpha channel contributes to each RGB output channel. In the identity matrix all three entries are zero: alpha does not bleed into color. Every SA-CSS-CMM attack and every SA-CSS-CMS/CMI/CME/CMG/CMB attack maintains this property. Alpha-column injection sets these values to non-zero while simultaneously zeroing the 3×3 RGB submatrix, routing color output entirely through the alpha channel.

The exploitation insight is straightforward: for opaque text (A=1.0) rendered on a white background, forcing R′=G′=B′=A=1.0 produces white. The text disappears. And because the 3×3 RGB submatrix is all-zero, any scanner checking v[0..2] (the R row RGB coefficients), v[5..7] (the G row), or v[10..12] (the B row) sees zeros and concludes the attack is a transparency attack — then checks the alpha output row and finds it intact at 1.0 and concludes there is no attack at all.

SA-CSS-CZA-001: direct alpha injection — unit coefficients

values="0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0"

All three RGB output rows have a03=a13=a23=1 and zeros in the RGB submatrix. The alpha output row is the identity. For any pixel with alpha A: R′=G′=B′=A. For opaque dark text (A=1.0, R=G=B≈0.01): R′=G′=B′=1.0 — pure white. Against a white page background the WCAG contrast ratio is 1.00:1. The consent text is invisible. Scanners that check “are all RGB input coefficients zero?” see yes and may infer transparency; but A′=A=1.0, so the element is opaque and the click target remains fully active.

/* SA-CSS-CZA-001: dark opaque text → white
   Input:  R=0.004, G=0.004, B=0.004, A=1.0
   Matrix: R' = 0·R + 0·G + 0·B + 1·A + 0 = 1.0
           G' = 0·R + 0·G + 0·B + 1·A + 0 = 1.0
           B' = 0·R + 0·G + 0·B + 1·A + 0 = 1.0
           A' = 0·R + 0·G + 0·B + 1·A + 0 = 1.0
   Output: R'=1.0, G'=1.0, B'=1.0, A'=1.0 → white, fully opaque
   WCAG on white background: 1.00:1 (invisible)
   Evasion: v[0..2]=0 (no RGB coefficients) → passes RGB-submatrix check
             a30..a32=0, a33=1 → passes alpha-output-row check 

SA-CSS-CZA-002: scaled alpha injection — coefficient 2 with offset −0.5

values="0 0 0 2 -0.5 0 0 0 2 -0.5 0 0 0 2 -0.5 0 0 0 1 0"

Sets a03=a13=a23=2 and offset a04=a14=a24=-0.5. For opaque text: R′=2×1.0−0.5=1.5, clamped to 1.0 — white. For semi-transparent elements with A=0.5: R′=2×0.5−0.5=0.5 (mid-grey). This variant evades detectors that specifically look for a03=a13=a23=1 as the exact unit-injection signature. For any A≥0.25: R′≥0 (visible), and for A≥0.75: R′≥1.0 (white after clamping). Because most consent UI elements use fully opaque text, the effective attack outcome is identical to SA-CSS-CZA-001 for all opaque inputs.

/* SA-CSS-CZA-002: scaled injection with offset — evades coefficient=1 check
   For A=1.0:  R' = 2×1.0 − 0.5 = 1.5 → clamp → 1.0 (white)
   For A=0.75: R' = 2×0.75 − 0.5 = 1.0 → white
   For A=0.5:  R' = 2×0.5 − 0.5 = 0.5 → mid-grey
   For A=0.25: R' = 2×0.25 − 0.5 = 0.0 → black
   WCAG for opaque consent text on white background: 1.00:1
   Evasion: a03 = 2 ≠ 1 → passes "a03=1" exact-match detector 

SA-CSS-CZA-003: single-channel alpha injection into R row only

values="0 0 0 1 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"

Only the R output row routes through alpha. G and B rows are near-identity (G′=G, B′=B). For opaque dark text: R′=A=1.0, G′=G≈0.004, B′=B≈0.004. The output is bright red #FF0101 — visually a danger/critical badge color. The attack targets badge semantic erasure: a green safe-permission badge (#22C55E, A=1.0) now outputs R′=1.0, G′=0.557, B′=0.116. The green badge turns orange-red; a safe permission appears dangerous. Any scanner checking only whether the element becomes white or transparent misses this entirely.

/* SA-CSS-CZA-003: R-channel alpha injection — badge semantic erasure
   Green safe badge input: R=0.016, G=0.557, B=0.116, A=1.0
   Output: R' = 0·0.016 + 0·0.557 + 0·0.116 + 1·1.0 + 0 = 1.0
           G' = 0·0.016 + 1·0.557 + 0·0.116 + 0·1.0 + 0 = 0.557
           B' = 0·0.016 + 0·0.557 + 1·0.116 + 0·1.0 + 0 = 0.116
   Linear output ≈ (1.0, 0.557, 0.116) → sRGB ≈ #FF7F33 (orange-red)
   Effect: green "SAFE" badge appears as an orange-red "WARNING/DANGER" color
   Evasion: only a03 ≠ 0; a13=a23=0; bypasses joint-column check 

SA-CSS-CZA-004: click-time alpha injection (JS mousedown identity → full injection)

The feColorMatrix is initialized with identity values. At mousedown on the consent button, JavaScript replaces the values attribute with the SA-CSS-CZA-001 matrix. The user sees the consent text normally when reading, then the text becomes invisible at the exact moment they click. Static analysis of the SVG sees only the safe identity matrix. A MutationObserver on the values attribute is the only reliable detection path.

/* SA-CSS-CZA-004: runtime injection at click time
   Initial state: values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 1 0"
   On mousedown:  values="0 0 0 1 0  0 0 0 1 0  0 0 0 1 0  0 0 0 1 0"
   Detection:
     new MutationObserver((muts) => {
       for (const m of muts) {
         if (m.attributeName === 'values') analyzeColorMatrix(m.target);
       }
     }).observe(feColorMatrixEl, { attributes: true }); */

Detection rule for SA-CSS-CZA: check whether any of a03, a13, or a23 is non-zero in the RGB output rows. If so, compute the matrix output for a representative dark opaque pixel (R=0.004, G=0.004, B=0.004, A=1.0). If the resulting linearized luminance exceeds 0.7 (approximately WCAG L≥0.7, corresponding to mid-light grey), flag as CRITICAL white-out via alpha-column injection. This rule correctly identifies CZA-001 and CZA-002 while remaining robust to scaled variants.

Family 2: Combined alpha+RGB attacks (SA-CSS-CMA)

SA-CSS-CZA attacked along a single axis: the alpha column. SA-CSS-CMA attacks along two axes simultaneously — modifying RGB channel values and the alpha output row at the same time. The evasion logic is more subtle: a single-axis tool that checks sepia (SA-CSS-CME pattern) sees the sepia RGB rows and might compute WCAG for the sepia output at 100% opacity. That output passes WCAG AA. The same tool then checks the alpha output row and finds 0.2 — a non-identity value. But 0.2 alpha on high-contrast sepia text against a white background is separately analyzed and appears fine as a “transparency” reading. Only a tool that applies both transformations simultaneously and then composites against the actual page background will catch the attack.

SA-CSS-CMA-001: sepia RGB + 0.2 alpha output

RGB rows: standard sepia matrix. Alpha output row: 0 0 0 0.2 0 (A′=0.2×A).

For opaque black consent text on a white page: sepia output is warm dark-brown (linear L≈0.008 before compositing). At A′=0.2, compositing against white gives effective luminance: L_composite = 0.2×0.008 + 0.8×1.0 = 0.8016. WCAG contrast of L_composite against white background = (1.05) / (0.8016+0.05) = 1.23:1 — a catastrophic fail against the 4.5:1 AA threshold. Neither check alone catches it: sepia at 100% opacity gives dark text (WCAG >4.5:1 passes); 0.2 alpha on otherwise-dark text gives near-white after compositing (could appear as a contrast check pass if the tool doesn’t composite against the real background).

/* SA-CSS-CMA-001: sepia matrix + A'=0.2×A
   Sepia RGB rows (standard SVG spec values):
   R' = 0.393·R + 0.769·G + 0.189·B + 0·A + 0
   G' = 0.349·R + 0.686·G + 0.168·B + 0·A + 0
   B' = 0.272·R + 0.534·G + 0.131·B + 0·A + 0
   A' = 0·R    + 0·G    + 0·B    + 0.2·A + 0

   For dark black text (R=G=B≈0, A=1.0):
   Sepia output: R'≈0, G'≈0, B'≈0, A'=0.2
   Composited on white: L_eff = 0.2×0 + 0.8×1.0 = 0.800
   WCAG vs white: (1.05)/(0.800+0.05) = 1.24:1  ← FAIL AA (need 4.5:1)

   Evasion:
     Sepia check alone (at A=1.0): dark text, WCAG passes → no flag
     Alpha check alone (0.2 alpha): tool may not composite → no flag
     Joint check required */

SA-CSS-CMA-002: all-zero 20-element matrix

values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"

Every coefficient and every offset is zero. R′=G′=B′=A′=0. The entire element becomes fully transparent — RGBA (0,0,0,0). The element is invisible. However, because CSS opacity is unchanged (it is a separate property), the element’s layout box remains fully present and the click target is active at its original position. A user reading the consent UI sees nothing; the “Allow” button is still clickable. Unlike CSS visibility:hidden or display:none which remove click targets, SVG filter transparency leaves them intact.

/* SA-CSS-CMA-002: complete transparency via all-zero matrix
   Every pixel → (R'=0, G'=0, B'=0, A'=0): fully transparent
   CSS opacity: unaffected (separate property, composited after filter)
   Pointer events: active (SVG filter doesn't affect hit-testing)
   Layout box: intact — user can click "Allow" on an invisible button

   Evasion: this is a combined RGB+alpha zero — detectors that check
   RGB rows separately (see SA-CSS-CMM-001 alpha-zero) may catch A'=0,
   but a joint "everything is zero" pattern can slip through a check
   that gates on "alpha row coefficients == 0" without also checking
   whether the entire 20-value array is zero. */

SA-CSS-CMA-003: B↔R channel swap + A′=0.5×A

RGB rows: B↔R swap matrix (R′=B, G′=G, B′=R). Alpha output row: 0 0 0 0.5 0.

Blue consent text (#2563EB, linear R=0.020, G=0.128, B=0.828, A=1.0): after B↔R swap, R′=0.828, G′=0.128, B′=0.020, A′=0.5. Composited on white: L_eff≈0.5×(0.299×0.828+0.587×0.128+0.114×0.020)+0.5×1.0≈0.5×0.327+0.5=0.664. WCAG vs white: (1.05)/(0.664+0.05)=1.47:1 — fail. Green safe badge (#22C55E, linear R=0.016, G=0.557, B=0.116, A=1.0): after swap, R′=0.116, G′=0.557, B′=0.016, A′=0.5. Composited on white: L_eff≈0.5×0.328+0.5=0.664. WCAG 1.47:1 — fail. Both axes (channel swap + alpha reduction) are required to produce the failure; either alone might pass a single-axis check.

/* SA-CSS-CMA-003: B↔R swap + A'=0.5×A
   values="0 0 1 0 0  0 1 0 0 0  1 0 0 0 0  0 0 0 0.5 0"

   Blue text (#2563EB, A=1.0) after filter + composite on white:
     Linear input: R=0.020, G=0.128, B=0.828
     After swap:   R'=0.828, G'=0.128, B'=0.020, A'=0.5
     L_text = 0.299×0.828 + 0.587×0.128 + 0.114×0.020 ≈ 0.325
     L_composite = 0.5×0.325 + 0.5×1.0 = 0.663
     WCAG = 1.05/0.713 ≈ 1.47:1  ← FAIL AA

   Green safe badge (#22C55E, A=1.0):
     Linear input: R=0.016, G=0.557, B=0.116
     After swap:   R'=0.116, G'=0.557, B'=0.016, A'=0.5
     L_text = 0.299×0.116 + 0.587×0.557 + 0.114×0.016 ≈ 0.363
     L_composite = 0.5×0.363 + 0.5×1.0 = 0.682
     WCAG = 1.05/0.732 ≈ 1.43:1  ← FAIL AA

   Single-axis B↔R check (at A=1.0): luminance barely changes → might pass
   Single-axis alpha=0.5 check (on black text): WCAG=1.05/(0.5+0.05)=1.91:1
     → also fails, but detector seeing "50% alpha on dark text" might flag
        differently than the joint semantic outcome */

SA-CSS-CMA-004: click-time identity → sepia+0.2-alpha

Initialized as identity. At mousedown, JavaScript sets the sepia+alpha matrix from SA-CSS-CMA-001. Static SVG inspection sees the safe identity matrix. Only a MutationObserver on the values attribute catches the transition.

/* SA-CSS-CMA-004: runtime sepia+alpha swap at click time
   Static state:  identity matrix (passes static analysis)
   mousedown:     sets CMA-001 matrix (sepia + A'=0.2×A)

   Detection requires MutationObserver watching feColorMatrix.values.
   On mutation: re-run full analyzeColorMatrix() including alpha-compositing.
   Note: if transition happens after mousedown but before click event fires,
   the click is processed against the modified matrix state. */

Detection rule for SA-CSS-CMA: compute the matrix output for representative input pixels (dark text, red badge, green badge) AND apply alpha compositing against a white background before computing WCAG. If WCAG contrast <4.5:1 for any representative input: flag as HIGH. Additionally compute the pair-contrast between red badge and green badge outputs; if <1.3:1, flag as CRITICAL (semantic-reversal). Do not treat alpha and RGB findings independently.

Family 3: Low saturation evasion band (SA-CSS-LSV)

feColorMatrix type="saturate" is syntactically simpler than type="matrix" — it takes a single value between 0 and 1. Detection tools have historically checked for values=0 (complete grayscale) and values ≤0.01 (perceptually grayscale). But the evasion band lies between 0.01 and 0.05: saturation is not zero, values like 0.03 or 0.05 are not caught by the ≤0.01 threshold, and yet the perceptual result still fails WCAG AA for color-coded permission badges.

The core problem is that permission UIs use color as a primary semantic signal: red means danger, green means safe. The WCAG contrast ratio measures luminance contrast between a text color and its background — but for badges that differ in hue rather than luminance, the relevant metric is the pair-contrast between the two semantically distinct badge colors. At s=0.05, a green safe badge (#22C55E) and a red danger badge (#EF4444) have outputs so desaturated that their pair-contrast is 1.13:1 — visually indistinguishable.

SA-CSS-LSV-001: values=0.05 — below AA threshold, above ≤0.01 check

For a green safe badge (#22C55E, sRGB linear R=0.016, G=0.557, B=0.116): the SVG spec type=saturate matrix at s=0.05 mixes each channel toward the luminance weighted average (0.213×R+0.715×G+0.072×B). Applying the full saturate matrix at s=0.05: output sRGB ≈ #5C6B5D (desaturated dark green). Against white background: WCAG 2.89:1 — fails AA (4.5:1 required). For a red danger badge (#EF4444): output sRGB ≈ #5D595C (desaturated warm grey). Against white: WCAG 2.82:1 — fails AA. Pair-contrast between the two badge outputs: 2.89/2.82 ≈ 1.02:1 — indistinguishable.

/* SVG saturate matrix at s=0.05:
   a = 0.213 + 0.787×s = 0.213 + 0.039 = 0.252
   b = 0.715 − 0.715×s = 0.715 − 0.036 = 0.679
   c = 0.072 − 0.072×s = 0.072 − 0.004 = 0.068
   d = 0.213 − 0.213×s = 0.213 − 0.011 = 0.202
   e = 0.715 + 0.285×s = 0.715 + 0.014 = 0.729
   f = 0.072 − 0.072×s = 0.068 (same as c)
   g = 0.213 − 0.213×s = 0.202 (same as d)
   h = 0.715 − 0.715×s = 0.679 (same as b)
   k = 0.072 + 0.928×s = 0.072 + 0.046 = 0.118

   Matrix:  [a  b  c]   [R']   [0.252 0.679 0.068][R]
            [d  e  f] × [G'] = [0.202 0.729 0.068][G]
            [g  h  k]   [B']   [0.202 0.679 0.118][B]

   Green badge #22C55E (linear: R=0.016, G=0.557, B=0.116):
     R' = 0.252×0.016 + 0.679×0.557 + 0.068×0.116 = 0.389
     G' = 0.202×0.016 + 0.729×0.557 + 0.068×0.116 = 0.423
     B' = 0.202×0.016 + 0.679×0.557 + 0.118×0.116 = 0.395
   Output L = 0.299×0.389 + 0.587×0.423 + 0.114×0.395 ≈ 0.413 (linear)
   WCAG vs white = 1.05 / (0.413+0.05) = 2.27:1  ← FAIL AA

   Evasion: 0.05 > 0.01 → passes the ≤0.01 threshold check */

SA-CSS-LSV-002: values=0.02 — within ΔL=0.002 of full grayscale

At s=0.02, the channel weights are so close to the grayscale luminance coefficients (0.213, 0.715, 0.072) that the output is perceptually indistinguishable from full grayscale (s=0). The maximum per-channel deviation from grayscale is less than 0.002 in linear light. Yet s=0.02 > s=0.01, and a detector using ≤0.01 as its threshold reports no finding. The green badge at s=0.02 outputs sRGB ≈ #5C5C5C; WCAG 2.85:1 against white. Red badge: sRGB ≈ #5D5C5C; WCAG 2.82:1. Pair-contrast: 1.01:1 — completely indistinguishable, yet the detector is silent.

SA-CSS-LSV-003: values=0.03 — gap between any fixed threshold pair

There is no fixed threshold pair (lo, hi) that covers all evasion-band values without also catching values that legitimately reduce saturation for aesthetic reasons. The only robust detection is output-based: compute the WCAG ratio at representative badge colors and check pair-contrast. A pair-contrast <1.3:1 between red and green badge outputs is the detection criterion, regardless of the nominal saturation value.

SA-CSS-LSV-004: click-time saturation drop (values=1 → 0.03)

The filter starts at full saturation (values="1"). At mousedown on the consent button, JavaScript changes values to "0.03". The user reads the consent dialog with full color fidelity; badges are visually distinct. At click time, all colors collapse into near-grayscale. Static analysis of the SVG sees values="1" (identity saturation) and reports no finding.

Saturation vs. WCAG for red and green badges:

Saturation (s) Green badge WCAG vs white Red badge WCAG vs white Pair-contrast (G vs R) WCAG AA pass? Pair distinguishable?
0.001 2.83:1 2.81:1 1.01:1 No No
0.01 2.84:1 2.82:1 1.01:1 No No
0.02 2.85:1 2.82:1 1.01:1 No No
0.03 2.86:1 2.83:1 1.01:1 No No
0.05 2.89:1 2.84:1 1.02:1 No No
0.10 3.01:1 2.88:1 1.04:1 No No
0.20 3.28:1 2.96:1 1.11:1 No Marginally
0.50 4.31:1 3.47:1 1.24:1 Borderline Partially
1.00 6.59:1 4.55:1 1.45:1 Yes Yes

Threshold evasion window: every saturation value from 0.001 through approximately 0.45 fails WCAG AA for the green badge. The existing ≤0.01 threshold catches values from 0 to 0.01 but misses the entire 0.01–0.45 range. Output-based pair-contrast detection with a 1.3:1 threshold closes this gap without requiring any specific numeric threshold on the saturation value itself.

Family 4: Non-180° hue rotation (SA-CSS-NHR)

feColorMatrix type="hueRotate" rotates all pixel hues by a constant angle in the HSL color space. Existing detection rules check for values=180 because at 180° the hue rotation maps red (≈0°) to cyan (180°) and green (120°) to magenta (300°), reversing the traffic-light color semantics. But 180° is not uniquely dangerous. Hue rotations at 60°, 120°, and 240° are equally or more destructive: they can map red directly to green (the worst possible semantic reversal for a danger/safe badge pair) or collapse the angular separation between distinct badge hues below perceptual discrimination thresholds.

The SVG specification defines the hue rotation matrix via an explicit 3×3 formula. For rotation angle θ:

/* SVG feColorMatrix type=hueRotate — closed-form matrix
   Let c = cos(θ), s = sin(θ)

   [R']   [0.213+0.787c−0.213s   0.715−0.715c−0.715s   0.072−0.072c+0.928s] [R]
   [G'] = [0.213−0.213c+0.143s   0.715+0.285c+0.140s   0.072−0.072c−0.283s] [G]
   [B']   [0.213−0.213c−0.787s   0.715−0.715c+0.715s   0.072+0.928c+0.072s] [B]

   Note: hueRotate does not modify alpha; A' = A always.

   At θ=0°:   c=1, s=0 → identity matrix (no change)
   At θ=180°: c=−1, s=0 → known attack, checked by most detectors
   At θ=120°: c=−0.5, s=0.866 → NOT checked, equally destructive
   At θ=60°:  c=0.5, s=0.866 → NOT checked, destructive
   At θ=240°: c=−0.5, s=−0.866 → NOT checked, mirror of 120° 

SA-CSS-NHR-001: values=120° — red danger badge maps to bright green

At θ=120° (c=−0.5, s=0.866), red danger badge #EF4444 (sRGB linear: R=0.871, G=0.057, B=0.057) transforms to:

/* θ=120°: c=-0.5, s=0.866
   Matrix entries:
   m00 = 0.213 + 0.787×(−0.5) − 0.213×0.866 = 0.213−0.394−0.184 = −0.365
   m01 = 0.715 − 0.715×(−0.5) − 0.715×0.866 = 0.715+0.358−0.619 =  0.454
   m02 = 0.072 − 0.072×(−0.5) + 0.928×0.866 = 0.072+0.036+0.804 =  0.912
   m10 = 0.213 − 0.213×(−0.5) + 0.143×0.866 = 0.213+0.107+0.124 =  0.444 (approx)
   m11 = 0.715 + 0.285×(−0.5) + 0.140×0.866 = 0.715−0.143+0.121 =  0.693
   m12 = 0.072 − 0.072×(−0.5) − 0.283×0.866 = 0.072+0.036−0.245 = −0.137
   m20 = 0.213 − 0.213×(−0.5) − 0.787×0.866 = 0.213+0.107−0.681 = −0.361
   m21 = 0.715 − 0.715×(−0.5) + 0.715×0.866 = 0.715+0.358+0.619 =  1.692 → clamp
   m22 = 0.072 + 0.928×(−0.5) + 0.072×0.866 = 0.072−0.464+0.062 = −0.330

   Red badge #EF4444 (linear R=0.871, G=0.057, B=0.057):
     R' = −0.365×0.871 + 0.454×0.057 + 0.912×0.057 = −0.318+0.026+0.052 = −0.240 → clamp → 0
     G' =  0.444×0.871 + 0.693×0.057 − 0.137×0.057 =  0.387+0.040−0.008 =  0.419
     B' = −0.361×0.871 + 1.692×0.057 − 0.330×0.057 = −0.314+0.096−0.019 = −0.237 → clamp → 0
   Output: (0, 0.419, 0) → bright green
   WCAG of (0, 0.419, 0) vs white: L_green = 0.587×0.419 = 0.246
   WCAG = 1.05/(0.246+0.05) = 3.55:1  ← FAIL AA
   Semantic result: red DANGER badge appears as GREEN SAFE badge */

The red danger badge (#EF4444) maps to a bright green output. A user sees green — the universal “safe” signal — for a permission that is dangerous. This is the most severe possible semantic reversal. The 180° rotation detector is silent because values="120" is not 180.

SA-CSS-NHR-002: values=60° — red to orange-yellow, green to cyan

At θ=60° (c=0.5, s=0.866), red #EF4444 maps toward orange-yellow and green #22C55E maps toward cyan-teal. The pair-contrast between the two badge outputs drops below the discrimination threshold, and neither output has the same hue as the original — a user familiar with traffic-light conventions cannot reliably interpret the badge colors. WCAG contrast of the orange-yellow output against white: ≈3.05:1 (fail AA). WCAG contrast of the cyan output against white: ≈2.84:1 (fail AA). Both values independently fail the 4.5:1 threshold, meaning the consent UI is failing on two independent WCAG checks that a 180°-only detector never tests.

/* θ=60°: c=0.5, s=0.866
   Red #EF4444 (linear R=0.871, G=0.057, B=0.057):
   Approximate output: R'≈0.668, G'≈0.318, B'≈0.019 → sRGB ~#CE7415 (orange)
   WCAG vs white: L≈0.299×0.668+0.587×0.318+0.114×0.019 ≈ 0.388
   WCAG = 1.05/(0.388+0.05) = 2.40:1  ← FAIL AA

   Green #22C55E (linear R=0.016, G=0.557, B=0.116):
   Approximate output: R'≈0.051, G'≈0.329, B'≈0.558 → sRGB ~#1E5FCA (blue-cyan)
   WCAG vs white: L≈0.299×0.051+0.587×0.329+0.114×0.558 ≈ 0.272
   WCAG = 1.05/(0.272+0.05) = 3.26:1  ← FAIL AA */

SA-CSS-NHR-003: values=240° — mirror of 120°, different mapping

At θ=240° (c=−0.5, s=−0.866), the rotation is the mirror image of 120° (same cosine, negated sine). The red badge does not map to green this time, but to a different desaturated output; green maps to a warm-orange output. Both badge outputs still fail WCAG AA, and the pair-contrast is <1.3:1. A detector that adds a special case for values=120 after observing CZA-001 is blind to this variant.

/* θ=240°: c=-0.5, s=-0.866 (mirror of 120°)
   Evasion chain:
     180°-only detector: values=240 ≠ 180 → no flag
     120°-specific detector: values=240 ≠ 120 → no flag
   Correct detection: closed-form output check for any θ
     compute red-badge and green-badge outputs
     if either WCAG < 4.5:1 OR pair-contrast < 1.3:1 → CRITICAL */

SA-CSS-NHR-004: click-time hue rotation (values=0 → 120)

Initialized as values="0" (no hue change). At mousedown on the consent confirmation button, JavaScript sets values="120". The user reads the consent dialog with correct badge colors; at the moment they click, the danger badge appears as a safe green badge. The semantic reversal happens precisely at confirmation time — the highest-impact point in the consent flow.

Closed-form detection for SA-CSS-NHR: for any hue rotation angle θ, compute the full 3×3 matrix using the SVG spec formula (c=cosθ, s=sinθ). Apply to canonical red (#EF4444) and green (#22C55E) inputs. Check three conditions: (1) WCAG of transformed red against white <4.5:1; (2) WCAG of transformed green against white <4.5:1; (3) hue of transformed red ∈ [90°, 150°] (visually green) or hue of transformed green ∈ [330°, 30°] (visually red). Condition 3 catches the semantic reversal at any angle, not just 120°. All conditions warrant a CRITICAL finding.

Multi-axis detection: the unified analyzeColorMatrix() function

The central lesson of these four families is that single-axis checks have asymmetric blind spots. Alpha-column detection (checking a03, a13, a23) alone misses SA-CSS-CMA-001 (sepia+alpha) because the alpha column coefficients in that attack are in the alpha output row, not the RGB rows. Saturation threshold detection (≤0.01) misses LSV-002 and LSV-003. A 180°-hue check misses NHR-001/002/003. Any one of these detections applied in isolation leaves at least three families undetected.

The solution is a unified output-based function that evaluates the numerical effect of the matrix on a representative set of input pixels, composites the result against a white page background, and checks the composited output against accessibility thresholds. This approach is inherently multi-axis: it doesn’t matter which matrix parameters are responsible for the failure — if the output fails WCAG AA for consent-relevant colors, the finding is raised.

/* Unified multi-axis feColorMatrix detection — pseudocode

function analyzeColorMatrix(feColorMatrixEl) {
  const findings = [];
  const type = feColorMatrixEl.getAttribute('type') ?? 'matrix';
  const valStr = feColorMatrixEl.getAttribute('values') ?? '';

  // --- 1. Parse matrix values into a Float64Array ---
  let v;
  if (type === 'matrix') {
    v = valStr.trim().split(/[\s,]+/).map(Number);
    if (v.length !== 20) return [{ id: 'CZA-PARSE', sev: 'LOW' }];
  } else if (type === 'saturate') {
    const s = parseFloat(valStr);
    v = buildSaturateMatrix(s);   // returns 20-element array
  } else if (type === 'hueRotate') {
    const θ = (parseFloat(valStr) * Math.PI) / 180;
    v = buildHueRotateMatrix(θ);  // returns 20-element array
  } else {
    return [];  // luminanceToAlpha, feColorMatrix identity handled elsewhere
  }

  // --- 2. Alpha-column check (SA-CSS-CZA) ---
  // v[3], v[8], v[13] = a03, a13, a23 in the RGB output rows
  if (v[3] !== 0 || v[8] !== 0 || v[13] !== 0) {
    const darkOpaque = applyMatrix(v, { R: 0.004, G: 0.004, B: 0.004, A: 1.0 });
    const L_out = luminance(composite(darkOpaque, WHITE_BG));
    if (L_out > 0.7) {
      findings.push({ id: 'SA-CSS-CZA', sev: 'CRITICAL',
        detail: `alpha-column injection: dark opaque text → L=${L_out.toFixed(3)}` });
    }
  }

  // --- 3. Representative input set: compute WCAG for each ---
  const INPUTS = [
    { name: 'black_text',    R: 0.004, G: 0.004, B: 0.004, A: 1.0 },
    { name: 'red_badge',     R: 0.871, G: 0.057, B: 0.057, A: 1.0 },  // #EF4444
    { name: 'green_badge',   R: 0.016, G: 0.557, B: 0.116, A: 1.0 },  // #22C55E
    { name: 'blue_text',     R: 0.020, G: 0.128, B: 0.828, A: 1.0 },  // #2563EB
    { name: 'white_bg',      R: 1.000, G: 1.000, B: 1.000, A: 1.0 },
  ];

  const outputs = {};
  for (const inp of INPUTS) {
    const raw = applyMatrix(v, inp);
    outputs[inp.name] = composite(raw, WHITE_BG);
  }

  // WCAG contrast check: dark text on white background
  const L_text = luminance(outputs['black_text']);
  const wcag_text = (1.05) / (L_text + 0.05);
  if (wcag_text < 4.5) {
    findings.push({ id: 'SA-CSS-CMA', sev: 'CRITICAL',
      detail: `consent text WCAG=${wcag_text.toFixed(2)}:1 < 4.5:1 (AA)` });
  }

  // WCAG check for badge colors individually
  for (const badge of ['red_badge', 'green_badge', 'blue_text']) {
    const L_b = luminance(outputs[badge]);
    const wcag_b = (Math.max(L_b, 1.0) + 0.05) / (Math.min(L_b, 1.0) + 0.05);
    if (wcag_b < 4.5) {
      findings.push({ id: 'SA-CSS-NHR-OR-LSV', sev: 'HIGH',
        detail: `${badge} WCAG=${wcag_b.toFixed(2)}:1 after filter+composite` });
    }
  }

  // --- 4. Semantic-reversal check: red/green badge pair-contrast ---
  const L_red   = luminance(outputs['red_badge']);
  const L_green = luminance(outputs['green_badge']);
  const pair = (Math.max(L_red, L_green) + 0.05) / (Math.min(L_red, L_green) + 0.05);
  if (pair < 1.3) {
    findings.push({ id: 'SA-CSS-LSV-OR-NHR', sev: 'CRITICAL',
      detail: `red/green badge pair-contrast=${pair.toFixed(2)}:1 < 1.3:1 (indistinguishable)` });
  }

  // Check if red and green badges have swapped hue quadrant (NHR-specific)
  const hue_red_out   = hue(outputs['red_badge']);
  const hue_green_out = hue(outputs['green_badge']);
  if (inRange(hue_red_out, 90, 150) || inRange(hue_green_out, 330, 30)) {
    findings.push({ id: 'SA-CSS-NHR', sev: 'CRITICAL',
      detail: 'badge hue semantic reversal: red→green or green→red after rotation' });
  }

  return findings;
}

// --- 5. MutationObserver: covers all runtime variants (CZA-004, CMA-004, LSV-004, NHR-004) ---
function watchColorMatrix(el) {
  new MutationObserver((mutations) => {
    for (const m of mutations) {
      if (m.attributeName === 'values' || m.attributeName === 'type') {
        const newFindings = analyzeColorMatrix(m.target);
        if (newFindings.length > 0) reportFindings(m.target, newFindings);
      }
    }
  }).observe(el, { attributes: true, attributeFilter: ['values', 'type'] });
}

// Helper: apply 4×5 matrix to RGBA input
function applyMatrix(v, { R, G, B, A }) {
  return {
    R: clamp01(v[0]*R + v[1]*G + v[2]*B  + v[3]*A  + v[4]),
    G: clamp01(v[5]*R + v[6]*G + v[7]*B  + v[8]*A  + v[9]),
    B: clamp01(v[10]*R + v[11]*G + v[12]*B + v[13]*A + v[14]),
    A: clamp01(v[15]*R + v[16]*G + v[17]*B + v[18]*A + v[19]),
  };
}

// Helper: alpha composite against opaque white background
function composite(fg, bg = { R:1, G:1, B:1, A:1 }) {
  const a = fg.A;
  return {
    R: a*fg.R + (1-a)*bg.R,
    G: a*fg.G + (1-a)*bg.G,
    B: a*fg.B + (1-a)*bg.B,
    A: 1.0,
  };
}

// Helper: relative luminance (IEC 61966-2-1 linearization)
function luminance({ R, G, B }) {
  const lin = (c) => c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
  return 0.2126*lin(R) + 0.7152*lin(G) + 0.0722*lin(B);
}

// Helper: hue in degrees from linear RGB
function hue({ R, G, B }) {
  const max = Math.max(R,G,B), min = Math.min(R,G,B), d = max-min;
  if (d === 0) return 0;
  let h = max === R ? (G-B)/d % 6 : max === G ? (B-R)/d + 2 : (R-G)/d + 4;
  return ((h * 60) + 360) % 360;
}

The MutationObserver step at stage 5 is the critical runtime coverage mechanism. All four click-time variants (CZA-004, CMA-004, LSV-004, NHR-004) change the values attribute of the feColorMatrix element at click time. By observing attributeFilter: ['values', 'type'], the observer fires immediately on any such change, re-runs analyzeColorMatrix(), and reports any findings before the click event reaches the application handler. This closes the dynamic attack surface without requiring instrumentation of JavaScript event handlers.

Why output-based detection beats threshold-based detection: threshold-based rules (s ≤0.01, values=180, a03≠0) are all descriptions of inputs. Output-based detection measures effects. An attacker can always find a matrix parameter combination outside the checked input thresholds that produces the same harmful output. Output-based detection makes the attacker’s job fundamentally harder: to evade detection, they must produce harmful visual effects while simultaneously staying inside all WCAG thresholds and pair-contrast thresholds. That is a much harder constraint to satisfy.

Findings summary

Critical
SA-CSS-CZA-001 / SA-CSS-CZA-002 — Alpha-column injection forces opaque dark text to white (WCAG 1.00:1). Evades all RGB-input-coefficient detectors. Click target remains active.
Critical
SA-CSS-CZA-003 — Single-channel alpha injection causes badge semantic erasure: green safe badge turns orange-red. Evades both white-forcing and full-injection detectors.
Critical
SA-CSS-CMA-001 — Sepia RGB + A′=0.2×A: WCAG 1.23:1 after compositing on white. Requires joint RGB+alpha analysis. Single-axis sepia or alpha checks both report false negatives.
Critical
SA-CSS-CMA-002 — All-zero 20-element matrix: element fully transparent, click target active. CSS opacity unchanged; pointer events not affected by SVG filter transparency.
High
SA-CSS-CMA-003 — B↔R swap + A′=0.5×A: blue text and green badge both fail WCAG AA after compositing. WCAG 1.43–1.47:1. Neither axis alone reliably triggers detection.
High
SA-CSS-LSV-001 through LSV-003 — Saturation evasion band 0.01–0.05: red/green badge pair-contrast 1.01–1.02:1, WCAG 2.82–2.89:1. Existing ≤0.01 threshold misses the entire band.
Critical
SA-CSS-NHR-001 — Hue rotation 120°: red danger badge outputs bright green (WCAG 3.55:1 fail). Semantic reversal identical to worst-case 180° rotation. Not checked by any 180°-specific detector.
High
SA-CSS-NHR-002 / SA-CSS-NHR-003 — Hue rotation 60° and 240°: both badge colors fail WCAG AA and pair-contrast <1.3:1. Evade 120°-specific and 180°-specific checks.
Critical
CZA-004 / CMA-004 / LSV-004 / NHR-004 — All four click-time variants: static SVG analysis sees identity; attack activates at mousedown. MutationObserver on values attribute is the only effective detection path.

Conclusion

The four new feColorMatrix attack families — alpha-column injection (SA-CSS-CZA), mixed alpha+RGB attacks (SA-CSS-CMA), low saturation evasion band (SA-CSS-LSV), and non-180° hue rotation (SA-CSS-NHR) — demonstrate that single-axis detection tools are structurally insufficient for the consent-attack surface. Any detection rule parameterized on a single matrix property (coefficient value, saturation number, rotation angle) can be evaded by an attacker who targets a different axis. The unified analyzeColorMatrix() function presented here closes the gap by evaluating the output effect of any feColorMatrix configuration against representative consent-UI colors, compositing results against a white background, and checking both individual WCAG ratios and cross-badge pair-contrast. Covered together with the SA-CSS-CMM synthesis, these eight families now account for all known exploitable regions of the 20-dimensional feColorMatrix parameter space. Full technical references for each family are available on the individual pattern pages linked above.