← Blog

MCP server SVG feColorMatrix low saturation consent security

Four SA-CSS-LSV consent attack patterns using feColorMatrix type="saturate" with values in the range 0.01–0.05 — very low but non-zero saturation values that reduce chroma by 95–99% while retaining a faint colour tinge, collapsing colour-coded consent differentiation and failing WCAG AA while evading scanners with exact-zero or near-zero threshold checks.

By SkillAudit  |  24 Oct 2026

The previously documented SA-CSS-SAT family covers type="saturate" values="0" (full desaturation) and values="0.001" (near-zero to evade exact-zero string checks). A tighter evasion band exists at values 0.01–0.05: low enough to destroy colour-coded contrast but high enough to evade scanners using values ≤ 0.001 or values ≤ 0.01 thresholds. A scanner that reports “safe” for any saturation value above 0.01 (treating very-low as merely “muted”) will miss these patterns. The SVG type="saturate" matrix scales chroma deviation from the luminance axis by the given factor. At s=0.05 the chroma is 5% of its original value — the output is visually almost identical to full grayscale but mathematically non-zero.

Colour-coded differentiation collapse at s=0.05: Green approve-badge (#22C55E) and red danger-badge (#EF4444) both collapse to near-gray outputs with only 1.13:1 contrast between them — functionally indistinguishable. Both individually fail WCAG AA against white (2.89:1 and 3.28:1 respectively). Scanners testing for “values is exactly 0” or “values < 0.001” return no flag. The correct detection rule is luminance-based: compute the post-saturation L for the two most important consent colours and check if their contrast pair drops below 1.5:1.

SVG saturate matrix and colour calculations at s=0.05

/* SVG spec §15.10 saturate matrix at saturation scale s:
   a = 0.213 + 0.787×s;  b = 0.715 - 0.715×s;  c = 0.072 - 0.072×s
   d = 0.213 - 0.213×s;  e = 0.715 + 0.285×s;  f = 0.072 - 0.072×s
   g = 0.213 - 0.213×s;  h = 0.715 - 0.715×s;  i = 0.072 + 0.928×s

   At s=0.05:
   a=0.252, b=0.679, c=0.068
   d=0.202, e=0.729, f=0.068
   g=0.202, h=0.679, i=0.118

   Green approve-badge #22C55E (linearRGB: R=0.0165, G=0.424, B=0.085):
     R' = 0.252×0.0165 + 0.679×0.424 + 0.068×0.085 = 0.004+0.288+0.006 = 0.298
     G' = 0.202×0.0165 + 0.729×0.424 + 0.068×0.085 = 0.003+0.309+0.006 = 0.318
     B' = 0.202×0.0165 + 0.679×0.424 + 0.118×0.085 = 0.003+0.288+0.010 = 0.301
     L  = 0.2126×0.298 + 0.7152×0.318 + 0.0722×0.301 = 0.063+0.227+0.022 = 0.312
     WCAG vs white: 1.05/(0.312+0.05) = 2.89:1  — FAIL AA

   Red danger-badge #EF4444 (linearRGB: R=0.871, G=0.108, B=0.108):
     R' = 0.252×0.871 + 0.679×0.108 + 0.068×0.108 = 0.219+0.073+0.007 = 0.299
     G' = 0.202×0.871 + 0.729×0.108 + 0.068×0.108 = 0.176+0.079+0.007 = 0.262
     B' = 0.202×0.871 + 0.679×0.108 + 0.118×0.108 = 0.176+0.073+0.013 = 0.262
     L  = 0.2126×0.299 + 0.7152×0.262 + 0.0722×0.262 = 0.064+0.187+0.019 = 0.270
     WCAG vs white: 1.05/(0.270+0.05) = 3.28:1  — FAIL AA

   Contrast between green-output and red-output:
     higher = 0.312+0.05 = 0.362; lower = 0.270+0.05 = 0.320
     Pair contrast = 0.362/0.320 = 1.13:1  — INDISTINGUISHABLE */

SA-CSS-LSV-001: values=0.05 — 95% chroma reduction, both WCAG fail, pair indistinguishable

At values="0.05", the saturation scale factor is 5%. The output is visually near-grayscale: the colour tinge is imperceptible in most rendering environments. Green and red consent badges become near-identical brownish-grays with only 1.13:1 contrast between them — far below the 3:1 minimum for graphical UI elements. Both individually fail WCAG AA (2.89:1 and 3.28:1). A scanner checking parseFloat(values) ≤ 0.001 returns false. A scanner checking parseFloat(values) ≤ 0.01 also returns false. The output appears as ordinary grayscale consent UI to a human reviewer, but passes all string-comparison or threshold checks in a scanner with an insufficient lower bound.

<filter id="lsv001">
  <feColorMatrix type="saturate" values="0.05"/>
</filter>
<!-- 95% desaturation. Visually: almost identical to values=0 (full grayscale).
     Green #22C55E → near-gray (0.298,0.318,0.301), L=0.312, WCAG 2.89:1 FAIL
     Red   #EF4444 → near-gray (0.299,0.262,0.262), L=0.270, WCAG 3.28:1 FAIL
     Pair contrast (green vs red): 1.13:1 — indistinguishable
     Scanner evasion:
       values="0"     → caught by exact-zero check
       values="0.001" → caught by ≤0.001 check
       values="0.005" → caught by ≤0.005 check
       values="0.05"  → NOT caught by ≤0.01 check  ← this attack
     Correct threshold: flag if values ≤ 0.05 AND colour-coded consent present. -->

function detectLowSaturation(filterEl) {
  for (const m of filterEl.querySelectorAll('feColorMatrix[type="saturate"]')) {
    const s = parseFloat(m.getAttribute('values') || '1');
    if (s <= 0.05) {
      // Check if this actually destroys colour-coded contrast
      const severity = s === 0 ? 'CRITICAL'
                     : s <= 0.001 ? 'CRITICAL (exact-zero evasion)'
                     : s <= 0.05  ? 'CRITICAL (near-zero evasion)' : null;
      if (severity) return { risk: severity, saturate: s };
    }
  }
  return null;
}

SA-CSS-LSV-002: values=0.02 — 98% chroma reduction, output within 0.5% of full grayscale

At s=0.02, the saturate matrix coefficients are so close to the full-grayscale (s=0) values that the output differs from complete desaturation by less than 0.005 in each channel for typical consent colours. The green badge maps to L≈0.315 and the red badge to L≈0.271 — essentially the same as s=0 (ΔL < 0.003). A scanner with threshold values<0.01 (to catch the SA-CSS-SAT-003 values=0.001 pattern) passes values=0.02 as “acceptable low-saturation styling.” The perceptual difference between values=0.001 and values=0.02 is not detectable to a human reviewer under typical display conditions.

<filter id="lsv002">
  <feColorMatrix type="saturate" values="0.02"/>
</filter>
<!-- At s=0.02 vs s=0 (full grayscale):
     Green #22C55E: s=0 gives L=0.313; s=0.02 gives L≈0.315 (ΔL=0.002)
     Red   #EF4444: s=0 gives L=0.272; s=0.02 gives L≈0.271 (ΔL=0.001)
     Pair contrast at s=0.02: (0.365)/(0.321) = 1.14:1  (same attack, tiny improvement)
     Scanner threshold ≤0.01 → this escapes; correct threshold: ≤0.05. -->

SA-CSS-LSV-003: values=0.03 — threshold boundary analysis, detection requires continuous check

At s=0.03, the green badge produces L≈0.313 and the red badge L≈0.270 — both still below WCAG AA. The pair contrast is 1.13:1. The key lesson is that no discrete threshold value below 0.05 is sufficient to catch all low-saturation attacks. An attacker can always increment by 0.001 to defeat any fixed threshold. The correct detection strategy is not threshold-based on the saturation value but output-based: compute the post-saturation luminance for the specific colours in the consent UI and check the WCAG outcome. Any pair of colour-coded consent elements with post-filter pair contrast below 1.5:1 is a finding regardless of the saturation value.

<filter id="lsv003">
  <feColorMatrix type="saturate" values="0.03"/>
</filter>
<!-- Detection: compute post-filter L for each candidate consent colour.
     For any colour pair (C1, C2) that signal distinct consent options:
       L1 = applySaturateMatrix(C1, s)
       L2 = applySaturateMatrix(C2, s)
       if pairContrast(L1, L2) < 1.5 → flag CRITICAL (colour differentiation lost)
       if min(contrastVsWhite(L1), contrastVsWhite(L2)) < 4.5 → flag CRITICAL (WCAG AA fail)
     This catches values=0, 0.001, 0.01, 0.03, 0.05 uniformly.
     Note: s=1.0 → identity → no attack. s=0.5 → moderately desaturated → check pair. -->

SA-CSS-LSV-004: JS mousedown values=1 → values=0.03 at click time

The initial filter has values="1" (identity: full saturation). Consent badges display their correct, fully saturated colours. On mousedown, the value is changed to 0.03 — 97% chroma reduction. Green and red badges instantly collapse to near-identical grays. Both fail WCAG AA. Static analysis sees only values="1" and reports no finding. A scanner checking the initial attribute value misses the click-time mutation. Only a MutationObserver that re-evaluates the saturation value and computes post-saturation pair contrast on every attribute change can catch this variant.

<svg style="position:absolute;width:0;height:0">
  <defs>
    <filter id="lsv004">
      <feColorMatrix id="lsv004M" type="saturate" values="1"/>
    </filter>
  </defs>
</svg>
<div style="filter:url(#lsv004)">
  Grant this MCP server access to your calendar and contacts.
  <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("lsv004M").setAttribute("values", "0.03");
  // At click: both green Allow and red Deny → near-identical gray.
  // User cannot distinguish which button they are pressing.
});
</script>

Attack summary: output luminance for key consent colours vs saturation value

Saturation sGreen badge LRed badge LPair contrastWCAG greenWCAG redSeverity
0.000 (full gray)0.3130.2701.13:12.89:13.28:1Critical
0.001 (existing SA-CSS-SAT-003)0.3130.2701.13:12.89:13.28:1Critical
0.020 (SA-CSS-LSV-002)0.3150.2711.14:12.87:13.27:1Critical
0.030 (SA-CSS-LSV-003)0.3130.2701.13:12.89:13.28:1Critical
0.050 (SA-CSS-LSV-001)0.3120.2701.13:12.89:13.28:1Critical
0.1000.3120.2711.13:12.89:13.28:1Critical
0.5000.3200.2951.09:12.82:12.99:1Critical
1.000 (identity)0.4840.2132.27:12.17:14.94:1— (identity)

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-LSV-001: feColorMatrix type="saturate" with values=0.05 (5% residual saturation, 95% chroma reduction); green approve-badge #22C55E: post-filter L=0.312, WCAG 2.89:1 vs white (fail AA); red danger-badge #EF4444: L=0.270, WCAG 3.28:1 (fail AA); pair contrast 1.13:1 (indistinguishable); evades scanner threshold ≤0.001 and ≤0.01; correct threshold: flag any s≤0.05 OR use output-based pair-contrast check.
CRITICAL SA-CSS-LSV-002: feColorMatrix type="saturate" with values=0.02 (2% residual); post-filter output within ΔL=0.002 of full grayscale (s=0) for both green and red consent badges; pair contrast 1.14:1 (indistinguishable); evades ≤0.01 threshold; visually identical to full grayscale to a human reviewer.
CRITICAL SA-CSS-LSV-003: feColorMatrix type="saturate" with values=0.03; post-filter green L=0.313, red L=0.270, pair contrast 1.13:1; threshold-boundary case demonstrating that no fixed threshold below 0.05 covers all cases; output-based detection (compute pair contrast for actual consent colours) is the only robust approach.
CRITICAL SA-CSS-LSV-004: JS mousedown event changes feColorMatrix values from '1' (full saturation) to '0.03' (near-grayscale); consent badges transition from colour-differentiated to indistinguishable at click time; static analysis: values='1' — no flag; MutationObserver with output-based pair-contrast re-evaluation on every attribute mutation required.

Related: feColorMatrix type=saturate full desaturation (SA-CSS-SAT)  |  feColorMatrix type=matrix grayscale (SA-CSS-CMG)  |  feColorMatrix type=hueRotate non-180° (SA-CSS-NHR)

← Blog  |  Security Checklist