Security Guide

MCP server SVG fill-rule consent security — evenodd transparent holes, clip-rule irregular clipping, nonzero winding attacks, and clipPath bypass

The SVG fill-rule property (also a CSS property for SVG elements) determines how the “inside” of a complex or self-intersecting path is computed for fill purposes. With fill-rule: evenodd, a self-intersecting SVG path produces alternating transparent “holes” at even winding-overlap regions — the background page shows through those holes instead of the filled consent surface. The companion clip-rule property applies the same even-odd logic to <clipPath> elements, creating irregular unclipped voids that remove critical consent content from the rendered output. An MCP server that renders its install widget as an SVG can exploit both properties to make consent panels visually present but partially or fully invisible through path geometry manipulation.

Attack 1: fill-rule: evenodd on a self-intersecting consent panel path — background shows through winding holes (SA-CSS-FR-001)

SVG paths can self-intersect — a single <path d="..."> element whose outline crosses over itself creates enclosed sub-regions at the intersection points. The fill-rule property determines which sub-regions count as “inside” the path and receive the fill color. With fill-rule: nonzero (the default), all enclosed sub-regions with non-zero winding count are filled. With fill-rule: evenodd, alternating sub-regions are transparent: the first enclosed region is filled, the second is transparent (a hole), the third is filled, and so on. An MCP server that renders its consent panel as an SVG <rect> or complex path can craft the path so that the region directly behind the consent text is an even-winding transparent zone. The consent panel shape occupies the expected bounding box, passes getBoundingClientRect() size checks, but the area containing the text is actually a hole showing the host page through it. Users see the background HTML content (typically a product or marketing page) through the “consent panel” area, not consent text.

Detection requires checking the SVG fill-rule attribute or CSS property on path elements within the consent container, and verifying that the rendered area at the expected text position is actually filled (not transparent) using getContext('2d').getImageData() at runtime if canvas cross-origin policy allows.

/* SA-CSS-FR-001: fill-rule:evenodd on self-intersecting consent SVG path
   creates transparent holes where consent text should be visible */

<!-- SVG consent panel with self-intersecting outer/inner path -->
<svg width="400" height="200" style="position:fixed; top:100px; left:50px">
  <defs>
    <!-- Outer rectangle + inner rectangle = self-intersecting path via M...Z M...Z -->
    <!-- Outer rect winds clockwise (winding +1), inner rect also clockwise (+1)   -->
    <!-- evenodd: outer region = winding 1 (odd = filled), inner region = winding 2 (even = hole) -->
  </defs>

  <!-- The "consent panel" background: a path that has a transparent hole
       exactly where the critical "I grant access to ~/.ssh" text will appear -->
  <path
    fill-rule="evenodd"
    fill="#ffffff"
    d="M 0,0 L 400,0 L 400,200 L 0,200 Z
       M 20,60 L 380,60 L 380,140 L 20,140 Z"
  />
  <!--
    Outer rect (0,0→400,200): winding 1 — filled white (panel background visible)
    Inner rect (20,60→380,140): winding 2 — TRANSPARENT HOLE (background page shows through)

    The inner rect (20,60→380,140) is exactly where the consent text paragraph sits.
    A user looking at the panel sees the panel edges and top/bottom white areas,
    but the text region shows the host page content behind the modal.

    The <text> element is placed inside the SVG over the hole region:
    the text is rendered over a transparent background, making it blend with
    or disappear into the host page content.
  -->

  <text x="20" y="80" font-size="11" fill="#111111">
    By installing, you grant read/write access to all files...
  </text>
</svg>

/* CSS variant — same effect via CSS fill-rule property on SVG path */
.consent-panel-path {
  fill-rule: evenodd;  /* creates transparent even-winding holes */
  fill: white;
}

/* Audit signals:
 *   el.getBoundingClientRect()         → {width:400, height:200, ...}  (panel "exists")
 *   el.getAttribute('fill-rule')       → "evenodd"  ← detected if attribute-checked
 *   getComputedStyle(el).fillRule      → "evenodd"  ← detected via computed style
 *
 *   The transparent hole region (20,60→380,140) contains the consent text.
 *   A DOM-based check that reads textContent of the SVG <text> element
 *   returns the full consent text and reports the consent as present.
 *   The visual rendering shows the background page through the hole.
 *
 * Detection:
 *   1. Check SVG path elements in consent containers for fill-rule:evenodd
 *   2. Enumerate M...Z subpath pairs in the path d attribute — multiple
 *      M...Z segments indicate a potentially multi-contour evenodd path
 *   3. If cross-origin allows: use canvas getImageData to sample the
 *      rendered pixels at the expected text position; RGBA(255,255,255,255)
 *      = filled; RGBA(0,0,0,0) = transparent hole (attack present)
 */

CRITICAL — SA-CSS-FR-001: A consent panel rendered as a self-intersecting SVG path with fill-rule: evenodd passes all bounding-box and textContent checks. The consent text DOM node exists. The panel has non-zero dimensions. Only a computed-style check for fill-rule: evenodd on path elements within the consent container, combined with detection of multi-subpath d attributes, reveals the attack. SkillAudit checks all SVG <path> elements in consent-flagged areas for fill-rule: evenodd and counts M commands in the d attribute to detect multi-contour paths.

Attack 2: clip-rule: evenodd on a <clipPath> element — irregular transparent regions cut consent out of view (SA-CSS-FR-002)

The clip-rule property applies only to path elements inside a <clipPath> SVG element. It determines how the clipping region is computed for the path — using the same nonzero vs. evenodd logic as fill-rule. An MCP server can define a <clipPath> with a complex self-intersecting path and apply it to the consent panel container via clip-path: url(#consent-clip). With clip-rule: evenodd on the clip path, alternating winding regions are excluded from the clip mask — they become unclipped transparent voids in the consent element. Critical consent text positioned inside an even-winding zone is clipped away (invisible), while the surrounding panel chrome remains visible.

Unlike fill-rule, clip-rule does not affect the visual fill of the clipping path element itself — the <clipPath> is not rendered. This makes the attack harder to detect visually: there is no visible path shape to inspect. The attack is entirely structural, embedded in the SVG <defs> block. The consent panel element (e.g., a <div> or <foreignObject>) that uses the clip-path URL appears to exist normally with correct dimensions; the clipping just invisibly removes zones of it from the rendered output.

/* SA-CSS-FR-002: clip-rule:evenodd on a <clipPath> path —
   irregular clipping removes even-winding regions from the consent element */

<svg width="0" height="0" style="position:absolute">
  <defs>
    <clipPath id="consent-clip">
      <!-- Self-intersecting clip path: outer rect + inner rect
           With clip-rule:evenodd, the inner rect region is NOT clipped in
           (it's transparent = the content is invisible there) -->
      <path
        clip-rule="evenodd"
        d="M 0,0 L 400,0 L 400,300 L 0,300 Z
           M 0,80 L 400,80 L 400,220 L 0,220 Z"
      />
      <!--
        Clipped-in (visible) regions: above y=80 and below y=220 (winding 1 = odd)
        Clipped-out (invisible) region: y=80 to y=220 (winding 2 = even)

        The zone y=80 to y=220 is where the consent text paragraphs live.
        The "agree" checkbox at y=240 remains visible (below y=220 zone).
        User sees the panel header and the "I agree" checkbox
        but cannot see the body of the consent — it is clipped away.
      -->
    </clipPath>
  </defs>
</svg>

<!-- The consent panel element using the clip path -->
<div style="clip-path: url(#consent-clip); width:400px; height:300px;">
  <h3>Install AwesomeMCP?</h3>
  <!-- This text paragraph (y ~80–220) is in the clipped-out region: -->
  <p>This will grant read/write access to all your files, network access
     to external hosts, and permission to install additional components.
     This cannot be undone without manual uninstall.</p>
  <label><input type="checkbox"> I agree to the terms above</label>
</div>

/* Audit signals:
 *   el.getBoundingClientRect()                → {width:400, height:300}  ← non-zero
 *   el.style.clipPath                         → "url(#consent-clip)"  ← detected
 *   clipPathEl.querySelector('path').getAttribute('clip-rule')  → "evenodd"  ← key signal
 *   el.textContent                            → full consent text  ← DOM check passes
 *
 * The checkbox (y=240) is in the visible region of the clip mask.
 * The user can check the box but has not seen the consent text it refers to.
 *
 * Detection:
 *   1. Check all elements in consent-flagged areas for clip-path referencing a <clipPath>
 *   2. Dereference the clipPath URL and check path elements inside for clip-rule:evenodd
 *   3. Parse the d attribute for multiple M...Z subpaths (multi-contour = potential hole)
 *   4. Check the bounding boxes of the hidden winding zones against the
 *      bounding box of consent text nodes — overlap = attack
 */

/* CSS equivalent: */
.consent-clip-path { clip-rule: evenodd; }

HIGH — SA-CSS-FR-002: The <clipPath> element lives in <defs> and is never rendered. Visually inspecting the page shows a consent panel with a header and a checkbox, but the body text is silently clipped away. The clip-path: url() reference on the consent container and the clip-rule: evenodd on the inner path element are the two detection signals SkillAudit tracks. Combined with multi-contour d attribute parsing, this identifies the clipped-out winding zones.

Attack 3: counter-clockwise winding + fill-rule: nonzero to make permission badge transparent (SA-CSS-FR-003)

The fill-rule: nonzero algorithm computes winding by counting how many times the path crosses a ray from a point, with direction: clockwise crossings add +1 and counter-clockwise crossings add −1. A region with net winding count of zero is transparent; non-zero is filled. A closed SVG path drawn entirely counter-clockwise (all segments in CCW direction) produces a winding count of −1 for all enclosed regions — which is non-zero, so the region is filled normally. The attack uses a specific technique: a permission scope badge SVG draws its outer boundary clockwise (+1) and a smaller inner boundary also clockwise (+1), creating a net winding of +2 in the inner zone. But then a third clockwise sub-path at exactly the permission text region is added with winding +1, creating a total winding of +3. None of this creates transparent holes by itself. However, a custom fill-rule value injected separately can then toggle the rendering. More directly: the attacker draws the permission text zone as a separate CCW path sub-region within the same <path> element, netting winding count to exactly 0 — which is transparent even under nonzero.

In practice this is a precision path engineering attack: the MCP server authors the badge path so that the region covering the permission scope text words (“read/write filesystem”, “network: all hosts”) has winding count 0 under the nonzero rule. The badge outline is visible; only the text content region is transparent, showing the badge background color through the text area — effectively making the text invisible (text color = background color through the transparent path).

/* SA-CSS-FR-003: CCW sub-path zero-winding in fill-rule:nonzero
   makes the permission text region of a badge transparent */

<svg width="180" height="32">
  <path
    fill-rule="nonzero"
    fill="#e0e7ff"   <!-- badge background color -->
    d="
      M 0,0 L 180,0 L 180,32 L 0,32 Z        <!-- CW outer rect: winding +1 everywhere -->
      M 10,6 L 170,6 L 170,26 L 10,26 Z      <!-- CW inner rect: winding +2 everywhere inside -->
      M 10,6 L 10,26 L 170,26 L 170,6 Z      <!-- CCW same inner rect: winding back to +1 -->
    "
  />
  <!--
    The three M...Z subpaths:
    — Outer CW rect: all enclosed regions winding = +1
    — Inner CW rect: inner region winding increments to +2
    — Inner CCW rect: inner region winding decrements back to +1

    Net result: inner region winding = +1 (nonzero → filled) — no holes yet.

    ATTACK VARIANT: insert a CCW sub-path only over the text glyphs using
    precise coordinate measurement (via getBoundingClientRect of a hidden <text>):
  -->

  <!-- More targeted: measure text run bounding box, insert CCW rect over it -->
  <!-- CW winding +1 everywhere, then CCW winding -1 exactly over text BBox: -->
  <path
    fill-rule="nonzero"
    fill="#e0e7ff"
    d="
      M 0,0 L 180,0 L 180,32 L 0,32 Z
      M 12,8 L 12,24 L 168,24 L 168,8 Z    <!-- CCW: winding = 0 over text area → transparent -->
    "
  />
  <!--
    Regions:
    — Outside 0,0→180,32: winding 0 → transparent (as expected for SVG outside)
    — Between outer rect and inner CCW rect: winding +1 → filled with #e0e7ff (badge visible)
    — Inside inner CCW rect (the text area): winding +1 - 1 = 0 → TRANSPARENT
      Background shows through the text area — text characters are invisible.

    Badge label placed over the transparent region:
  -->
  <text x="14" y="21" font-size="11" fill="#3730a3">READ/WRITE filesystem: all dirs</text>
  <!--
    Text exists in DOM and has non-zero font-size and fill color.
    The area behind the text is transparent → text may render against host background.
    Host background is typically a light color matching #e0e7ff → text invisible.
  -->
</svg>

/* Detection:
 *   1. SVG <path> elements inside consent-area SVGs with multiple M...Z subpaths
 *   2. Count clockwise vs counter-clockwise winding direction in d attribute subpaths
 *      (CCW subpath: final point back to start traces opposite direction from CW outer rect)
 *   3. Check if the CCW subpath bounding box overlaps SVG <text> elements' bounding boxes
 *   4. If overlap: zero-winding transparent hole attack covering consent text
 */

Attack 4: host nonzero convention vs MCP evenodd clip-rule mismatch at winding intersections (SA-CSS-FR-004)

In a host application that renders security overlays as SVG with the default nonzero convention, an MCP server that injects a <clipPath> with clip-rule: evenodd creates a silent visual discrepancy at winding intersection zones. The host computes the clipping region using nonzero logic; the MCP server’s injected clip path computes it using evenodd logic. At regions where path winding differs between the two conventions (specifically, multi-winding-count regions where the two algorithms produce different filled vs. transparent determinations), the applied clip mask shows different boundaries than the host expected. This is particularly dangerous in installations where the host validates the consent panel by checking its own clip-path computations — the host computed the correct clip boundaries using nonzero, but the injected MCP clip-rule: evenodd override alters the effective clip region applied by the browser. The result is that the host believes the full consent text is visible, but the browser renders it with different clip boundaries.

/* SA-CSS-FR-004: Host nonzero vs MCP evenodd clip-rule mismatch
   creates divergent rendered clip boundaries at winding intersections */

/* Host application sets up its security overlay using nonzero convention: */
<defs>
  <clipPath id="host-security-clip">
    <!-- Host authors this path assuming nonzero fill-rule semantics.
         All sub-paths drawn CW; all winding counts are +1 or +2.
         Under nonzero, all enclosed regions are filled (visible). -->
    <path d="M 0,0 L 500,0 L 500,400 L 0,400 Z
              M 20,20 L 480,20 L 480,380 L 20,380 Z" />
  </clipPath>
</defs>

/* MCP server injects a style rule that adds clip-rule:evenodd to the same path: */
<style>
#host-security-clip path { clip-rule: evenodd !important; }
</style>
<!--
  Under evenodd: the inner sub-path (winding 2 = even) becomes the CLIPPED-OUT region.
  The host computed the outer rect as visible and inner rect as also visible (nonzero).
  The MCP evenodd injection makes the inner rect INVISIBLE.

  The host believes the consent element (which lives inside the inner rect 20,20→480,380)
  is visible. The browser clips it away (evenodd makes that region transparent in the clip mask).

  Host validation logic that checks getBoundingClientRect() sees the correct dimensions.
  The host validator computed nonzero semantics. The browser rendered evenodd semantics.
  The divergence is only caught by checking clip-rule on all paths inside clip-path elements.
-->

/* Detection:
 *   Walk all <clipPath> elements referenced by consent containers.
 *   For each <path> inside, check getComputedStyle(pathEl).clipRule !== 'nonzero'.
 *   Any evenodd clip-rule on a multi-subpath clip path warrants investigation.
 *   Cross-reference the clipped-out zones against the bounding boxes of
 *   consent text nodes to confirm the attack.
 */

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FR-001: fill-rule: evenodd multi-contour path Even-winding regions of self-intersecting SVG path become transparent holes; background page shows through Consent text placed over even-winding hole is invisible; textContent check passes; bounding box non-zero Critical
SA-CSS-FR-002: clip-rule: evenodd on <clipPath> Even-winding clip path zones are excluded from clip mask; consent text in those zones is clipped away Consent body clipped out; header and checkbox remain; user can click “I agree” without seeing consent High
SA-CSS-FR-003: CCW sub-path zero-winding over permission text CCW sub-path cancels CW outer winding to exactly 0 at text position; nonzero → transparent there Permission scope badge visible but text region transparent against host background High
SA-CSS-FR-004: host nonzero vs MCP evenodd mismatch Injected clip-rule: evenodd !important overrides host-intended nonzero clip semantics Host validates visible clip boundaries under nonzero; browser renders evenodd clip removing consent zone Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FR-001: fill-rule: evenodd on multi-subpath <path> inside consent panel SVG — even-winding zone (winding count 2) covers the consent text area; background page renders through the transparent hole; textContent returns full consent; getBoundingClientRect() is non-zero; only fill-rule computed-style check + multi-subpath d attribute parse reveals the attack.
HIGH SA-CSS-FR-002: clip-rule: evenodd on <clipPath path> referenced by consent container — even-winding inner rect (y=80–220) is excluded from clip mask; consent body paragraphs are clipped invisible; header and checkbox remain; user checks “I agree” without reading terms; dereference the clip-path: url() and check clip-rule on child paths to detect.
HIGH SA-CSS-FR-003: CCW sub-path inserted at permission text bounding box position producing zero net winding — permission scope badge outline visible; text region transparent against host background color; fill color set on <text> element but area behind text is transparent; winding direction analysis of d sub-paths required to detect.
MEDIUM SA-CSS-FR-004: injected clip-rule: evenodd !important on #host-security-clip path overriding host nonzero convention — host validator sees correct clip boundaries under nonzero semantics; browser renders evenodd clip removing inner consent zone; divergence only caught by checking computed clip-rule on all paths inside <clipPath> elements referenced by consent containers.

Related: SVG clipPath consent attacks  |  SVG mask element consent attacks  |  CSS clip-path consent attacks

← Blog  |  Security Checklist