← Blog

SVG Filter Coordinate Attacks on MCP Consent Dialogs

Five position-aware SVG filter attack families — asymmetric Gaussian blur, SourceAlpha overlays, composite knockouts, filter canvas clipping, and per-primitive subregion targeting — exploit the spatial relationship between filter coordinate systems and element bounding boxes to invisibly erase MCP consent text. Detection requires spatial reasoning about filter regions, not just attribute value thresholds.

By SkillAudit  |  29 Oct 2026  |  22 min read

Most SVG filter detection rules work on individual primitive attributes in isolation: is stdDeviation above some threshold? Is opacity below 0.5? Is a flood-color suspicious? These scalar checks are easy to write and pass most unit tests. They fail against the class of attacks described in this post.

The five attack families covered here — SA-CSS-GBA, SA-CSS-GBS, SA-CSS-COU, SA-CSS-FUO, and SA-CSS-PUO — are unified by a single theme: they all exploit the spatial relationship between filter coordinates and element bounds. A two-value stdDeviation can blur one axis into illegibility while keeping the other axis’s value below a numeric threshold. A white overlay can be positioned with sub-pixel precision over exactly the consent text area by using the text’s own alpha channel as a mask. A filter canvas can be defined so it clips everything outside a 0.1%×0.1% region at the element center, making the element invisible while still intercepting clicks. None of these attacks are detectable without modeling the spatial relationship between the filter’s coordinate system, the filter region, and the element’s rendered geometry.

Background: SVG filter coordinate spaces

SVG filters operate in two nested coordinate systems. The outer system, controlled by filterUnits, defines the filter canvas — the rectangular region in which the filter effect is computed. The inner system, controlled by primitiveUnits, defines the coordinate frame used for individual primitive attributes like x, y, width, height, and stdDeviation subregions.

By default both attributes use userSpaceOnUse, which expresses coordinates in the user coordinate system of the element that references the filter. When set to objectBoundingBox, coordinates are expressed as fractions of the element’s bounding box: x="0.5" means “at 50% of the element’s width from its left edge.” These two systems can be mixed — filterUnits=objectBoundingBox with primitiveUnits=userSpaceOnUse is the default, while filterUnits=userSpaceOnUse with primitiveUnits=objectBoundingBox is an unusual combination that can produce surprising results.

The key insight: the default filter canvas is larger than the element. The SVG spec defaults are x="-10%" y="-10%" width="120%" height="120%" (in objectBoundingBox fractions), giving a 10% margin around the element on all sides to accommodate blur spreading. An attacker can override these defaults to either shrink the canvas to near-zero (clipping everything) or displace it entirely off the element (clipping everything by a different mechanism).

SA-CSS-GBA: asymmetric feGaussianBlur stdDeviation

The SVG feGaussianBlur primitive accepts a two-value stdDeviation attribute: stdDeviation="σX σY". When two values are supplied, σX controls the horizontal Gaussian spread and σY controls the vertical spread independently. This decoupled form enables attacks that evade any detection rule that reads stdDeviation as a single token.

/* SA-CSS-GBA-001: stdDeviation="0 8" — vertical-only blur */
<filter id="gba001">
  <feGaussianBlur stdDeviation="0 8"/>
</filter>

/* Effect:
   σX = 0  → zero horizontal spreading; character outlines intact
   σY = 8  → 8px vertical Gaussian kernel; strokes spread ±24px vertically
   Multi-line consent text: descenders of line N overlap ascenders of line N+1
   Single-line text: stroke centers remain but vertical resolution is lost
   WCAG contrast at stroke center: ~2.63:1 (AA FAIL for normal text requires 4.5:1)
   WCAG at stroke edge: ~1.50:1

   CSSOM detection:
   getComputedStyle(span).filter → "url(#gba001)"
   SVGFEGaussianBlurElement.stdDeviationX.baseVal → 0   ← a naive "σ≥1" check PASSES
   SVGFEGaussianBlurElement.stdDeviationY.baseVal → 8   ← requires reading BOTH axes */

/* SA-CSS-GBA-003: stdDeviation="0.5 4" — evasion of multi-threshold rules */
/* max(σX,σY) = 4, evades "≥5" threshold rules */
/* σX = 0.5, evades "≥1 on either axis" rules */
/* σY = 4 still produces WCAG 2.10:1 fail on standard 16px consent text */

The critical detection requirement: any check for feGaussianBlur must read both stdDeviationX and stdDeviationY independently via their IDL properties. Reading stdDeviation as a raw attribute string and parsing it as a single float will either return the first value (missing a large Y) or return NaN (if the string contains a space). The safe approach is:

function checkGaussianBlur(feGB) {
  const sigX = feGB.stdDeviationX.baseVal;
  const sigY = feGB.stdDeviationY.baseVal;
  /* Flag if EITHER axis exceeds threshold */
  if (sigX >= 1 || sigY >= 1) {
    return {
      risk: sigX >= 3 || sigY >= 3 ? 'CRITICAL' : 'HIGH',
      sigX, sigY,
      type: 'asymmetric-gaussian-blur'
    };
  }
  return null;
}

SA-CSS-GBA-004 adds a JavaScript-based dynamic variant: the filter starts with stdDeviation="0 0.1" (visually identical to unfiltered text, below any reasonable threshold) and transitions to stdDeviation="0 8" on mousedown. The attack fires precisely at the moment the user initiates a click on the consent button, creating a TOCTOU window between the pre-click visible state and the post-mousedown blurred state. MutationObserver on stdDeviation attribute changes on all filter primitives in consent ancestors is required.

SA-CSS-GBS: feGaussianBlur in=SourceAlpha — alpha mask precision targeting

The in="SourceAlpha" input to feGaussianBlur uses only the alpha channel of the source element’s rendering as the filter input, discarding all color information. For a text span, SourceAlpha is a binary mask: A=1 at every glyph pixel, A=0 at every non-glyph pixel. This mask encodes the exact spatial position of every character stroke at sub-pixel precision.

Why SourceAlpha enables precision attacks: When a malicious filter uses feGaussianBlur in="SourceAlpha", it gains exact knowledge of where every consent text glyph pixel is located. A subsequent feFlood + feComposite chain can place a white overlay positioned precisely over the glyph area, using the alpha mask to determine coverage. The result is a white overlay that matches the text’s own glyph geometry with pixel-level accuracy — contrast 1.00:1 while CSSOM reports the original dark text color unchanged.

/* SA-CSS-GBS-001: 4-primitive SourceAlpha whiteout chain */
<filter id="gbs001" color-interpolation-filters="sRGB">
  <!-- Step 1: blur the alpha mask of the consent text -->
  <feGaussianBlur in="SourceAlpha" stdDeviation="8" result="blurredMask"/>

  <!-- Step 2: generate opaque white flood -->
  <feFlood flood-color="white" flood-opacity="1" result="whiteFill"/>

  <!-- Step 3: composite white onto blurred alpha mask
       operator="in" at glyph area: white × blurredMask_alpha = white (opaque)
       operator="in" outside text:  white × 0 = transparent -->
  <feComposite in="whiteFill" in2="blurredMask" operator="in" result="overlay"/>

  <!-- Step 4: blend overlay over the original consent text -->
  <feComposite in="overlay" in2="SourceGraphic" operator="over"/>
</filter>

/* Effect: white overlay covers text area with Gaussian soft edge.
   WCAG: 1.00:1 at glyph center, rising to ~1.09:1 at 3σ boundary.
   CSSOM: getComputedStyle(span).color → original dark color.
   getComputedStyle(span).opacity → "1".
   filter: "url(#gbs001)" — requires parsing the filter's in= attributes */

SA-CSS-GBS-002 substitutes feBlend mode="normal" for the final feComposite over, producing a light-gray fog (L=0.90) instead of pure white. This evades detection rules that specifically flag feFlood flood-color="white" followed by feComposite. SA-CSS-GBS-003 replaces the Gaussian blur with a tight stdDeviation=2 combined with feFuncA discrete tableValues="0 1" binarization, producing a glyph-exact (rather than soft-edged) white cover at 92% opacity — WCAG 1.08:1. The binarized form is visually indistinguishable from the Gaussian form but uses a completely different primitive sequence.

The detection requirement for SA-CSS-GBS is that any filter containing feGaussianBlur in="SourceAlpha" feeding into a feFlood+feComposite chain must be flagged. The in="SourceAlpha" attribute on a blur is a strong indicator because legitimate blur effects almost always use in="SourceGraphic" (or default to it). The combination of SourceAlpha blur + feFlood + feComposite applied to a consent ancestor has no benign explanation.

SA-CSS-COU: feComposite operator=out — alpha-knockout consent text erasure

The Porter-Duff out compositing operator computes:

result_color = in_color × in_alpha × (1 − in2_alpha)
result_alpha = in_alpha × (1 − in2_alpha)

The term (1 − in2_alpha) is zero wherever in2 is opaque. For consent text, SourceGraphic is opaque (A=1) at every glyph pixel. When in2 is set to SourceGraphic and in is set to a white flood fill, every glyph pixel computes white × 1 × (1 − 1) = 0 — transparent. Non-glyph pixels (A=0) compute white × 1 × (1 − 0) = white. The filter output is a white rectangle with glyph-shaped transparency holes. Over a white background: every pixel appears white. Contrast 1.00:1.

CSSOM is completely blind to the knockout: getComputedStyle(span).color returns the original dark text color. getComputedStyle(span).opacity returns "1". The alpha-knockout is applied entirely in the SVG rendering pipeline after CSSOM layout. Detectors relying on computed style properties report zero findings.

SA-CSS-COU-002 escalates to full container erasure: when the filter is applied to the entire consent container (a <div> with opaque background), SourceAlpha is A=1 at every rendered pixel of the container. Feeding a white flood into feComposite operator="out" in2="SourceAlpha" computes white × (1 − 1) = 0 at every pixel — the entire consent container becomes transparent. The invisible container still intercepts pointer events. SA-CSS-COU-004 triggers this via a JavaScript mousedown transition from operator="over" (identity compositing) to operator="out" at the exact moment of click confirmation.

SA-CSS-FUO: filterUnits=objectBoundingBox — filter canvas clipping

When filterUnits="objectBoundingBox" (the default), the filter canvas is specified as fractions of the element’s bounding box. The SVG spec defaults produce a canvas 20% wider and 20% taller than the element (the “bleed margin” needed for blur spreading). An attacker can override these defaults to produce zero-area or off-element canvases that clip all rendering.

/* SA-CSS-FUO-001: near-zero filter canvas at element center */
<filter id="fuo001" filterUnits="objectBoundingBox"
        x="0.5" y="0.5" width="0.001" height="0.001">
  <feGaussianBlur stdDeviation="0"/>
</filter>

/* Effect:
   Filter canvas = center point of element, 0.1% × 0.1% of bounding box.
   SVG renderer clips all filter output to this region.
   The consent element is rendered as a tiny dot at its center.
   All text content falls outside the filter canvas → clipped to nothing.
   The element still occupies its layout box (width/height unchanged).
   The invisible element intercepts click events normally.
   WCAG: 0.00:1 (no pixels rendered outside the 0.1% dot).

   Detection: compute intersection of filter canvas with element bounding box.
   Required condition: filterCanvas.intersects(elementBBox) AND
                       intersection.area / elementBBox.area >= MIN_RATIO */

/* SA-CSS-FUO-002: filter canvas displaced off-element entirely */
<filter id="fuo002" filterUnits="objectBoundingBox"
        x="2.0" y="0" width="1.0" height="1.0">
  <feGaussianBlur stdDeviation="0"/>
</filter>

/* Filter canvas starts at 2× the element width to the right —
   completely outside the element bounding box.
   Zero intersection. Element renders as nothing. */

SA-CSS-FUO-003 demonstrates a more targeted attack: y="-0.1" height="0.45" renders only the top 45% of the element. For a multi-line consent dialog where permission details appear in the lower lines, the visible portion shows only the bland header text while the specific permissions (file access, network calls, secret access) are clipped out of the filter canvas. The user sees truncated content but the element is partially visible, making the attack harder to detect visually.

Detection requires computing the actual filter canvas rectangle in element-local coordinates and checking what fraction of the element bounding box it covers:

function checkFilterCanvas(filterEl, elementBBox) {
  const x = parseFloat(filterEl.getAttribute('x') ?? '-0.1');
  const y = parseFloat(filterEl.getAttribute('y') ?? '-0.1');
  const w = parseFloat(filterEl.getAttribute('width') ?? '1.2');
  const h = parseFloat(filterEl.getAttribute('height') ?? '1.2');
  const units = filterEl.getAttribute('filterUnits') ?? 'objectBoundingBox';

  let canvasRect;
  if (units === 'objectBoundingBox') {
    /* Convert fractions to absolute coordinates */
    canvasRect = {
      left:   elementBBox.left   + x * elementBBox.width,
      top:    elementBBox.top    + y * elementBBox.height,
      right:  elementBBox.left   + (x + w) * elementBBox.width,
      bottom: elementBBox.top    + (y + h) * elementBBox.height
    };
  } else {
    /* userSpaceOnUse: x/y are absolute in user coordinate system */
    canvasRect = { left: x, top: y, right: x + w, bottom: y + h };
  }

  /* Compute intersection coverage */
  const ix = Math.max(canvasRect.left, elementBBox.left);
  const iy = Math.max(canvasRect.top, elementBBox.top);
  const iw = Math.min(canvasRect.right, elementBBox.right) - ix;
  const ih = Math.min(canvasRect.bottom, elementBBox.bottom) - iy;
  if (iw <= 0 || ih <= 0) return { risk: 'CRITICAL', coverage: 0 };
  const coverage = (iw * ih) / (elementBBox.width * elementBBox.height);
  if (coverage < 0.5) return { risk: 'HIGH', coverage };
  if (coverage < 0.8) return { risk: 'MEDIUM', coverage };
  return null;
}

SA-CSS-PUO: primitiveUnits=objectBoundingBox — per-primitive subregion targeting

When primitiveUnits="objectBoundingBox", individual primitive attributes (x, y, width, height) are expressed as bounding-box fractions. This allows a filter primitive to be precisely targeted to a specific spatial region of the consent element. Unlike filterUnits attacks (which affect the entire filter output), primitiveUnits attacks can affect only a portion of the element — selectively attacking one region while leaving others intact.

/* SA-CSS-PUO-001: blur only the left 50% of the element — action verbs targeted */
<filter id="puo001" primitiveUnits="objectBoundingBox">
  <!-- Blur left half (x=0 to x=0.5): action verbs "Grant", "Write", "Delete" -->
  <feGaussianBlur x="0" y="0" width="0.5" height="1" stdDeviation="5"
                  result="leftBlurred"/>

  <!-- Composite blurred left + unblurred right -->
  <feComposite in="leftBlurred" in2="SourceGraphic" operator="over"
               x="0" y="0" width="0.5" height="1"/>
</filter>

/* Effect: right half of consent text appears normally (passes whole-element WCAG check).
   Left half is blurred — specifically targeting the action verbs at sentence start.
   Whole-element opacity = 1; whole-element color = dark; whole-element blur check fails
   because the filter's stdDeviation only applies within the x=[0,0.5] subregion. */

/* SA-CSS-PUO-002: white overlay on rightmost 30% — severity badge region */
<filter id="puo002" primitiveUnits="objectBoundingBox">
  <feFlood flood-color="white" flood-opacity="1" result="white"
            x="0.7" y="0" width="0.3" height="1"/>
  <feComposite in="white" in2="SourceGraphic" operator="over"
               x="0.7" y="0" width="0.3" height="1"/>
</filter>

SA-CSS-PUO-003 targets the right half with a partial opacity reduction: feFuncA slope=0.05 applied only within a x="0.5" y="0" width="0.5" height="1" subregion brings the right half to A=0.05 (95% transparent), producing WCAG 1.05:1 failure for that region while the left half appears at full opacity. A whole-element opacity detector returns 1.0 for the left half and correctly raises no finding — the partial subregion targeting is invisible to element-level checks.

Why subregion attacks evade whole-element checks: Most automated detection samples the element as a whole — average opacity, average color, average contrast. A subregion attack on 30% of the element does not change the whole-element average by enough to trigger threshold-based rules. Detection requires sampling the element’s filter output as a spatial grid, checking each cell independently against legibility thresholds.

Unified detection: spatial analysis of filter output

All five attack families share a common detection requirement: the filter output must be analyzed as a two-dimensional image, not as a set of primitive attribute values. The following unified detection sketch addresses all five families in a single pass:

async function analyzeConsentFilterSpatially(consentEl) {
  /* 1. Check for filter reference */
  const filterVal = getComputedStyle(consentEl).filter;
  const filterIdMatch = filterVal.match(/url\(#([^)]+)\)/);
  if (!filterIdMatch) return null;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return null;

  const bbox = consentEl.getBoundingClientRect();
  const findings = [];

  /* 2. SA-CSS-FUO: check filter canvas coverage */
  const canvasResult = checkFilterCanvas(filterEl, bbox);
  if (canvasResult) findings.push({ family: 'SA-CSS-FUO', ...canvasResult });

  /* 3. SA-CSS-GBA: check asymmetric stdDeviation */
  for (const feGB of filterEl.querySelectorAll('feGaussianBlur')) {
    const sigX = feGB.stdDeviationX.baseVal;
    const sigY = feGB.stdDeviationY.baseVal;
    if (sigX >= 1 || sigY >= 1) {
      const isSourceAlpha = (feGB.getAttribute('in') || '').toLowerCase() === 'sourcealpha';
      findings.push({
        family: isSourceAlpha ? 'SA-CSS-GBS' : 'SA-CSS-GBA',
        risk: (sigX >= 3 || sigY >= 3 || isSourceAlpha) ? 'CRITICAL' : 'HIGH',
        sigX, sigY, isSourceAlpha
      });
    }
  }

  /* 4. SA-CSS-COU: check feComposite operator=out/xor */
  for (const fc of filterEl.querySelectorAll('feComposite')) {
    const op = (fc.getAttribute('operator') || 'over').toLowerCase();
    if (op === 'out' || op === 'xor') {
      findings.push({ family: 'SA-CSS-COU', risk: 'CRITICAL', operator: op });
    }
  }

  /* 5. SA-CSS-PUO: check primitiveUnits-based partial subregion targeting */
  const primUnits = filterEl.getAttribute('primitiveUnits') ?? 'userSpaceOnUse';
  if (primUnits === 'objectBoundingBox') {
    for (const prim of filterEl.children) {
      const w = parseFloat(prim.getAttribute('width') ?? '1');
      const h = parseFloat(prim.getAttribute('height') ?? '1');
      if (w < 0.9 || h < 0.9) {
        findings.push({
          family: 'SA-CSS-PUO', risk: 'HIGH',
          coverage: w * h, tag: prim.tagName
        });
      }
    }
  }

  /* 6. MutationObserver for dynamic variants (GBA-004, GBS-004, COU-004, FUO-004) */
  const mo = new MutationObserver(() => analyzeConsentFilterSpatially(consentEl));
  mo.observe(filterEl, {
    subtree: true, attributes: true,
    attributeFilter: ['stdDeviation', 'in', 'operator', 'x', 'y', 'width', 'height',
                      'filterUnits', 'primitiveUnits', 'flood-color', 'flood-opacity']
  });

  return findings.length > 0 ? { findings, filterEl, bbox } : null;
}

Dynamic TOCTOU attacks common across all five families

Each of the five attack families has a corresponding JavaScript-based dynamic variant (SA-CSS-GBA-004, SA-CSS-GBS-004, SA-CSS-COU-004, SA-CSS-FUO-004, SA-CSS-PUO-004). The attack pattern is consistent:

  1. The filter starts in a benign, identity-like state (zero blur, transparent flood, normal operator, default filter canvas)
  2. A mousedown event listener on the consent button or parent element transitions the filter to its attack state
  3. The consent text is rendered normally during the user’s review period; only at the moment of click is it erased
  4. The user perceives no visual change because the click confirmation is immediate

This TOCTOU attack model is particularly difficult to defend against using screenshot-based detection, because a screenshot taken before the mousedown will show the consent text correctly. Only a MutationObserver watching all relevant filter attributes — attached before the user can interact with the consent element — can reliably detect the transition.

Attack family comparison

Family WCAG at attack CSSOM detection? Evades attribute threshold? Detection requires
SA-CSS-GBA (asymmetric blur) 1.40–2.63:1 No Yes (single-axis check) Read both sigX and sigY independently
SA-CSS-GBS (SourceAlpha overlay) 1.00–1.21:1 No Yes (white-flood-specific rules) Detect in="SourceAlpha" on blur + flood chain
SA-CSS-COU (out knockout) 1.00:1 No Yes (opacity-only rules) Detect operator="out" or "xor" with flood input
SA-CSS-FUO (canvas clipping) 0.00–2.20:1 No Yes (primitive-only rules) Compute filter canvas ∩ element bbox coverage fraction
SA-CSS-PUO (subregion targeting) 1.05–2.84:1 (partial) No Yes (whole-element checks) Spatial grid sampling or subregion primitive analysis

The shared evasion strategy: exploit detection rule granularity

All five attack families share a deeper structural property: they operate at a level of granularity finer than what detection rules typically model. SA-CSS-GBA exploits the two-value nature of stdDeviation while detection rules parse it as a scalar. SA-CSS-GBS exploits the distinction between SourceGraphic and SourceAlpha inputs while detection rules only care about the presence of blur. SA-CSS-COU exploits the Porter-Duff operator set while detection rules only check opacity values. SA-CSS-FUO exploits the filter canvas coordinate system while detection rules check only primitive attributes. SA-CSS-PUO exploits spatial subregions while detection rules aggregate over the whole element.

In each case, the attacker has found a dimension of variation that the detector ignores. The theme — “position-aware attacks” — points to a common prescription: detection rules must model not just what filter primitives are present, but where their effects are geometrically positioned relative to the consent text they target.

The practical consequence for MCP runtime audit infrastructure: static analysis of filter attribute values (rule-based scanning) is insufficient for this class of attacks. Effective detection requires either (a) rendering-pipeline integration that can compare the filter’s output image against the unfiltered consent text on a per-pixel basis, or (b) a spatial analysis pass that checks filter geometry (canvas coverage, primitive subregions, coordinate system interactions) against the element’s bounding box. SkillAudit implements both approaches as part of its MCP consent filter audit capability.

Related pages