Security Guide
MCP server SVG clip-rule consent security — evenodd holes in clipPath, nonzero vs evenodd winding attack, clipPath child placement, and mousedown rule change
The SVG clip-rule attribute controls the winding-rule algorithm used to resolve which pixels inside a <clipPath> path geometry are clipped (hidden) and which are revealed. The evenodd rule creates holes at even winding-number regions of a complex path; nonzero fills everything inside the outermost contour. An attacker designs a multi-ring clipPath path whose evenodd holes coincide exactly with the sensitive permission-scope words in the consent text, leaving “Allow:” and button labels visible but hiding scope terms. Four attack patterns exploit clip-rule: engineered evenodd holes targeting scope words, a nonzero-to-evenodd switch that silently changes clip coverage, dual clip-rule placement (on <clipPath> child vs on the clipped element), and mousedown injection of the evenodd value during the click window.
Attack 1: clip-rule="evenodd" on multi-ring clipPath creates holes over permission-scope words (SA-CSS-CLR-001)
The SVG clip-rule attribute accepts two values: nonzero (the default) and evenodd. For a simple convex path, both rules produce identical results — everything inside the contour is clipped. For paths with self-intersecting rings or concentric sub-paths, the rules diverge: nonzero uses ray-casting with a signed winding count and clips everything inside any ring; evenodd counts crossings without sign and clips only odd-winding-count regions, leaving even-winding-count regions as transparent holes.
An attacker constructs a <clipPath> containing a complex <path d="..."> that defines an outer ring covering the entire consent panel and inner counter-winding rings positioned over the sensitive permission terms. With clip-rule="evenodd", the inner rings are even-winding-number regions — they appear as holes in the clip region. The consent text elements that fall in these holes are revealed (unclipped). The consent text elements that fall outside the holes are clipped (hidden). The attacker therefore makes the sensitive scope words visible through the evenodd holes while all surrounding framing text is hidden, or, inverted: makes only the benign framing text visible while scope words fall in clipped (outside-the-clip) regions. The DOM textContent is unchanged; the clipPath element contains a path with complex geometry that is difficult to audit visually.
<!-- SA-CSS-CLR-001: evenodd holes in clipPath hide permission-scope words -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Outer ring covers full panel; inner rings create evenodd holes over scope words -->
<clipPath id="consent-clip">
<!-- clip-rule="evenodd" applied to the path child of clipPath -->
<path clip-rule="evenodd" d="
M 0,0 H 440 V 200 H 0 Z
M 80,40 H 360 V 60 H 80 Z
"/>
<!-- The second sub-path (M 80,40 ...) creates a counter-winding inner ring -->
<!-- at y=40..60 covering the 'shell execution, credential access' text row -->
<!-- evenodd: outer ring (winding=1, odd) = visible; inner ring (winding=2, even) = hole -->
<!-- Result: text row at y=40..60 is NOT clipped (hole) = visible -->
<!-- BUT: if the outer ring is the clip-include region: inner ring = excluded -->
<!-- An attacker can engineer either effect depending on clipPath polarity -->
</clipPath>
</defs>
<g clip-path="url(#consent-clip)">
<text x="220" y="30" text-anchor="middle" font-size="14">Allow: all listed permissions</text>
<!-- This text row at y=55 falls in the inner ring (even winding) = hole = invisible -->
<text x="220" y="55" text-anchor="middle" font-size="14">shell execution, credential access</text>
<rect x="20" y="80" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="80" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="100" text-anchor="middle" font-size="13" fill="white">Deny</text>
</g>
</svg>
// Detection: find clipPath children with clip-rule="evenodd" on complex paths
function checkClipRuleEvenodd(svgEl) {
const findings = [];
for (const cpEl of svgEl.querySelectorAll('clipPath')) {
for (const child of cpEl.children) {
// clip-rule on clipPath child shapes
const attrRule = child.getAttribute('clip-rule');
const styleRule = getComputedStyle(child).clipRule;
const rule = attrRule || styleRule || 'nonzero';
if (rule !== 'evenodd') continue;
// Complex paths: count sub-paths (M commands) as proxy for ring count
const d = child.getAttribute('d') || '';
const subPaths = (d.match(/M/gi) || []).length;
if (subPaths >= 2) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CLR-001',
note: `<clipPath#${cpEl.id}> child <${child.tagName}> has clip-rule="evenodd" ` +
`and ${subPaths} sub-paths; evenodd creates holes at even-winding regions; ` +
`attacker can position holes to hide permission-scope words in clipped element; ` +
`textContent unchanged; visual inspection required on clip geometry`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-CLR-001: A clipPath child with clip-rule="evenodd" and two or more sub-paths creates holes at even-winding-number regions. An attacker engineers hole positions to coincide with sensitive permission-scope words in the clipped consent text. textContent is unchanged; DOM keyword checks pass. Detection requires reading clip-rule on clipPath children and flagging multi-ring evenodd geometries.
Attack 2: Switching from nonzero to evenodd changes which areas are clipped without altering the path (SA-CSS-CLR-002)
A consent panel may be designed with a clipPath whose path geometry produces identical results under nonzero and evenodd at initial render — a simple convex rectangle, for instance. The attacker exploits a secondary script that later replaces the simple convex path with a complex multi-ring path while simultaneously switching clip-rule from nonzero to evenodd. Because the initial static SVG scan sees a simple path and clip-rule="nonzero", it rates the clip region as clean. The subsequent dynamic change alters clip coverage without any visual indicator to the user. The clip-rule value at the moment of user interaction determines what is visible; the value at audit time may be entirely different. Detection requires monitoring clip-rule attribute mutations on clipPath children throughout the consent session lifecycle.
<!-- SA-CSS-CLR-002: dynamic nonzero-to-evenodd switch changes clip coverage -->
<svg id="consent-svg" width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Initially: simple rect clipPath, nonzero -- static scan sees clean clip -->
<clipPath id="cp">
<path id="cp-path" clip-rule="nonzero" d="M 0,0 H 440 V 200 H 0 Z"/>
</clipPath>
</defs>
<g clip-path="url(#cp)">
<text x="220" y="55" text-anchor="middle" font-size="14">
shell execution, credential access
</text>
<rect x="20" y="80" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</g>
</svg>
<script>
(function() {
// Fires after initial render -- replaces simple path with multi-ring + switches to evenodd
setTimeout(() => {
const cpPath = document.getElementById('cp-path');
cpPath.setAttribute('d', 'M 0,0 H 440 V 200 H 0 Z M 80,40 H 360 V 70 H 80 Z');
cpPath.setAttribute('clip-rule', 'evenodd'); // now creates hole over scope-word row
}, 200);
})();
</script>
// Detection: MutationObserver on clip-rule attribute of clipPath children
function watchClipRuleChanges(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'clip-rule' && mut.attributeName !== 'd') continue;
const target = mut.target;
if (!target.closest('clipPath')) continue;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CLR-002',
note: `clipPath child attribute "${mut.attributeName}" changed dynamically; ` +
`old="${mut.oldValue}" → new="${target.getAttribute(mut.attributeName)}"; ` +
`dynamic clip-rule or path change alters clip coverage after initial audit; ` +
`MutationObserver on clipPath children required`
});
}
});
for (const cpEl of svgEl.querySelectorAll('clipPath')) {
observer.observe(cpEl, { subtree: true, attributes: true, attributeOldValue: true });
}
return findings;
}
CRITICAL — SA-CSS-CLR-002: A dynamic script switches clip-rule from nonzero to evenodd and simultaneously replaces a simple path with a multi-ring path, changing clip coverage after the initial static audit. Detection requires MutationObserver on clipPath children watching for clip-rule and d attribute mutations.
Attack 3: clip-rule on <clipPath> child vs on the clipped element — two detection targets (SA-CSS-CLR-003)
The SVG specification allows clip-rule to be set in two places: on the shape children of a <clipPath> element (where it controls the winding rule for computing the clip region shape), or as a CSS property via a stylesheet applied to those children. An audit that only checks the clip-rule attribute on <clipPath> child elements will miss a clip-rule: evenodd rule applied via a <style> block or style attribute, because getAttribute('clip-rule') returns null for CSS-set values. The correct check is getComputedStyle(childElement).clipRule, which returns the resolved value regardless of whether it was set via attribute or stylesheet.
<!-- SA-CSS-CLR-003: clip-rule set via CSS stylesheet -- getAttribute returns null -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
/* clip-rule set via CSS -- getAttribute('clip-rule') returns null -- audit misses it */
.clip-path-shape { clip-rule: evenodd; }
</style>
<clipPath id="cp">
<!-- No clip-rule attribute here -- attribute scan returns null -- appears clean -->
<path class="clip-path-shape"
d="M 0,0 H 440 V 200 H 0 Z M 80,40 H 360 V 70 H 80 Z"/>
</clipPath>
</defs>
<g clip-path="url(#cp)">
<text x="220" y="55" text-anchor="middle" font-size="14">
shell execution, credential access
</text>
</g>
</svg>
// Detection: use getComputedStyle not getAttribute for clip-rule
function checkClipRuleComputed(svgEl) {
const findings = [];
for (const cpEl of svgEl.querySelectorAll('clipPath')) {
for (const child of cpEl.children) {
// WRONG: const rule = child.getAttribute('clip-rule') -- misses CSS-set rule
// CORRECT: use getComputedStyle
const rule = getComputedStyle(child).clipRule || 'nonzero';
if (rule !== 'evenodd') continue;
const d = child.getAttribute('d') || '';
const subPaths = (d.match(/M/gi) || []).length;
if (subPaths >= 2) {
const viaAttr = !!child.getAttribute('clip-rule');
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CLR-003',
note: `<clipPath#${cpEl.id}> child clip-rule resolved to "evenodd" ` +
`(set via ${viaAttr ? 'attribute' : 'CSS stylesheet'}); ` +
`getAttribute('clip-rule') = ${viaAttr ? '"evenodd"' : 'null — attribute scan misses this'}; ` +
`getComputedStyle.clipRule required; ${subPaths} sub-paths create holes`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-CLR-003: clip-rule set via a CSS stylesheet is invisible to getAttribute('clip-rule'). Audits must use getComputedStyle(clipPathChild).clipRule to resolve the effective rule. Combined with a multi-ring path, a CSS-set evenodd rule creates holes that attribute-only scanners cannot detect.
Attack 4: JS mousedown switches clip-rule to evenodd, reverts at mouseup (SA-CSS-CLR-004)
A JavaScript mousedown event listener calls clipPathChild.setAttribute('clip-rule', 'evenodd') on a <clipPath> child that already has a multi-ring path geometry. The path was designed so that under nonzero (at rest) the consent text is fully visible, but under evenodd (during the click window) the permission-scope words fall in holes and are hidden. A mouseup handler reverts to nonzero. The DOM shows clip-rule="nonzero" at rest and post-interaction; only during the mousedown-to-mouseup window is evenodd in effect. Static SVG analysis sees a multi-ring path with clip-rule="nonzero" — which is the safe default — and rates the clipPath as acceptable.
<!-- SA-CSS-CLR-004: mousedown switches clip-rule to evenodd, reverts at mouseup -->
<svg id="consent-svg" width="440" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp">
<!-- At rest: nonzero -- full rect covers panel -- everything visible -->
<!-- During click: evenodd -- inner ring creates hole over scope words -->
<path id="cp-shape" clip-rule="nonzero"
d="M 0,0 H 440 V 200 H 0 Z M 80,40 H 360 V 70 H 80 Z"/>
</clipPath>
</defs>
<g clip-path="url(#cp)">
<text x="220" y="55" text-anchor="middle" font-size="14">
shell execution, credential access
</text>
<rect x="20" y="80" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="100" text-anchor="middle" font-size="13" fill="white">Allow</text>
</g>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg');
const shape = document.getElementById('cp-shape');
svg.addEventListener('mousedown', () => shape.setAttribute('clip-rule', 'evenodd'), true);
svg.addEventListener('mouseup', () => shape.setAttribute('clip-rule', 'nonzero'), true);
})();
</script>
// Detection: MutationObserver on clip-rule of clipPath children during interaction
function watchClipRuleInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'clip-rule') continue;
if (!mut.target.closest('clipPath')) continue;
const newVal = mut.target.getAttribute('clip-rule');
if (newVal === 'evenodd') {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-CLR-004',
note: `clip-rule on clipPath child changed to "evenodd" dynamically; ` +
`old="${mut.oldValue}" → new="evenodd"; ` +
`inject-and-revert pattern switches clip coverage during click window; ` +
`static SVG sees clip-rule="nonzero" at rest; ` +
`MutationObserver on clipPath children during interaction simulation required`
});
}
}
});
for (const cpEl of svgEl.querySelectorAll('clipPath')) {
observer.observe(cpEl, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['clip-rule'] });
}
return findings;
}
CRITICAL — SA-CSS-CLR-004: JavaScript injects clip-rule="evenodd" at mousedown and reverts to nonzero at mouseup. The multi-ring path geometry was designed so evenodd holes land on permission-scope words. Static scan sees nonzero; during the click window, scope words are hidden. MutationObserver on clip-rule attribute of clipPath children required.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-CLR-001: evenodd holes in clipPath | clip-rule=evenodd on multi-ring clipPath path creates holes at even-winding regions; holes engineered to coincide with permission-scope words; textContent unchanged | textContent keyword check; BCR of clipped element unchanged; hole geometry requires SVG path winding analysis | Critical |
| SA-CSS-CLR-002: dynamic nonzero-to-evenodd switch | setTimeout/DOMContentLoaded replaces simple path and switches clip-rule to evenodd after initial render; initial scan sees clean nonzero clip | Static SVG scan at initial render; clip-rule and path both change dynamically; MutationObserver required | Critical |
| SA-CSS-CLR-003: clip-rule set via CSS stylesheet | clip-rule: evenodd in <style> block — getAttribute returns null; getComputedStyle returns evenodd | Attribute-only scan (getAttribute('clip-rule') = null); requires getComputedStyle.clipRule | Critical |
| SA-CSS-CLR-004: mousedown inject, mouseup revert | JS setAttribute('clip-rule','evenodd') at mousedown; reverts at mouseup; holes hide scope words during click window; DOM clean at rest | Static scan (nonzero at rest); post-interaction DOM snapshot; MutationObserver on clip-rule attr during interaction simulation required | Critical |