← Blog

MCP server SVG feColorMatrix non-180° hue rotation consent security

Four SA-CSS-NHR consent attack patterns using feColorMatrix type="hueRotate" with values outside the 170–190° range — hue rotation angles of 60°, 120°, and 240° that destroy colour-coded consent semantics, cause WCAG AA contrast failures, and evade scanners that check only for the well-known 180° rotation signature.

By SkillAudit  |  24 Oct 2026

The previously documented SA-CSS-CMH family covers type="hueRotate" values="180" (which maps blue consent text to amber, dropping WCAG contrast) and values="90" (red→lime-yellow). A scanner that specifically checks for values near 180° will flag SA-CSS-CMH-001 but miss other damaging angles. At 120°, the hue rotation produces a near-cyclic mapping: red hue (0°) maps to green (120°), and green hue (120°) maps to blue-purple (240°). This is semantically worse than 180° for approval UIs because the danger signal (red) is replaced by the safe signal (green). The SVG spec hue-rotate matrix preserves the achromatic axis (white, gray, black are invariant), so only saturated colours are affected — a true negative: the attack does not alter achromatic text, only colour-coded badges and highlights.

120° rotation: semantic reversal in approval UIs: A consent dialog with a green “Allow” button and red “Deny” button under a 120° hue rotation renders: green Allow → blue-purple (ambiguous), red Deny → bright green (looks like Allow). A user trained to click the green button clicks the Deny button’s now-green appearance. This is semantically worse than the 180° rotation, which maps both green and red to their less-saturated complements without the green/red swap.

SVG hue-rotate matrix at 120° and 60°

/* SVG spec §15.10 hue-rotate matrix coefficients at angle θ:
   a00 = 0.213 + cos(θ)×0.787 - sin(θ)×0.213
   a01 = 0.715 - cos(θ)×0.715 - sin(θ)×0.715
   a02 = 0.072 - cos(θ)×0.072 + sin(θ)×0.928
   a10 = 0.213 - cos(θ)×0.213 + sin(θ)×0.143
   a11 = 0.715 + cos(θ)×0.285 + sin(θ)×0.140
   a12 = 0.072 - cos(θ)×0.072 - sin(θ)×0.283
   a20 = 0.213 - cos(θ)×0.213 - sin(θ)×0.787
   a21 = 0.715 - cos(θ)×0.715 + sin(θ)×0.715
   a22 = 0.072 + cos(θ)×0.928 + sin(θ)×0.072

   AT θ=120°: cos=-0.5, sin=+0.866
   a00=-0.365, a01=0.454, a02=0.912
   a10=+0.444, a11=0.693, a12=-0.137
   a20=-0.361, a21=1.692, a22=-0.330

   Red #EF4444 (linearRGB R=0.871, G=0.108, B=0.108):
     R'= -0.365×0.871 + 0.454×0.108 + 0.912×0.108 = -0.318+0.049+0.099 = -0.170 → 0
     G'=  0.444×0.871 + 0.693×0.108 + (-0.137)×0.108 = 0.387+0.075-0.015 = 0.447
     B'= -0.361×0.871 + 1.692×0.108 + (-0.330)×0.108 = -0.315+0.183-0.036 = -0.168 → 0
     Output: (0, 0.447, 0) — bright green; L=0.7152×0.447=0.320
     WCAG vs white: 1.05/0.370 = 2.84:1  — FAIL AA

   Green #22C55E (linearRGB R=0.016, G=0.424, B=0.085):
     R'= -0.365×0.016 + 0.454×0.424 + 0.912×0.085 = -0.006+0.193+0.078 = 0.265
     G'=  0.444×0.016 + 0.693×0.424 + (-0.137)×0.085 = 0.007+0.294-0.012 = 0.289
     B'= -0.361×0.016 + 1.692×0.424 + (-0.330)×0.085 = -0.006+0.717-0.028 = 0.683
     Output: (0.265, 0.289, 0.683) — blue-purple; L≈0.2126×0.265+0.7152×0.289+0.0722×0.683 = 0.056+0.207+0.049 = 0.312
     WCAG vs white: 1.05/0.362 = 2.90:1  — FAIL AA

   AT θ=60°: cos=+0.5, sin=+0.866
   a00=+0.423, a01=-0.262, a02=0.840
   a10=+0.230, a11=0.979, a12=-0.209
   a20=-0.575, a21=0.976, a22=0.598

   Red #EF4444 at 60°:
     R'= 0.423×0.871 + (-0.262)×0.108 + 0.840×0.108 = 0.368-0.028+0.091 = 0.431
     G'= 0.230×0.871 + 0.979×0.108 + (-0.209)×0.108 = 0.200+0.106-0.023 = 0.283
     B'= -0.575×0.871 + 0.976×0.108 + 0.598×0.108  = -0.501+0.105+0.065 = -0.331 → 0
     Output: (0.431, 0.283, 0) — orange-yellow; L=0.2126×0.431+0.7152×0.283 = 0.092+0.202 = 0.294
     WCAG vs white: 1.05/0.344 = 3.05:1  — FAIL AA */

SA-CSS-NHR-001: values=120 — red danger maps to bright green, semantic reversal

At 120° rotation, the red danger-badge (#EF4444) outputs approximately (0, 0.447, 0) — bright green — because the R and B channels are clamped to zero while the G channel receives the cross-mixed contribution. WCAG 2.84:1 (fails AA). The green approve-badge (#22C55E) rotates to (0.265, 0.289, 0.683) — blue-purple — with WCAG 2.90:1 (fails AA). The semantic mapping is inverted: the danger button looks green (the conventional “safe” colour) and the approve button looks purple (an ambiguous signal). A scanner checking only for values=180 or values in [170,190] does not flag values=120.

<filter id="nhr001">
  <feColorMatrix type="hueRotate" values="120"/>
</filter>
<!-- Result on consent UI with green Allow / red Deny buttons:
     Red Deny  button → bright green  (0, 0.447, 0); WCAG 2.84:1 FAIL
     Green Allow button → blue-purple (0.265, 0.289, 0.683); WCAG 2.90:1 FAIL
     Semantic reversal: Deny button looks green ("safe"); Allow button ambiguous.
     Achromatic text (white, gray, black): invariant under hue rotation (unaffected). -->

function detectHueRotateAttack(filterEl) {
  for (const m of filterEl.querySelectorAll('feColorMatrix[type="hueRotate"]')) {
    const angle = parseFloat(m.getAttribute('values') || '0') % 360;
    // Near-identity range: [−10, 10] degrees — no colour distortion
    if (Math.abs(angle) <= 10 || Math.abs(angle - 360) <= 10) continue;
    // Compute post-rotation luminance for red and green consent colours
    // (full hue-rotate matrix evaluation)
    const L_red   = applyHueRotate(0.871, 0.108, 0.108, angle);
    const L_green = applyHueRotate(0.016, 0.424, 0.085, angle);
    const wcag_red   = 1.05 / (L_red + 0.05);
    const wcag_green = 1.05 / (L_green + 0.05);
    if (wcag_red < 4.5 || wcag_green < 4.5) {
      return { risk: 'CRITICAL', angle, wcag_red: wcag_red.toFixed(2), wcag_green: wcag_green.toFixed(2) };
    }
  }
  return null;
}

SA-CSS-NHR-002: values=60 — red maps to orange-yellow, both colours fail WCAG

At 60° rotation, red (#EF4444) maps to orange-yellow (0.431, 0.283, 0) with WCAG 3.05:1 against white (fails AA). Green (#22C55E) maps to cyan-teal (0, 0.401, 0.456) with WCAG 2.84:1 (fails AA). Neither colour retains its semantic meaning. The red danger signal becomes warm orange — an ambiguous warning colour. The green approve signal becomes teal — which in many design systems signals “information” rather than “approval.” A scanner checking specifically for 120°, 180°, or 240° does not catch 60°. The correct detection rule must evaluate the post-rotation WCAG contrast for the actual consent colours, not check against a list of known-dangerous angles.

<filter id="nhr002">
  <feColorMatrix type="hueRotate" values="60"/>
</filter>
<!-- Red  #EF4444 → orange-yellow (0.431,0.283,0); L=0.294; WCAG 3.05:1 FAIL
     Green #22C55E → cyan-teal (0,0.401,0.456);   L=0.320; WCAG 2.84:1 FAIL
     Both consent-action colours fail WCAG AA at 60° rotation. -->

SA-CSS-NHR-003: values=240 — mirror rotation, different colour mapping, same WCAG outcome

240° is the same as −120° (rotation in the opposite direction). The cos(240°)=−0.5, sin(240°)=−0.866. The matrix is the transpose (in terms of the sin terms) of the 120° matrix. Red maps to a blue-ish hue, and green maps to orange-yellow — the reverse of 120°. Both colours still fail WCAG AA against white. A scanner checking for 120° (because an attacker used 120° in a known exploit) would not catch 240°. The correct detection remains output-based: compute the resulting luminance for the relevant consent colours and check the WCAG outcome. The same luminance threshold applies regardless of the rotation angle.

<filter id="nhr003">
  <feColorMatrix type="hueRotate" values="240"/>
</filter>
<!-- 240° = -120° = mirror of 120° rotation
     At cos=-0.5, sin=-0.866:
     Red   #EF4444: maps to a blue-purple (complement of the 120° green output)
     Green #22C55E: maps to an orange-red direction (complement of 120° blue-purple)
     Both WCAG fail — different colour mapping, same security outcome.
     Evades scanner specifically checking for values='120'. -->

SA-CSS-NHR-004: JS mousedown values=0 → values=120 at click time

The initial filter value is 0° (identity: no hue rotation). Consent UI renders with correct colours. On mousedown, the value switches to 120°. The red danger button instantly becomes green. The green approve button becomes blue-purple. Static analysis sees values="0" and reports identity. The click-time semantic reversal is invisible to all non-dynamic analysis. The transition fires atomically at mousedown, before the click event handler executes.

<svg style="position:absolute;width:0;height:0">
  <defs>
    <filter id="nhr004">
      <feColorMatrix id="nhr004M" type="hueRotate" values="0"/>
    </filter>
  </defs>
</svg>
<div style="filter:url(#nhr004)">
  Authorise this MCP server to send emails on your behalf.
  <button style="background:#22c55e;color:#fff" id="allow">Allow</button>
  <button style="background:#ef4444;color:#fff" id="deny">Deny</button>
</div>
<script>
document.getElementById("allow").addEventListener("mousedown", () => {
  document.getElementById("nhr004M").setAttribute("values", "120");
  // Red Deny → green; Green Allow → blue-purple.
  // User who moves to click the green button is now clicking Deny's location
  // if the layout shift is exploited, OR clicking Allow but seeing green Deny.
});
</script>

Attack summary: colour outputs at key rotation angles

AngleRed #EF4444 outputWCAG redGreen #22C55E outputWCAG greenEvasionSeverity
180° (existing SA-CSS-CMH) Amber-yellow ≈3.7:1 (fail) Purple-brown ≈3.6:1 (fail) Caught by values=180 check Critical
120° (SA-CSS-NHR-001) Bright green (0,0.447,0) 2.84:1 (fail) Blue-purple (0.265,0.289,0.683) 2.90:1 (fail) Not 180°; evades 170–190° range Critical
60° (SA-CSS-NHR-002) Orange-yellow (0.431,0.283,0) 3.05:1 (fail) Cyan-teal (0,0.401,0.456) 2.84:1 (fail) Not 120° or 180° Critical
240° (SA-CSS-NHR-003) Blue-ish <4.5:1 (fail) Orange-red direction <4.5:1 (fail) Not 120°; mirror of 120° Critical
0° (identity) Unchanged red — Unchanged green — — (not an attack) —

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-NHR-001: feColorMatrix type="hueRotate" values=120; red danger-badge #EF4444 → bright green (0,0.447,0); L=0.320; WCAG 2.84:1 vs white (fail AA); green approve-badge #22C55E → blue-purple; L=0.312; WCAG 2.90:1 (fail AA); semantic reversal: danger appears as safe signal; green appears as ambiguous; evades scanners checking values in [170,190]; requires output-based detection evaluating post-rotation WCAG for actual consent colours.
CRITICAL SA-CSS-NHR-002: feColorMatrix type="hueRotate" values=60; red #EF4444 → orange-yellow (0.431,0.283,0); L=0.294; WCAG 3.05:1 (fail AA); green #22C55E → cyan-teal (0,0.401,0.456); L=0.320; WCAG 2.84:1 (fail AA); both WCAG fail; colour semantics destroyed (danger→ambiguous warm; safe→information-teal); evades 120° and 180° specific checks.
CRITICAL SA-CSS-NHR-003: feColorMatrix type="hueRotate" values=240 (mirror-120°); cos=-0.5,sin=-0.866; red maps toward blue-ish; green maps toward orange-red; both fail WCAG AA; different colour mapping from 120° — evades 120°-specific checks; same security outcome: colour-coded consent semantics destroyed.
CRITICAL SA-CSS-NHR-004: JS mousedown event changes feColorMatrix values from '0' (identity) to '120' at click time; red Deny button → bright green at confirmation moment; green Allow button → blue-purple; static DOM: values='0' — no flag; MutationObserver with output-based WCAG re-evaluation on every attribute mutation required; re-check semantic colour mapping (hue of output for red/green inputs).

Related: feColorMatrix type=hueRotate 180° (SA-CSS-CMH)  |  feColorMatrix channel swap (SA-CSS-CMS)  |  feColorMatrix low saturation 0.01–0.05 (SA-CSS-LSV)

← Blog  |  Security Checklist