October 15, 2026 SVG Security

SVG feDisplacementMap as MCP Consent Bypass Vectors: Scale Overflow, Alpha-Channel Selection, and getBoundingClientRect Evasion

Most SVG filter attacks on MCP consent panels work by hiding, blurring, or color-washing the consent text. <feDisplacementMap> takes a different approach: it physically relocates pixels. Every output pixel is sampled from a different input position — one determined by channel values read from a second image, multiplied by a scale attribute. With scale=500 and a solid feFlood image as the displacement source, every pixel of the consent panel moves 250 pixels in one direction — off-screen, clipped to transparency, invisible. The DOM element does not move. getBoundingClientRect() reports the original position. The Allow button is still clickable at the original coordinates. The user sees a blank panel and presses a button they believe is harmless. This article documents how the displacement formula works, four attack patterns that exploit it against MCP consent UI, why layout-based visibility checks fail entirely against this primitive, and the detection algorithm SkillAudit uses to catch every variant.

The displacement formula: why 0.5 is the neutral point

The <feDisplacementMap> element applies a per-pixel spatial displacement to its source image. For each output pixel at position (x, y), the primitive reads the input image at a shifted position computed as:

x_out = x_in + scale × (X_channel_value − 0.5)
y_out = y_in + scale × (Y_channel_value − 0.5)

Where X_channel_value and Y_channel_value are the R, G, B, or A channel values of the displacement map image at the same position (x, y), selected by the xChannelSelector and yChannelSelector attributes respectively. Channel values are in the range [0, 1].

The subtraction of 0.5 is the key: it centers the displacement range around zero. A channel value of exactly 0.5 produces a displacement of scale × 0 = 0 — no displacement. A channel value of 1.0 produces scale × 0.5 displacement in the positive direction. A channel value of 0.0 produces scale × (−0.5) = −scale × 0.5 displacement in the negative direction. The maximum possible displacement (positive or negative) is scale / 2.

This means:

The feFlood primitive is the simplest way to create a uniform displacement source. feFlood flood-color="rgb(255,0,0)" produces a solid red image with R=1.0 at every pixel. Combined with xChannelSelector="R" and scale="500", the x-displacement at every pixel is 500 × (1.0 − 0.5) = 250 pixels — a spatially uniform, fixed displacement applied to every pixel of the consent text simultaneously.

Why feFlood is the preferred displacement source for attacks: A feTurbulence displacement map creates a spatially varying displacement that distorts the text without uniformly relocating it — the text becomes unreadable but pixels remain somewhere near their original positions. A feFlood map creates a uniform displacement that translates the entire rendered image by a fixed offset. With a large enough scale, this places all pixels outside the filter region where they are clipped to transparency. The consent panel becomes completely blank rather than distorted.


Attack 1 (SA-CSS-FDM-001): scale="500" xChannelSelector="R" — uniform rightward displacement

The canonical feDisplacementMap consent attack uses a feFlood flood-color="rgb(255,0,0)" (R=1.0 everywhere) displacement source with scale="500" and xChannelSelector="R". The x-displacement is 500 × (1.0 − 0.5) = 250 pixels rightward. For a standard 420-pixel-wide consent panel, this moves the leftmost pixel from position x=0 to x=250, and the rightmost pixel from x=420 to x=670. All pixels are shifted 250 pixels to the right.

The filter region for an SVG filter defaults to x=−10%, y=−10%, width=120%, height=120% relative to the element's bounding box. For a 420×180px consent panel, this gives a filter region of approximately x=−42px, width=504px — so the right boundary of the filter region is at x=462. After the 250px displacement, pixels originally at x>212 (position 212 to 420) are shifted beyond x=462 and clipped to transparency. The left 212 pixels (which are shifted to positions 462–670) are also outside the filter region and clipped. The result is a completely blank consent panel.

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="fdm-right" x="-10%" y="-10%" width="120%" height="120%"
            color-interpolation-filters="sRGB">
      <!-- Displacement map: R=1.0 at every pixel (solid red) -->
      <feFlood flood-color="rgb(255,0,0)" flood-opacity="1" result="redmap"/>
      <!-- x_displacement = 500*(R-0.5) = 500*(1.0-0.5) = +250px (right) -->
      <!-- Filter region right boundary at +462px; shifted pixels exit at x>212 -->
      <feDisplacementMap in="SourceGraphic" in2="redmap"
                         scale="500"
                         xChannelSelector="R"
                         yChannelSelector="G"/>
      <!-- G=0 for red feFlood: y_displacement = 500*(0-0.5) = -250px (up) -->
      <!-- Result: all pixels exit top-right — blank panel -->
    </filter>
  </defs>
</svg>

<div id="consent" style="filter:url(#fdm-right); width:420px; padding:24px">
  <p>This MCP server requests access to your filesystem and credentials.</p>
  <button id="allow">Allow</button>  <button>Deny</button>
</div>

// Result: blank consent panel.
// getBoundingClientRect(consent) returns { left:100, top:200, width:420, height:180 }
// All consent text is off-screen but the Allow button fires at click(x=140, y=275).

CRITICAL — SA-CSS-FDM-001: feDisplacementMap scale="500" with a uniform red feFlood source displaces all consent pixels 250px rightward and 250px upward (for a red feFlood where G=0). All displaced pixels exit the filter region and are clipped to transparency. The consent panel renders as a blank rectangle. Maximum displacement per pixel is scale / 2 in each selected channel axis; for scale=500, max displacement is 250px — exceeding a standard consent panel width in a single step.


Attack 2 (SA-CSS-FDM-002): scale="-500" — negative scale, opposite direction

The SVG specification explicitly allows negative values for the scale attribute. With scale="-500" and feFlood flood-color="rgb(255,0,0)" (R=1.0), the x-displacement is −500 × (1.0 − 0.5) = −250 pixels — leftward instead of rightward. For a consent panel near the center or left edge of the viewport, a 250-pixel leftward displacement moves all consent pixels off the left edge and clips them to transparency.

The negative-scale variant is important because many security rules are written as scale > 100 rather than Math.abs(scale) > 100. A rule that only catches positive values misses this attack entirely. The visual outcome is identical to the positive variant — a blank consent panel with a hittable Allow button at the original DOM coordinates. The only observable difference is the direction of displacement, which is irrelevant to the attack's effectiveness: the goal is to exit the filter region boundary, and this can be achieved in any direction.

Combining positive and negative displacements on separate axes is also possible. scale="-500" xChannelSelector="R" yChannelSelector="G" with a green feFlood flood-color="rgb(0,255,0)" (R=0, G=1) produces x-displacement = −500 × (0.0 − 0.5) = 250 pixels (right — because R=0 and the formula negates) and y-displacement = −500 × (1.0 − 0.5) = −250 pixels (upward). Simultaneous two-axis displacement makes it harder to inadvertently leave any consent pixels within the visible area due to partial overlap.

CRITICAL — SA-CSS-FDM-002: Negative scale values displace pixels in the opposite direction to positive values. Security checks must use Math.abs(scale) > threshold rather than scale > threshold. A scale of −500 is exactly as dangerous as a scale of +500: both displace pixels 250px outside the standard filter region for a 420px panel, resulting in a completely blank consent panel with the Allow button still hittable at the original layout position.


Attack 3 (SA-CSS-FDM-003): xChannelSelector="A" in2="SourceGraphic" — alpha self-displacement

The third variant uses the source image's own alpha channel as the displacement map, with in2="SourceGraphic" and xChannelSelector="A". Unlike the feFlood variants, this attack has no additional displacement map primitive — the displacement source is the element being displaced.

For consent text rendered on an opaque background (e.g., a white or dark panel), all pixels have alpha=1 — both text glyphs and background pixels are fully opaque. The x-displacement at every pixel is scale × (1.0 − 0.5) = scale / 2 — identical to the feFlood red variant. All pixels shift uniformly off-screen. The attack behaves the same as SA-CSS-FDM-001 but uses the SourceGraphic itself as the displacement map, making the filter graph appear simpler (no suspicious extra primitive).

The more sophisticated sub-variant applies to consent rendered with a transparent background — for example, an SVG text element with transparent surroundings. In this case:

The text glyphs are displaced rightward while the transparent background moves leftward. Both groups exit the filter region and are clipped to transparency — the consent panel is blank. If overflow: visible is set on the filter region (by expanding x, y, width, height), the displaced groups appear on opposite sides of the panel: text to the right, background to the left. This creates a "shattered" appearance where fragments of the consent text appear outside the panel boundaries, which might be dismissed as a visual effect rather than a security attack. Under default (overflow: hidden) filter regions, both groups are clipped and nothing is visible.

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="fdm-alpha-self" x="-100%" y="-100%" width="300%" height="300%">
      <!-- No feFlood needed — displacement source is the element itself -->
      <!-- For opaque backgrounds: A=1 everywhere → all pixels displaced scale/2 -->
      <!-- For transparent backgrounds: glyphs (A=1) right, bg (A=0) left -->
      <feDisplacementMap in="SourceGraphic" in2="SourceGraphic"
                         scale="500"
                         xChannelSelector="A"
                         yChannelSelector="A"/>
    </filter>
  </defs>
</svg>

<text x="20" y="40" font-size="14" fill="#111"
      filter="url(#fdm-alpha-self)">
  Authorize file access, credential read, outbound network egress
</text>

// SVG text has transparent background (A=0 outside glyphs, A=1 inside)
// Glyph pixels: A=1 → x += 500*(1-0.5) = +250px (right, off-screen)
// Background pixels: A=0 → x += 500*(0-0.5) = -250px (left, off-screen)
// Both groups clipped to transparency → blank consent area
// No feFlood, no feColorMatrix, no feGaussianBlur — filter graph looks innocuous
// Only Math.abs(scale) > 100 check catches this

CRITICAL — SA-CSS-FDM-003: in2="SourceGraphic" xChannelSelector="A" uses the element's own alpha channel as the displacement map. No additional primitive is needed. For opaque-background consent panels, all pixels have A=1 and are displaced uniformly off-screen. For transparent-background SVG text, glyph pixels and background pixels move in opposite directions, both exiting the filter region. The filter graph is simpler than the feFlood variants and may appear to be a stylistic displacement effect. Detection requires checking Math.abs(scale) regardless of the in2 and xChannelSelector attributes.


Attack 4 (SA-CSS-FDM-004): getBoundingClientRect evasion — the Allow button stays hittable

feDisplacementMap applies its displacement during the compositing and rendering stage of the browser pipeline — after layout. The DOM element's position in the document flow is unchanged. The element occupies the same space in the layout tree as it would without the filter. getBoundingClientRect() queries the layout tree, not the render output, and returns the pre-displacement bounding box.

This creates a compound attack property. The consent text is rendered 250 pixels from where the DOM says it is — invisible, clipped to transparency, replaced by blank space. But the browser's pointer event handling also uses the layout position for hit-testing: click events are delivered to the element at the layout coordinates. The "Allow" button inside the consent panel fires its click handler when the user clicks at the button's original layout position, even though the rendered button text is invisible (displaced off-screen).

The attack is therefore self-consistent in a way that pure opacity-based or blur-based attacks are not:

Compound attack: A blank panel caused by feDisplacementMap displacement is more dangerous than a transparent panel caused by alpha-zeroing, because the user sees a visible panel boundary (the panel container element itself is visible — background color, border, padding) but the text content has been moved. The user may assume the panel loaded incorrectly and click the primary action button out of confusion. An alpha-zeroed panel is purely invisible; a blank-content panel with a visible chrome looks like a partially loaded UI — a more convincing attack surface.

// All layout-based checks return false negatives for feDisplacementMap attacks

// 1. getBoundingClientRect — layout position, not render position
const rect = el.getBoundingClientRect();
// rect = { left: 100, top: 200, width: 420, height: 180 }
// Displaced pixels are at (350, 200) but clipped — invisible
// naiveCheck(el) → true (element is "in viewport" at layout position)

// 2. Intersection Observer — fires on layout boundary intersection
new IntersectionObserver(([e]) => {
  console.log(e.isIntersecting); // true — layout box intersects viewport
  // Rendered content is off-screen; this API cannot detect displacement
}).observe(el);

// 3. checkVisibility (CSS properties only, not render position)
el.checkVisibility({ checkVisibilityCSS: true }); // true
// feDisplacementMap does not affect visibility, display, or opacity CSS properties

// 4. Pointer events (hit-test uses layout position)
el.querySelector('#allow').addEventListener('click', approve);
// fires when user clicks at (layout_x + button_offset_x, layout_y + button_offset_y)
// regardless of where the button text is rendered (which is off-screen)

// ✓ Only reliable detection: filter graph analysis
function auditConsentFilter(el) {
  const filter = getComputedStyle(el).filter
               || el.getAttribute('filter') || '';
  const urlMatch = filter.match(/url\(["']?(#[\w-]+)["']?\)/);
  if (!urlMatch) return null;

  const def = document.querySelector(urlMatch[1]);
  if (!def) return [{ sev: 'HIGH', note: 'Filter not found — closed shadow DOM?' }];

  return [...def.querySelectorAll('feDisplacementMap')]
    .map(fdm => {
      const scale = parseFloat(fdm.getAttribute('scale') || '0');
      const abs   = Math.abs(scale);
      if (abs === 0) return null;
      return {
        sev: abs > 300 ? 'CRITICAL' : abs > 100 ? 'HIGH' : 'MEDIUM',
        attribute: 'scale',
        value: scale,
        note: `feDisplacementMap scale=${scale} — max displacement ${(abs*0.5).toFixed(0)}px`
      };
    })
    .filter(Boolean);
}

Detection gap summary: getBoundingClientRect, Intersection Observer, checkVisibility, and CSS property inspection all fail to detect feDisplacementMap attacks. The displacement is applied at render time, after layout, and is invisible to every layout-based DOM API. The only reliable detection method is to resolve the filter URL to a <filter> element, enumerate all feDisplacementMap children, and check the absolute value of the scale attribute.


Why feFlood is the preferred displacement source — and why any non-neutral color is an attack

An attacker constructing a feDisplacementMap consent attack needs a displacement map image that produces a uniform, spatially constant displacement large enough to move all consent pixels outside the filter region. The feFlood primitive is the ideal tool for this: it generates a solid-color rectangle covering the entire filter region, with a constant channel value at every pixel. The attacker needs only to choose a flood color that produces the desired channel value and pair it with the appropriate xChannelSelector.

The attack surface for feFlood displacement map sources is large. Consider all combinations of common flood colors with different channel selectors:

feFlood color xChannelSelector Channel value x-displacement (scale=500) Direction
rgb(255,0,0) — redR1.0+250pxright
rgb(0,0,0) — blackR0.0−250pxleft
rgb(0,255,0) — greenG1.0+250pxright
rgb(0,0,255) — blueB1.0+250pxright
rgb(255,255,255) — whiteR or G or B1.0+250pxright
rgba(0,0,0,1) — black opaqueA1.0+250pxright
rgb(128,128,128) — mid-grayR or G or B0.502+1pxright (near-zero)

Only mid-gray (exactly R=G=B=0.5 or equivalently rgb(128,128,128)) produces near-zero displacement. Every other solid color used as a displacement source with scale=500 produces approximately ±250px displacement. An auditor reviewing an MCP server's SVG filter graph should flag every feFlood used as in2 for a feDisplacementMap unless the flood color is demonstrably mid-gray and the scale is demonstrably small.

The zero-tolerance rule: feDisplacementMap has no legitimate use in consent UI rendering. No consent panel needs pixel-level spatial displacement. Any feDisplacementMap primitive on a consent element — regardless of scale, regardless of channel selector, regardless of displacement source — should be flagged as a CRITICAL finding and the consent rendering blocked until the filter is removed. The correct scale for consent UI is 0, and the correct way to achieve scale=0 is to not have a feDisplacementMap element at all.


Detection algorithm summary

SkillAudit's consent filter analyzer applies the following steps to every element in an MCP server's consent UI that has a filter CSS property or attribute:

Step Action Flag condition
1 Resolve the filter URL to a <filter> element in the document or shadow DOM If URL resolves to null: flag HIGH (potential closed shadow DOM concealment)
2 Enumerate all feDisplacementMap children of the filter element If any feDisplacementMap present: continue to step 3
3 Read the scale attribute; compute Math.abs(scale) abs(scale) > 300: CRITICAL; abs(scale) > 100: HIGH; abs(scale) > 50: MEDIUM; any nonzero: flag for review
4 Identify the displacement map source (in2 attribute): feFlood, SourceGraphic, feTurbulence, or result of another primitive feFlood source: escalate to CRITICAL regardless of scale (uniform displacement). SourceGraphic self-displacement: flag same as feFlood (A=1 for all opaque pixels). feTurbulence: flag HIGH (distortion attack even if not off-screen)
5 Verify that getBoundingClientRect returns a layout position, not a render position, and flag this as an inherent detection-evasion property of the attack Always annotate: "layout-based visibility checks are insufficient for feDisplacementMap; filter graph analysis is required"
6 Check the filter region (x, y, width, height attributes): if the region is expanded beyond 200% width/height, the displacement may be visible outside the element rather than clipped — flag overflow-visible variant Expanded filter region + large scale: CRITICAL (overflow variant may shatter consent text visibly around the panel boundary)

Mitigations

For MCP server authors:

For security auditors reviewing MCP servers:

CRITICAL

feFlood + scale=500 (SA-CSS-FDM-001)

feFlood R=1.0 as displacement map; x-displacement = +250px; all consent pixels exit filter region rightward; panel blank; Allow button hittable at original layout position.

CRITICAL

Negative scale (SA-CSS-FDM-002)

scale=−500 reverses direction; −250px leftward. Security rules checking only scale > 100 miss this. Detection requires Math.abs(scale) > 100. Same blank-panel outcome.

CRITICAL

Alpha self-displacement (SA-CSS-FDM-003)

in2="SourceGraphic" xChannelSelector="A"; A=1 for opaque pixels; scale/2 displacement without extra primitives. Simpler filter graph; only scale attribute check catches it.

HIGH

getBoundingClientRect evasion (SA-CSS-FDM-004)

Displacement is post-layout render operation. getBoundingClientRect, Intersection Observer, checkVisibility — all return pre-displacement position. Click events fire at layout coordinates. Allow button remains hittable on blank panel.

SkillAudit analyzes every feDisplacementMap in your MCP server's SVG consent UI as part of the standard free audit. The scanner resolves filter URLs, checks absolute scale values, classifies displacement map sources (feFlood, SourceGraphic, feTurbulence), and flags the getBoundingClientRect evasion property explicitly in the report. Paste your GitHub URL to get a full report including the displacement analysis alongside all other SVG filter consent attack checks.

← Blog  |  feDisplacementMap SEO reference  |  Security Checklist