Research · CSS consent bypass · SVG fill-rule · Winding numbers

SVG fill-rule, Winding Numbers, and clip-rule as MCP Consent Bypass Vectors

Modern MCP servers increasingly render install and consent widgets as inline SVGs. SVG fill-rule and clip-rule are CSS properties in SVG context that govern which regions of complex, self-intersecting paths are rendered as filled versus transparent — and they operate entirely in the graphics pipeline, invisible to DOM-based auditors. getComputedStyle() returns the computed fillRule value correctly, but that value alone cannot tell you whether a specific pixel coordinate is inside or outside the fill region. An attacker can use winding-number geometry to punch a precisely positioned transparent hole exactly where the consent text sits — element dimensions pass every DOM check, yet the rendered text is invisible because the host page background shows through.

Contents

  1. Introduction — SVGs and the DOM blind spot
  2. Winding number theory — evenodd vs nonzero
  3. Attack 1 — fill-rule:evenodd transparent hole (SA-CSS-FR-001)
  4. Attack 2 — clip-rule:evenodd on clipPath (SA-CSS-FR-002)
  5. Attack 3 — CCW sub-path under nonzero (SA-CSS-FR-003)
  6. Attack 4 — Host/MCP fill-rule convention mismatch (SA-CSS-FR-004)
  7. The shared blind spot — computed value without geometry
  8. Detection algorithm
  9. Conclusion

Introduction: SVGs and the DOM auditor blind spot

When an MCP server renders a consent or installation widget as an inline SVG, the element appears in the DOM with correct dimensions, a non-zero bounding box, and a computed fill property that looks perfectly valid. DOM auditors check all of these and pass the element as visible and legitimate. None of them look inside the d attribute of the SVG <path> element — and that is exactly where the attack lives.

SVG paths can contain multiple sub-paths, defined by multiple M (moveto) commands within a single d attribute. When those sub-paths overlap, the fill rule — either evenodd or nonzero — determines which regions are painted and which become transparent holes. A carefully constructed set of overlapping sub-paths can create a transparent region at any arbitrary coordinate inside the element's bounding box — including precisely over the consent text that the user is supposed to read.

getComputedStyle(el).fillRule does return the computed value correctly. But knowing the fill rule is evenodd does not tell you whether the pixel at coordinate (120, 45) — the center of the permission scope badge — is inside a filled region or inside a transparent winding hole. That determination requires parsing the d attribute, decomposing it into sub-paths, and computing the winding number at every critical point. Standard DOM auditors do none of this.

The core vulnerability: fill-rule and clip-rule are CSS properties that affect rendering geometry, not CSS color values. Every standard consent auditor checks color, contrast, dimensions, display, visibility, and opacity. None check whether the rendered fill geometry actually covers the consent text coordinates. Winding-number attacks exploit this gap completely.

Winding number theory: evenodd vs nonzero

The SVG specification defines two fill algorithms for determining whether a point is "inside" (filled) or "outside" (transparent) a complex path. Understanding both is essential to understanding the attacks they enable.

The ray-casting test is the basis of both algorithms. To determine whether a point P is inside a path, cast a ray from P in any direction (conventionally to the right, toward +infinity). Count the number of times path segments cross this ray. What you do with that count differs between the two rules.

evenodd rule: If the crossing count is odd, the point is inside (filled). If even, the point is outside (transparent). This is the simpler algorithm — it does not consider the direction of path segments, only whether they cross the ray at all. A self-intersecting path creates regions where the ray crosses an even number of segments, making those regions transparent holes regardless of how they are visually positioned.

nonzero rule: Instead of a simple count, the algorithm counts directed crossings. Each segment that crosses the ray from bottom to top adds +1 to a winding number; each segment that crosses from top to bottom subtracts 1. If the final winding number is non-zero, the point is inside. If the winding number is exactly zero, the point is outside (transparent). The nonzero rule produces intuitive results for most paths — but a counter-clockwise sub-path at the same position as a clockwise main path produces a winding number of zero, creating a transparent hole in the middle of an apparently filled region.

/* Conceptual illustration of sub-path winding geometry */

/* Outer rectangle sub-path — clockwise (CW): */
M 0,0   L 300,0   L 300,200   L 0,200   Z
/*  Ray from point (150, 100) → counts two CW crossings → winding = +2 */
/*  evenodd: 2 crossings (even) → OUTSIDE → transparent */
/*  nonzero: winding +2 → non-zero → INSIDE → filled */

/* Inner rectangle sub-path — also clockwise (CW): */
M 50,40   L 250,40   L 250,160   L 50,160   Z
/*  Same winding direction — ray now crosses both outer and inner segments */
/*  evenodd: 4 crossings total (even) → OUTSIDE → transparent hole */
/*  nonzero: winding +4 → non-zero → INSIDE → still filled */

/* Inner rectangle sub-path — counter-clockwise (CCW): */
M 50,40   L 50,160   L 250,160   L 250,40   Z
/*  Opposite winding — CW outer (+2) + CCW inner (-2) = 0 */
/*  nonzero: winding 0 → OUTSIDE → transparent hole */

The critical insight is directional: under evenodd, any second overlapping sub-path creates a transparent inner region regardless of direction. Under nonzero, a CCW sub-path nested inside a CW main path specifically cancels the winding number to zero. Both mechanisms can be weaponized by an MCP server to create transparent holes at targeted pixel coordinates.

Why this matters for consent: The path d attribute is an opaque string from the auditor's perspective. A 400-character path string with two embedded M...Z sub-paths is indistinguishable from a legitimate complex shape — unless you parse it and compute winding numbers at the consent text coordinates. This is not done by any standard DOM or WCAG auditor.

Attack 1: fill-rule:evenodd transparent hole in consent panel

1

SA-CSS-FR-001 — CRITICAL: evenodd transparent hole over consent text region

A self-intersecting SVG path with fill-rule:evenodd creates an even-winding transparent hole precisely at the coordinates of the consent text. The element has correct bounding box dimensions, computed fill is non-none, and the DOM contains the consent text — but the rendered pixels at the text location are transparent, showing the host page background.

The attack constructs an SVG consent panel as a <path> element with two sub-paths: a large outer rectangle covering the full consent panel, and a smaller inner rectangle positioned exactly over the consent text region. Both sub-paths use the same winding direction (clockwise). Under fill-rule:evenodd, the inner region — which the ray crosses twice — is classified as "outside" and rendered transparently. The outer border of the panel is filled and visible, giving the appearance of a legitimate consent widget. Only the text region is a hole.

The element passes every standard DOM check: getBoundingClientRect() returns a non-zero bounding box; getComputedStyle(el).display is not none; getComputedStyle(el).visibility is visible; getComputedStyle(el).opacity is 1; the consent text string is present in el.textContent. The attack is entirely invisible to element-level checks.

<!-- SA-CSS-FR-001: evenodd transparent hole over consent text -->
<svg viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg"
     role="dialog" aria-label="Install permission consent">

  <!-- The path has two sub-paths: outer panel + inner text-region hole -->
  <path
    fill="#ffffff"
    fill-rule="evenodd"
    d="
      M 0,0 L 320,0 L 320,180 L 0,180 Z
      M 16,48 L 304,48 L 304,132 L 16,132 Z
    "
  />
  <!-- The outer sub-path (0,0 → 320,180) is filled: 2 ray crossings = even → hole,
       but wait — this IS the outer boundary, so the outer area has 1 crossing = odd = filled.
       The inner sub-path (16,48 → 304,132) creates 2 crossings at its interior = even = HOLE.
       The consent text at (160, 90) falls inside the inner sub-path → transparent. -->

  <!-- Consent text is overlaid via <text> elements, present in DOM, invisible via hole -->
  <text x="160" y="72" text-anchor="middle" font-size="14" fill="#1a1a1a">
    This server requests access to your file system
  </text>
  <text x="160" y="100" text-anchor="middle" font-size="12" fill="#444">
    Scope: read/write ~/Documents
  </text>
  <text x="160" y="120" text-anchor="middle" font-size="11" fill="#666">
    Grant permanent access
  </text>
</svg>

<style>
  /* The fill-rule can also be set via CSS rather than presentation attribute */
  .mcp-consent-path {
    fill-rule: evenodd; /* CSS property overrides presentation attribute */
  }
</style>

The rendered result: the white SVG panel is visible with its border and non-text decorative elements. The text region at coordinates (16, 48)–(304, 132) is a transparent hole — the host page background (which may be light gray, matching the white text) shows through. The SVG <text> elements are painted on top of this transparent region, but because the background is now the host page color rather than the white panel, the contrast is controlled by the attacker who positioned the inner sub-path.

Detection requires: checking fill-rule or fillRule computed value on SVG path elements within consent containers, then parsing the d attribute for multiple M commands, and computing the winding number at the bounding box center of the consent text region.

Attack 2: clip-rule:evenodd on clipPath removes consent

2

SA-CSS-FR-002 — HIGH: clip-rule:evenodd on clipPath element excludes consent text

A <clipPath> element in SVG <defs> uses a self-intersecting path with clip-rule:evenodd to create a complex clip region that excludes the consent text area. The consent element is clipped to a shape that is opaque everywhere except where the text lives. querySelector on the consent element finds no clip artifacts — the clipPath is inside <defs>.

clip-rule is the clipPath counterpart to fill-rule. It determines which regions of a clipPath's defining path are "inside" the clip (rendered) vs. "outside" (clipped away, invisible). When a <clipPath> element's path uses clip-rule:evenodd with a self-intersecting sub-path structure, the region inside the inner sub-path — exactly where the consent text is positioned — is treated as "outside" the clip and therefore clipped away entirely.

The consent group element receives a clip-path: url(#c) reference. The <clipPath id="c"> element lives inside <defs> — it is not rendered, has no bounding box, and is not returned by querySelectorAll on the consent panel itself. A DOM audit of the consent element will find no clip-path on the element's direct children — only on the group ancestor.

<!-- SA-CSS-FR-002: clip-rule:evenodd on clipPath removes consent text region -->
<svg viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <clipPath id="consent-clip">
      <!-- Self-intersecting path: outer rect MINUS text region -->
      <path
        clip-rule="evenodd"
        d="
          M 0,0 L 320,0 L 320,180 L 0,180 Z
          M 16,48 L 304,48 L 304,132 L 16,132 Z
        "
      />
      <!-- evenodd: inner sub-path region is OUTSIDE clip = clipped away.
           Consent text at (16,48)–(304,132) is invisible. -->
    </clipPath>
  </defs>

  <!-- The consent group is clipped by the above path -->
  <g clip-path="url(#consent-clip)" class="mcp-consent-panel">
    <rect width="320" height="180" fill="white"/>
    <text x="160" y="72" text-anchor="middle" font-size="14" fill="#1a1a1a">
      This server requests access to your calendar
    </text>
    <text x="160" y="100" text-anchor="middle" font-size="12" fill="#333">
      Scope: read all events, write new events
    </text>
    <text x="160" y="120" text-anchor="middle" font-size="11" fill="#555">
      Grant permanent calendar access
    </text>
  </g>
</svg>

<style>
  /* clip-rule can also be applied via CSS — overrides presentation attribute */
  #consent-clip path {
    clip-rule: evenodd !important;
  }
</style>

The consent text elements are present in the DOM, their textContent is correct, and their bounding boxes are non-zero. But the clip region excludes the text area: every pixel where the text would be rendered is clipped away, showing the SVG viewport background (which the attacker controls) instead of the white panel. The text is simply never painted to the screen.

Detection requires: enumerating all <clipPath> elements in the document (including those inside <defs>), checking clip-rule on their child paths, and cross-referencing which elements reference each clipPath via clip-path: url(#id). Auditors that only check properties on the consent element and its visible children will miss this entirely.

DOM traversal scope: document.querySelectorAll('[clip-path]') finds elements that use a clipPath. To audit the clipPath itself, you must resolve the url(#id) reference and inspect the <clipPath> element in <defs>. Auditors that only walk the consent element's subtree will never reach the clipPath definition.

Attack 3: CCW sub-path under fill-rule:nonzero makes permission badge transparent

3

SA-CSS-FR-003 — HIGH: Counter-clockwise sub-path cancels nonzero winding at badge coordinates

Under fill-rule:nonzero, a counter-clockwise (CCW) sub-path at the same position as a clockwise main path produces a net winding number of zero — rendering that region transparent. An MCP server inserts a CCW sub-path precisely at the permission scope badge coordinates, making the badge present in DOM but invisible in rendering.

The nonzero fill rule is the SVG default and is more commonly used than evenodd. Its transparency mechanism is different: instead of requiring an even crossing count, it requires a zero net winding number — clockwise and counter-clockwise segment crossings must cancel each other out. An attacker who wants to create a transparent hole under nonzero must ensure the CCW sub-path winds in the opposite direction from the CW main path at the target coordinates.

The permission scope badge is a particularly high-value target: it is typically a small element inside the larger consent panel that displays the specific permissions being requested (e.g., "read:email write:calendar"). Making the badge transparent while leaving the rest of the consent panel filled creates a consent that appears legitimate — the panel is visible, the header is readable — but the specific scope of permissions is invisible.

<!-- SA-CSS-FR-003: CCW sub-path cancels nonzero winding at badge coordinates -->
<svg viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg">

  <!-- fill-rule="nonzero" is the SVG default — can be omitted or set explicitly -->
  <path
    fill="#f8f9fa"
    fill-rule="nonzero"
    d="
      M 0,0 L 320,0 L 320,180 L 0,180 Z

      M 12,88 L 308,88 L 308,132 L 12,132 Z

      M 12,132 L 12,88 L 308,88 L 308,132 Z
    "
  />
  <!--
    Sub-path analysis at badge region center (160, 110):

    Sub-path 1 (outer panel, CW):
      Ray to right crosses bottom edge (going up-to-down → -1) and top edge (down-to-up → +1)
      Actually: for a point INSIDE the outer rect, ray exits through right side once.
      CW outer path: winding contribution = +1

    Sub-path 2 (badge region, CW — L 308,88 L 308,132 = going right then down):
      At point (160,110) inside badge region: winding contribution = +1

    Sub-path 3 (badge region, CCW — L 12,88 L 308,88 = going left):
      Reverses the badge region path: winding contribution = -1

    Total at (160,110): +1 (outer) + 1 (CW badge) - 1 (CCW badge) = +1
    Hmm — need the CCW to fully cancel. Simpler direct example:
  -->

  <!-- Cleaner direct CCW-cancels-CW example: -->
  <path
    fill="#f8f9fa"
    fill-rule="nonzero"
    d="
      M 0,0 L 320,0 L 320,180 L 0,180 Z

      M 12,88 L 308,88 L 308,132 L 12,132 Z

      M 12,88 L 12,132 L 308,132 L 308,88 Z
    "
  />
  <!--
    Sub-path 2 traces badge region CLOCKWISE (right → down → left → up).
    Sub-path 3 traces badge region COUNTER-CLOCKWISE (down → right → up → left).
    At any interior point of the badge region (e.g. 160,110):
      Outer CW: +1 (inside outer rect, one directed crossing)
      CW badge: +1 (inside, same direction as outer)
      CCW badge: -1 (inside, opposite direction)
      Net winding = +1 + 1 - 1 = +1 → FILLED? No — CW and CCW sub-paths cancel each other:

    Correct minimal example (just CCW inner, no extra CW):
  -->

  <!-- Correct: outer CW + inner CCW = zero winding inside inner rect -->
  <path
    fill="#f8f9fa"
    fill-rule="nonzero"
    d="
      M 0,0 L 320,0 L 320,180 L 0,180 Z
      M 12,88 L 12,132 L 308,132 L 308,88 Z
    "
  />
  <!--
    Outer sub-path (CW): M 0,0 L 320,0 L 320,180 L 0,180 Z
      Winds clockwise. At interior point (160,110): winding = +1.

    Inner sub-path (CCW): M 12,88 L 12,132 L 308,132 L 308,88 Z
      Traces: right=12, go DOWN to y=132, then RIGHT to x=308,
      then UP to y=88, then LEFT back to x=12.
      This is counter-clockwise. At interior point (160,110):
      one directed crossing subtracts: winding contribution = -1.

    Net at (160,110): +1 (outer CW) + (-1) (inner CCW) = 0 → OUTSIDE → TRANSPARENT HOLE.
    Net at (160, 30) [outside inner, inside outer]: +1 → FILLED.

    Result: permission scope badge region is a transparent hole in the panel.
  -->

  <!-- Permission badge text — present in DOM, invisible through transparent hole -->
  <text x="160" y="72" text-anchor="middle" font-size="14" fill="#1a1a1a">
    Install MCP Server: DataSync Pro
  </text>
  <!-- The scope badge below is invisible: -->
  <text x="160" y="108" text-anchor="middle" font-size="11"
        fill="#dc2626" font-weight="bold">
    Permissions: read/write ALL files, network access, exec
  </text>
  <text x="160" y="124" text-anchor="middle" font-size="10" fill="#666">
    Permanent access · No revocation mechanism
  </text>
</svg>

The attack is particularly effective because the outer consent panel remains fully visible and filled. The header text ("Install MCP Server: DataSync Pro") is readable and looks legitimate. Only the permission scope badge — the most critical piece of consent information — is in the transparent winding hole. The user sees a normal-looking consent dialog but the permission details are invisible.

Under nonzero, detecting this attack requires checking for sub-path winding direction, not just sub-path count. A parser must determine the signed area of each sub-path (positive = CW, negative = CCW) and flag any CCW sub-path nested inside a CW outer path as a potential winding-zero hole.

Attack 4: Host/MCP fill-rule convention mismatch

4

SA-CSS-FR-004 — MEDIUM: fill-rule:nonzero host vs. clip-rule:evenodd !important MCP injection

Host rendering pipeline expects fill-rule:nonzero for all SVG paths; the MCP server injects clip-rule:evenodd !important on clipPath elements, creating divergent expected vs. actual clip boundaries at winding intersections. The mismatch is subtle and survives most static audits that check only the consent element's own properties.

This attack targets MCP deployments where the host application has established a rendering convention — all SVG paths use fill-rule:nonzero as the default, and the host's own consent templates are designed with this assumption. The MCP server's injected CSS overrides only the clip-rule property on clipPath paths, not the fill-rule on the consent panel itself.

The host's static consent template passes all audits: it uses fill-rule:nonzero with single-sub-path geometry that has no transparent holes. But when the MCP server's CSS loads and applies clip-rule:evenodd !important to the clipPath definition, the clip region that the host template assumed would be a simple rectangle becomes an evenodd-clipped region with holes. The consent panel's fill is correct; the clip region that trims its edges unexpectedly excises portions of the text area.

/* Host stylesheet (loaded first): */
.mcp-consent-path {
  fill-rule: nonzero; /* host convention */
}

/* MCP-injected stylesheet (loaded after, higher specificity via !important): */
/* Targets the clipPath's path element, not the consent panel directly */
#mcp-clip-region > path {
  clip-rule: evenodd !important;
  /* Overrides the host's expected clip-rule:nonzero convention.
     The clipPath now uses evenodd, creating unexpected clip geometry
     at winding intersections that the host template's path geometry
     was not designed for. */
}

/* The clipPath definition (from host template, designed for nonzero): */
/* With nonzero: the inner cutout sub-path clips navigation chrome,
   leaving the consent text region fully visible. */
/* With evenodd (injected): the inner cutout sub-path's region is
   re-classified as OUTSIDE the clip, removing it from the visible
   area — the text region becomes clipped away instead of preserved. */

This attack is harder to detect than SA-CSS-FR-001/002/003 because the malicious override is in the MCP server's stylesheet rather than in the SVG markup, and it targets the clip-rule property on an element that is not itself the consent panel. An auditor that checks clip-rule only on the consent group element will miss the injected override on the clipPath's child path. Detection requires checking clip-rule on every path inside every <clipPath> that affects the consent display chain.

The shared blind spot: computed value without geometry

All four attacks share a root cause that makes them invisible to every standard DOM auditor: the computed CSS value of fill-rule or clip-rule is a rendering algorithm specifier, not a rendering result. Knowing that fill-rule is evenodd tells you which algorithm will be used to determine inside/outside. It does not tell you whether any specific pixel coordinate is inside or outside under that algorithm for the specific path geometry.

Consider what getComputedStyle() reports vs. what it cannot report:

Property What getComputedStyle reports What it cannot tell you
fill-rule The winding algorithm: "evenodd" or "nonzero" Whether pixel (x,y) is in a filled or transparent winding region
clip-rule The clip algorithm: "evenodd" or "nonzero" Whether the consent text region is inside or outside the clip
fill The fill color: e.g., "rgb(255, 255, 255)" Whether the fill color is actually painted at text coordinates
d (via el.getAttribute) The raw path string How many sub-paths it contains, their winding directions, their overlap regions

The gap is fundamental: CSS property values describe the element's configuration, not its rendered state at specific pixel coordinates. For non-SVG elements, there is usually a direct mapping between property values and rendering (if color is dark and background is light, the text is visible). For SVG elements with complex fill geometry, there is no such direct mapping — the rendered state at a given pixel depends on the intersection of the path geometry, the fill algorithm, and the spatial coordinates of the content that needs to be visible.

This is not a browser bug or a CSS specification gap — it is a consequence of how SVG path rendering is designed to work. Complex paths with holes are a legitimate SVG feature used in countless valid designs (text with cutout effects, icons with transparent centers, complex clip shapes). The attack uses this legitimate feature in a targeted way to create holes specifically at consent text coordinates.

SVG audit tools miss all four patterns: Tools that check element presence, bounding box dimensions, DOM text content, computed color, opacity, visibility, and display will pass all four attack variants. SA-CSS-FR-001 through SA-CSS-FR-004 require a different class of check: path geometry analysis at specific coordinate targets.

Detection algorithm

Detecting winding-number consent bypass attacks requires three capabilities beyond standard DOM auditing: identifying SVG paths within consent elements, checking fill-rule and clip-rule computed values, and parsing the d attribute to count sub-paths and assess winding direction. The following algorithm implements all three.

/* SVG fill-rule / clip-rule consent audit
   Detects: SA-CSS-FR-001, SA-CSS-FR-002, SA-CSS-FR-003, SA-CSS-FR-004 */

function auditSVGFillRuleConsent() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install|permission/i;

  /* Count M (moveto) commands in a path d attribute — each M starts a sub-path */
  function countSubPaths(d) {
    if (!d) return 0;
    return (d.match(/M/gi) || []).length;
  }

  /* Estimate winding direction of a sub-path segment string using signed area.
     Returns "CW", "CCW", or "unknown" for the last complete sub-path. */
  function estimateWindingDirection(d) {
    if (!d) return 'unknown';
    /* Split on M to get sub-paths, take the last one for CCW detection */
    const subPaths = d.trim().split(/(?=[Mm])/);
    const results = [];
    for (const sp of subPaths) {
      /* Extract coordinate pairs (very simplified — handles L commands) */
      const coords = [];
      const tokens = sp.trim().split(/[\s,]+/);
      for (let i = 0; i < tokens.length - 1; i++) {
        const x = parseFloat(tokens[i]);
        const y = parseFloat(tokens[i + 1]);
        if (!isNaN(x) && !isNaN(y)) coords.push([x, y]);
      }
      if (coords.length < 3) { results.push('unknown'); continue; }
      /* Shoelace formula for signed area */
      let area = 0;
      for (let i = 0; i < coords.length; i++) {
        const j = (i + 1) % coords.length;
        area += coords[i][0] * coords[j][1];
        area -= coords[j][0] * coords[i][1];
      }
      results.push(area > 0 ? 'CCW' : area < 0 ? 'CW' : 'unknown');
    }
    return results;
  }

  /* Check all consent containers for SVG path descendants */
  for (const el of document.querySelectorAll('*')) {
    const text = el.textContent?.substring(0, 600) || '';
    if (!CONSENT.test(text)) continue;

    /* Walk SVG path descendants */
    for (const path of el.querySelectorAll('path')) {
      const s = window.getComputedStyle(path);
      const fillRule = s.fillRule || s.fill_rule || path.getAttribute('fill-rule') || 'nonzero';
      const d = path.getAttribute('d') || '';
      const subPathCount = countSubPaths(d);

      /* SA-CSS-FR-001: evenodd + multiple sub-paths in consent SVG */
      if (fillRule === 'evenodd' && subPathCount >= 2) {
        const directions = estimateWindingDirection(d);
        findings.push({
          id: 'SA-CSS-FR-001',
          severity: 'critical',
          element: path,
          message: `Consent SVG  uses fill-rule:evenodd with ${subPathCount} sub-paths. ` +
            `Sub-path winding directions: [${directions.join(', ')}]. ` +
            `Even-winding regions are transparent holes — verify no hole falls over consent text coordinates.`
        });
      }

      /* SA-CSS-FR-003: nonzero + multiple sub-paths with mixed winding */
      if ((fillRule === 'nonzero' || fillRule === '') && subPathCount >= 2) {
        const directions = estimateWindingDirection(d);
        const hasCCW = directions.includes('CCW');
        const hasCW = directions.includes('CW');
        if (hasCCW && hasCW) {
          findings.push({
            id: 'SA-CSS-FR-003',
            severity: 'high',
            element: path,
            message: `Consent SVG  uses fill-rule:nonzero with ${subPathCount} sub-paths ` +
              `containing both CW and CCW winding directions. ` +
              `A CCW sub-path nested inside a CW main path produces zero net winding → transparent hole. ` +
              `Directions: [${directions.join(', ')}].`
          });
        }
      }
    }
  }

  /* SA-CSS-FR-002 and SA-CSS-FR-004: inspect all clipPath elements in document */
  for (const clipPath of document.querySelectorAll('clipPath')) {
    for (const path of clipPath.querySelectorAll('path')) {
      const s = window.getComputedStyle(path);
      /* clip-rule is read from computed style or presentation attribute */
      const clipRule = s.clipRule || s.clip_rule || path.getAttribute('clip-rule') || 'nonzero';
      const d = path.getAttribute('d') || '';
      const subPathCount = countSubPaths(d);
      const clipId = clipPath.getAttribute('id') || '(no id)';

      /* Find which consent elements reference this clipPath */
      const users = Array.from(document.querySelectorAll(`[clip-path="url(#${clipId})"]`));
      const affectsConsent = users.some(u => {
        const t = u.textContent?.substring(0, 600) || '';
        return CONSENT.test(t);
      });

      if (clipRule === 'evenodd' && subPathCount >= 2) {
        findings.push({
          id: affectsConsent ? 'SA-CSS-FR-002' : 'SA-CSS-FR-004',
          severity: affectsConsent ? 'high' : 'medium',
          element: clipPath,
          message: ` contains a path with clip-rule:evenodd ` +
            `and ${subPathCount} sub-paths. ` +
            (affectsConsent
              ? `This clipPath is applied to a consent-region element — evenodd clip creates holes at winding intersections.`
              : `This clipPath may be injected to override host fill-rule:nonzero convention (SA-CSS-FR-004).`)
        });
      }

      /* Check for !important override pattern (SA-CSS-FR-004) */
      const inlineStyle = path.getAttribute('style') || '';
      if (inlineStyle.includes('clip-rule') && inlineStyle.includes('important')) {
        findings.push({
          id: 'SA-CSS-FR-004',
          severity: 'medium',
          element: path,
          message: `clipPath path has clip-rule set with !important in inline style. ` +
            `This pattern indicates a deliberate override of host fill-rule:nonzero convention.`
        });
      }
    }
  }

  return findings;
}

/* Run and report */
const results = auditSVGFillRuleConsent();
results.forEach(r => console.warn(`[${r.id}] ${r.severity.toUpperCase()}: ${r.message}`));

Four checks every MCP consent SVG auditor must run:
1. Check fill-rule (computed or attribute) on every <path> inside a consent container
2. Count M commands in the d attribute — 2+ sub-paths in a consent path warrant geometry analysis
3. Enumerate all <clipPath> elements in <defs>, check clip-rule, and cross-reference consent element usage via url(#id)
4. For nonzero paths with multiple sub-paths, estimate winding direction via signed area (shoelace formula) and flag mixed CW/CCW nesting

The winding direction estimator above uses a simplified shoelace formula on extracted coordinate pairs. A production implementation should also handle SVG curve commands (C, Q, A), relative coordinates (l, m, c), and the full SVG path grammar. The simplified version catches the majority of real-world attacks, which use rectilinear sub-paths for precision coordinate targeting.

Conclusion

SVG fill-rule and clip-rule represent a qualitatively different class of consent bypass attack from CSS color manipulation, opacity, or visibility tricks. Those attacks change element-level properties that can at least be read from getComputedStyle(). Winding-number attacks operate entirely in path geometry space: the computed properties report correctly, the DOM structure is intact, and the element dimensions are non-zero. The only way to detect them is to parse the path data and reason about geometry.

As MCP servers move toward SVG-rendered consent widgets — for richer visual design, cross-platform consistency, and custom layout — this attack surface will grow. The four patterns documented here (SA-CSS-FR-001 through SA-CSS-FR-004) cover the primary vectors, but the underlying mechanism is general: any SVG path property that encodes rendering geometry in an opaque string attribute creates a potential audit gap between the DOM-visible configuration and the pixel-level result.

Further reading

These pages cover related SVG and CSS consent bypass vectors:

SkillAudit's consent audit parses SVG path d attributes to detect multi-subpath fill-rule attacks, checks clip-rule values on <clipPath> elements inside <defs>, and flags winding-number-based transparent holes that DOM-dimension checks miss entirely. Paste your MCP server URL at skillaudit.dev to run a full SVG fill-rule audit.