MCP server CSS feDisplacementMap security: SVG feDisplacementMap scale consent text displacement outside viewport, animated scale timing attack, and self-referential displacement distortion

Published 2026-09-26 — SkillAudit Research

The SVG feDisplacementMap filter primitive displaces each pixel of its source input image by a vector sampled from a second displacement map image. The scale attribute controls how far pixels are moved, in pixels. The formula is: for each output pixel at (x, y), the source pixel is read from position (x + scale × (map_R − 0.5), y + scale × (map_G − 0.5)), where map_R and map_G are the R and G channel values (normalized 0–1) of the displacement map at position (x, y). A uniform white displacement map (R=1.0, G=1.0) at scale=500 shifts every source pixel by exactly 250 pixels right and 250 pixels down.

SVG elements use overflow:hidden by default. Any displaced pixels that land outside the SVG viewport rectangle are clipped and do not render. Consent text elements with a large-scale feDisplacementMap still report their original pre-displacement positions via getBoundingClientRect — the DOM geometry is computed before filter application. Only checking rendered pixel positions post-filter reveals that all text pixels have been shifted outside the visible area. This makes feDisplacementMap attacks particularly evasive to automated DOM-geometry-based auditors.

Timing attack surface: The animated variant of this attack sets scale=0 at page load (consent text renders normally, passing a DOMContentLoaded snapshot audit) and animates the scale value to a large number via JavaScript setTimeout synchronized with the consent button activation delay. The text is readable during the user's reading window and invisible at the exact moment the button becomes clickable — or vice versa. Static audits at any single point in time miss this class of attack entirely.

Attack findings

CRITICAL
feDisplacementMap with large scale shifts consent text glyphs outside SVG viewport — text exists in DOM but rendered pixels are outside visible container
feDisplacementMap displaces each pixel of the source image (SourceGraphic = consent text) by a vector sampled from a displacement map. With a large scale value (e.g., scale="500"), even a moderate displacement map pushes all source pixels far outside the original bounding box. If the SVG element has overflow:hidden (the default for SVG), those displaced pixels are clipped to the SVG viewport — the consent text appears to have moved off the edge of its container. The text element still has positive getBoundingClientRect dimensions (reporting the pre-displacement area), the filter is applied, and the pixels are gone from the visible area.
<filter id="consent-displace" x="-50%" y="-50%" width="200%" height="200%">
  <!-- Displacement map: static white image → maximum displacement in both axes -->
  <feFlood flood-color="#ffffff" flood-opacity="1" result="disp-map"/>
  <!-- scale=500: 500px displacement in X and Y axes (much larger than any consent dialog) -->
  <!-- All consent text pixels shifted 500px right and down → outside any reasonable viewport -->
  <feDisplacementMap in="SourceGraphic" in2="disp-map" scale="500"
                     xChannelSelector="R" yChannelSelector="G"/>
</filter>
<!-- SVG container: 400×300px, overflow:hidden (default)
     Displaced pixels: at (500+orig_x, 500+orig_y) → all outside 400×300 viewport

     Checks that PASS:
     getBoundingClientRect(textEl) → positive dimensions (pre-filter rect)
     fill, opacity, visibility → normal
     textContent → correct consent text

     Only checking post-filter rendered pixel positions reveals attack
     displacement map value: #ffffff → R=1.0, G=1.0 → both axes → maximum positive shift -->
HIGH
Animated feDisplacementMap scale — consent text visible at page load; scale animates to large value at button activation time
The feDisplacementMap scale attribute starts at 0 (no displacement, consent text renders normally) and animates via CSS transition or SVG animate element to a large value (scale=300) synchronized with the delay before the "Accept" button becomes clickable. During the pre-activation period, consent text is readable. At button activation, the large displacement moves text outside the container. A static snapshot audit at DOMContentLoaded finds scale=0 (benign). The attack only executes at user interaction time.
<filter id="consent-displace-anim">
  <feFlood flood-color="#808080" result="disp-map"/>
  <feDisplacementMap id="disp-filter" in="SourceGraphic" in2="disp-map"
                     scale="0" xChannelSelector="R" yChannelSelector="G"/>
</filter>

<script>
// Activate after button delay (4 seconds)
setTimeout(() => {
  document.getElementById('disp-filter').setAttribute('scale', '400');
  // Consent text now displaced 400px × gray_channel_value px
  // Gray (0.5 normalized) → displacement = 400 × (0.5 - 0.5) = 0 for centered gray
  // White (1.0) → displacement = 400 × (1.0 - 0.5) = 200px right, down → outside viewport
}, 4000);
</script>
<!-- Static audit at t=0: scale="0" → no displacement → PASS
     User audit at t=4s: scale="400" → full displacement → ATTACK ACTIVE -->
MEDIUM
Small feDisplacementMap scale scatters individual character pixels — consent text readable in DOM but rendered as visual noise
A small-to-medium displacement scale (scale=8–15) combined with a high-frequency noise displacement map scatters individual character pixels by 8–15 pixels in random directions. The text characters are still "present" (each pixel exists somewhere in the filter output area), but the inter-character coherence is destroyed — adjacent pixels of the same character are displaced to different positions, and pixel clusters that form recognizable letterforms are dispersed. The text is visually unreadable as individual characters, even though all pixels are technically present in the output.
<filter id="consent-scatter">
  <!-- High-frequency noise displacement map -->
  <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" result="noise"/>
  <!-- scale=12: each pixel displaced 12px in noise direction → character coherence destroyed -->
  <feDisplacementMap in="SourceGraphic" in2="noise" scale="12"
                     xChannelSelector="R" yChannelSelector="G"/>
</filter>
<!-- scale=12 with 0.9 frequency turbulence:
     Each pixel displaced to nearby random position
     Adjacent pixels of same character letter scattered ±12px independently
     Character outlines no longer coherent; text visually illegible

     All individual pixels exist in filter output area (within filter region)
     getBoundingClientRect → positive ✓; filter region contains pixels ✓
     Only human readability test fails; automated pixel-presence checks pass -->
MEDIUM
feDisplacementMap using SourceGraphic as its own displacement map — self-referential displacement creates complex distortion pattern
Using in2="SourceGraphic" as the displacement map creates a self-referential filter where the displacement amount is determined by the source image's own pixel values. For dark consent text on a white background, the dark text pixels (low R value ≈ 0.1) and white background pixels (R ≈ 1.0) create a displacement gradient: dark pixels displace by 0.1×scale, white pixels by 1.0×scale. The result is a spatially variable displacement — text edges are at one offset, text bodies at another, creating an unpredictable distortion that makes characters unrecognizable at moderate scale values.
<filter id="consent-self-displace">
  <!-- Self-referential: SourceGraphic displaces SourceGraphic -->
  <!-- Dark text (low R) → small displacement; white bg (high R) → large displacement -->
  <!-- Creates variable displacement gradient across text/background boundary -->
  <feDisplacementMap in="SourceGraphic" in2="SourceGraphic" scale="20"
                     xChannelSelector="R" yChannelSelector="B"/>
</filter>
<!-- Dark text pixel (R≈0.1, B≈0.1):
     X displacement = (0.1 - 0.5) × 20 = -8px (left shift)
     Y displacement = (0.1 - 0.5) × 20 = -8px (up shift)

     White background pixel (R≈1.0, B≈1.0):
     X displacement = (1.0 - 0.5) × 20 = 10px (right shift)
     Y displacement = (1.0 - 0.5) × 20 = 10px (down shift)

     Complex pattern: text content pixels shift left/up; background pixels shift right/down
     Creates visual chaos at text edges; text characters unrecognizable at scale ≥ 15 -->

Detection

function checkFeDisplacementMap(svgRoot) {
  const findings = [];
  const dispMaps = svgRoot.querySelectorAll('feDisplacementMap');
  for (const dm of dispMaps) {
    const scale = parseFloat(dm.getAttribute('scale') || '0');
    const in2Ref = dm.getAttribute('in2') || '';
    const filter = dm.closest('filter');

    // Check for large static scale
    if (scale > 50) {
      findings.push({ severity: 'critical', issue: `feDisplacementMap scale=${scale} — displacement larger than typical SVG viewport; consent text pixels shifted outside container` });
    }

    // Check for animated scale attribute
    const animEl = dm.querySelector('animate[attributeName="scale"]');
    if (animEl) {
      const toVal = parseFloat(animEl.getAttribute('to') || '0');
      const begin = animEl.getAttribute('begin') || '';
      if (toVal > 50) {
        findings.push({ severity: 'high', issue: `feDisplacementMap scale animates to ${toVal} — check animation timing vs button activation; static audit misses this` });
      }
    }

    // Check for CSS-animated scale via transition
    const cs = getComputedStyle(dm);
    const transition = cs.transition || '';
    if (transition.includes('scale')) {
      findings.push({ severity: 'high', issue: 'feDisplacementMap scale has CSS transition — re-check scale value after animations complete' });
    }

    // Check for moderate scale with turbulence displacement map (scatter attack)
    if (scale >= 8 && scale <= 30) {
      const turbulence = filter?.querySelector('feTurbulence');
      if (turbulence) {
        const freq = parseFloat(turbulence.getAttribute('baseFrequency') || '0');
        if (freq > 0.5) {
          findings.push({ severity: 'medium', issue: `feDisplacementMap scale=${scale} with high-frequency turbulence (baseFrequency=${freq}) — character pixel scatter attack; check rendered readability` });
        }
      }
    }
  }
  return findings.length ? findings : null;
}

Remediation

ControlHow it helps
Check feDisplacementMap scale value: flag any scale > 50 on consent text filters as CRITICAL; for scale 8–30, check if displacement map has high-frequency turbulence that could scatter character pixelsLarge scale values push all source pixels outside the SVG viewport under overflow:hidden; the threshold of 50px is large enough to displace text outside any standard consent dialog container
Re-check feDisplacementMap scale after all animations complete (SVG animate and CSS transitions) — a scale=0 at DOMContentLoaded may animate to large values at button activationThe animated scale timing attack is invisible to snapshot-based audits; the scale attribute must be observed dynamically over the full button activation lifecycle, not only at initial DOM ready
For animated displacement maps, compare animation begin/dur attributes to the consent UI's button activation delay — synchronized timing suggests a timing attackWhen the animation end time coincides exactly with the button becoming clickable, the consent text was readable during the reading window but invisible at the moment of user action; timing correlation is a strong signal
Verify that filter regions (x/y/width/height) on feDisplacementMap filters are bounded to the consent element viewport; enlarged filter regions can accommodate large displacement without obvious layout shiftA filter region extended to 200% width/height allows displaced pixels to render outside the nominal element box but still within the extended filter primitive subregion, making the displacement harder to detect from layout geometry alone

SkillAudit checks feDisplacementMap scale values via both static attribute inspection and dynamic animation tracking on filters applied to consent text elements, and flags scale values above threshold as well as animated scale sequences synchronized with button activation delays. Run a free audit on any MCP server GitHub URL to detect displacement map manipulation and the full SVG filter consent rendering attack surface.