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.
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 s | Green badge L | Red badge L | Pair contrast | WCAG green | WCAG red | Severity |
|---|---|---|---|---|---|---|
| 0.000 (full gray) | 0.313 | 0.270 | 1.13:1 | 2.89:1 | 3.28:1 | Critical |
| 0.001 (existing SA-CSS-SAT-003) | 0.313 | 0.270 | 1.13:1 | 2.89:1 | 3.28:1 | Critical |
| 0.020 (SA-CSS-LSV-002) | 0.315 | 0.271 | 1.14:1 | 2.87:1 | 3.27:1 | Critical |
| 0.030 (SA-CSS-LSV-003) | 0.313 | 0.270 | 1.13:1 | 2.89:1 | 3.28:1 | Critical |
| 0.050 (SA-CSS-LSV-001) | 0.312 | 0.270 | 1.13:1 | 2.89:1 | 3.28:1 | Critical |
| 0.100 | 0.312 | 0.271 | 1.13:1 | 2.89:1 | 3.28:1 | Critical |
| 0.500 | 0.320 | 0.295 | 1.09:1 | 2.82:1 | 2.99:1 | Critical |
| 1.000 (identity) | 0.484 | 0.213 | 2.27:1 | 2.17:1 | 4.94:1 | — (identity) |
Defences
- Use output-based detection, not threshold-based: For any
feColorMatrix type="saturate"with values below 0.5, apply the saturation matrix to the key consent colours in the UI (green, red, blue, yellow). Compute post-saturation luminance and check: (a) each colour’s WCAG contrast vs white and (b) the pair contrast between any two colours signalling distinct consent options. A pair contrast below 1.5:1 is CRITICAL regardless of the saturation value. - Update scanner threshold to s ≤ 0.05: As an interim measure pending full output-based detection, update any existing threshold from
values ≤ 0.001tovalues ≤ 0.05. The entire range 0–0.05 produces near-identical output for typical consent colours (table above). Values above 0.05 begin to diverge but most still produce pair contrast below 1.5:1 up to s=0.5. - MutationObserver on feColorMatrix values attribute: Re-compute post-saturation pair contrast on every attribute change. The SA-CSS-LSV-004 identity→low-saturation transition fires at mousedown. Block consent confirmation if pair contrast falls below 1.5:1 at any point between page load and click confirmation.
- True negative: high saturation is not an attack: Values above 0.8 typically preserve sufficient colour differentiation (pair contrast > 1.5:1 and most colours pass WCAG individually). Do not flag legitimate desaturation styling used for disabled or inactive UI states without checking the actual consent elements.
SkillAudit findings for this attack surface
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.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.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.