← Blog

MCP server SVG feTurbulence direct output consent security

Four SA-CSS-TBO consent attack patterns using feTurbulence as direct filter output — Perlin noise or fractalNoise textures blended over MCP server consent text to destroy character recognition while WCAG numeric contrast checks may still report passing values.

By SkillAudit  |  29 Oct 2026

The SVG feTurbulence primitive generates coherent noise (Perlin turbulence or fractalNoise) that fills the filter canvas with a procedurally generated texture. In most documented attacks, feTurbulence is used as a displacement map input to feDisplacementMap (spatial scrambling of text). The SA-CSS-TBO family uses feTurbulence differently: the noise output is blended directly over the consent text via feBlend or feComposite, creating a texture overlay that destroys character shape recognition without necessarily erasing the text entirely.

Detection gap: At mid-range turbulence scales, individual dark pixels from the original consent text remain present in the rendered output. A WCAG contrast measurement taken at the darkest point may report a passing value (>4.5:1). However, the spatial coherence of those dark pixels is destroyed — they are scattered through a noise field and no longer form recognizable letter shapes. Legibility requires WCAG contrast and spatial coherence; noise attacks exploit the gap between these two measures.

feTurbulence output characteristics

feTurbulence produces RGBA output in [0, 1] range per channel. The key parameters governing attack effectiveness:

/* baseFrequency: controls noise spatial frequency
   - Low (0.01-0.05): large smooth blobs → patches cover/reveal whole words
   - Medium (0.1-0.3): feature size ~3-10px → overlaps individual characters
   - High (0.5-1.0): fine-grained static → 1-3px features interleave with glyph strokes

   numOctaves: controls detail complexity
   - Higher numOctaves → more detail, harder to distinguish text stroke from noise

   type: "turbulence" vs "fractalNoise"
   - turbulence: smooth Perlin with abs() folding, values 0-1
   - fractalNoise: adds sub-octave layers, produces more uniform mid-gray distribution

   seed: deterministic per value (integer) */

<feTurbulence type="turbulence" baseFrequency="0.05" numOctaves="2"
              seed="42" result="noise"/>

SA-CSS-TBO-001: low-frequency noise blended over consent text

Low baseFrequency (0.05) produces noise blobs with feature sizes of approximately 20px — large enough to obscure individual words within a consent dialog. The noise output is composited over SourceGraphic via feBlend mode="normal". Wherever the turbulence alpha component is high, the noise covers the underlying text. Wherever the turbulence alpha is low, the original text shows through — producing a partially-visible patchy rendering where no complete words are legible.

<filter id="tbo001" color-interpolation-filters="sRGB">
  <!-- Generate low-frequency Perlin noise (blob size ~20px at 100% zoom) -->
  <feTurbulence type="turbulence" baseFrequency="0.05" numOctaves="2"
                seed="17" result="noise"/>

  <!-- feBlend normal: noise on top. Where noise A is high, noise covers text. -->
  <feBlend in="noise" in2="SourceGraphic" mode="normal"/>
</filter>

/* Result: mid-tone noise blobs (L ≈ 0.4-0.7) cover portions of consent text.
   - Covered text regions: dark text pixels hidden under mid-gray blobs
   - Exposed text regions: text visible but context (adjacent words) may be hidden
   - No complete sentence is readable: word context destroyed
   - WCAG point-sample at text center: may still read dark pixel (contrast pass)
     → false negative on WCAG check

   Evasion: no feFlood, no feComposite with knockout operator.
   No glyph channel manipulation. Pure noise overlay.

   Detection:
   - Enumerate feTurbulence in filters applied to consent ancestors
   - Any feTurbulence whose result feeds feBlend (mode=normal/overlay/multiply)
     directly over SourceGraphic warrants HIGH-CRITICAL finding
   - Compute turbulence expected coverage at given baseFrequency × filter region */

function detectTurbulenceOverlay(consentEl) {
  let el = consentEl;
  while (el && el !== document.body) {
    const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
    const urlMatch = filterVal.match(/url\(#([^)]+)\)/);
    if (urlMatch) {
      const filterEl = document.getElementById(urlMatch[1]);
      if (filterEl) {
        const turbEls = filterEl.querySelectorAll('feTurbulence');
        for (const turb of turbEls) {
          const result = turb.getAttribute('result') || '';
          /* Check if this turbulence result feeds a blend/composite over SourceGraphic */
          const blends = filterEl.querySelectorAll('feBlend, feComposite');
          for (const bl of blends) {
            if (bl.getAttribute('in') === result) {
              return { risk: 'CRITICAL', type: 'turbulence-direct-overlay', turbEl: turb };
            }
          }
        }
      }
    }
    el = el.parentElement;
  }
  return null;
}
CRITICAL
SA-CSS-TBO-001 — feTurbulence type=turbulence baseFrequency=0.05 blended normal over consent text. Low-frequency noise blobs (≈20px) cover individual words. No complete sentence readable. WCAG point-sample may pass. Detection: enumerate feTurbulence result → feBlend chain on consent ancestors.

SA-CSS-TBO-002: feBlend multiply — spatially variable contrast destruction

feBlend mode="multiply" computes result = in × in2 per channel. When in is the turbulence noise (values in [0,1]) and in2 is SourceGraphic (dark text on white: text pixels are dark ≈0.067, background pixels are white =1.0):

/* multiply mode: result = turbulence × SourceGraphic (per channel, [0,1]) */

/* At background pixel (SG_R = 1.0, SG_A = 0, white): */
result_R = turbulence_R × 1.0 = turbulence_R   /* noise value */
/* Background becomes noise-textured: mid-gray variations */

/* At glyph pixel (SG_R ≈ 0.067, dark text): */
result_R = turbulence_R × 0.067 ≈ 0.067 × turbulence_R
/* At turbulence=0.9: result_R ≈ 0.060  — still dark, WCAG ≈ 16:1 (passes) */
/* At turbulence=0.1: result_R ≈ 0.007  — nearly black, WCAG > 16:1 (passes) */

/* At background adjacent to glyph (SG_R = 1.0): */
/* turbulence=0.9: result_R ≈ 0.90 — nearly white, WCAG glyph/bg ≈ 16:1/0.9+0.05 = 16.8:1 */
/* turbulence=0.1: result_R ≈ 0.10 — mid-gray background */
/* → when turbulence is low at non-glyph and glyph: background is dark, glyph is very dark */
/*   → contrast between adjacent glyph and local background ≈ 1.05:1 — fail */

/* Effect: spatially variable contrast. Some glyph regions pass WCAG locally;
   others fail (where turbulence is high at inter-character spacing).
   No sentence-level coherence. */

<filter id="tbo002">
  <feTurbulence type="fractalNoise" baseFrequency="0.15" numOctaves="3" seed="99"/>
  <feBlend in="SourceGraphic" mode="multiply"/>
</filter>

/* Evades:
   - White flood/whiteout detection (no white fill)
   - Opacity threshold rules (glyph pixels are not transparent)
   - Simple WCAG point-sample (may find dark pixels at WCAG pass)
   - feComposite operator anomaly detection (no feComposite used)

   Detection requires spatially-sampled contrast analysis:
   sample a 3×3 grid across glyph and non-glyph pixels;
   compute local contrast for each sample pair;
   if majority of glyph regions show local contrast < 4.5, raise finding */
HIGH
SA-CSS-TBO-002 — feTurbulence fractalNoise + feBlend multiply on consent text. Spatially variable contrast: some regions WCAG pass, others fail. Background texturized by noise; glyph-to-local-background contrast destroyed at mid-turbulence regions. Detection: spatially sampled local contrast analysis.

SA-CSS-TBO-003: high-frequency fractalNoise static — glyph stroke diffusion

At high baseFrequency (0.8–1.0) with multiple octaves, feTurbulence type="fractalNoise" produces a fine-grained static pattern with feature sizes of 1–3px. At this scale, the noise texture pixels interleave with the 1–2px stroke widths of rendered text glyphs. The visual result is analogous to television static overlaid on text: individual pixels retain their approximate luminance, but the spatial coherence of letter strokes is broken into disconnected noise islands. No OCR algorithm, human or automated, can reliably read text through high-frequency static at this density.

<filter id="tbo003">
  <!-- High-frequency fractalNoise: feature size 1-3px (interleaves with glyph strokes) -->
  <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="4"
                seed="7" result="staticNoise"/>

  <!-- feComposite arithmetic: result = k1×in×in2 + k2×in + k3×in2 + k4
       With k1=0, k2=0.5, k3=0.5, k4=0: result = 0.5×noise + 0.5×SourceGraphic
       Blends noise 50% with original text -- >
  <feComposite in="staticNoise" in2="SourceGraphic"
               operator="arithmetic" k1="0" k2="0.5" k3="0.5" k4="0"/>
</filter>

/* At 1px glyph stroke pixel (dark, L≈0.05):
   noise_L = fractalNoise at this pixel ≈ uniform [0, 1] at high freq
   result_L = 0.5 × noise_L + 0.5 × 0.05

   If noise_L = 0.8: result_L = 0.4 + 0.025 = 0.425 (mid-gray — glyph stroke pixel now gray)
   If noise_L = 0.1: result_L = 0.05 + 0.025 = 0.075 (still dark — glyph retains darkness)

   → ~50% of glyph stroke pixels become mid-gray (L≈0.3-0.5)
   → Stroke broken into alternating dark/gray islands ≈ 1-3px wide
   → At 14px text, each glyph stroke has 8-12px length;
     4-6 dark islands of 1-2px, 4-6 gray islands of 1-2px
   → Letter identity lost: "a" cannot be distinguished from "e" from "o"

   WCAG: point-sample at darkest glyph remnant: L≈0.05, contrast ≈ 20:1 → PASS
   Reality: character is unreadable.

   Evades: WCAG checks, opacity/color checks, feComposite operator checks
   (operator=arithmetic doesn't trigger knockout rules)

   Detection:
   - feTurbulence with high baseFrequency (≥0.5) blended with SourceGraphic
   - feComposite operator=arithmetic with k2>0 and k3>0 (mixing noise with source)
   - Raise HIGH finding: spatial coherence of consent text may be destroyed */
HIGH
SA-CSS-TBO-003 — feTurbulence fractalNoise baseFrequency=0.85 + feComposite arithmetic (50% mix). High-frequency static at glyph-stroke scale destroys letter identity. WCAG point-sample passes (dark pixels remain). Detection: feTurbulence baseFrequency≥0.5 feeding feComposite arithmetic or feBlend on consent ancestors.

SA-CSS-TBO-004: JS baseFrequency ramp — delayed legibility destruction

This temporal variant exploits the assumption that consent text is readable when first displayed. The filter begins with a benign low amplitude (baseFrequency effectively zero, or seed=0 with numOctaves=1 producing nearly transparent output), then programmatically ramps baseFrequency from 0.001 to 0.8 over 500ms via requestAnimationFrame. The dialog is readable for the first ~0.5s, then becomes progressively noisier. By the time the user reaches the consent button (typically 2–5s reading time), the consent text is fully obscured.

<svg style="position:absolute;width:0;height:0" aria-hidden="true">
  <defs>
    <filter id="tbo004">
      <feTurbulence id="tbo004-turb" type="fractalNoise"
                    baseFrequency="0.001" numOctaves="4" seed="3" result="noise"/>
      <feBlend in="noise" in2="SourceGraphic" mode="normal"/>
    </filter>
  </defs>
</svg>

<script>
/* Ramp baseFrequency from 0.001 to 0.8 over 500ms */
function startNoiseRamp() {
  const turbEl = document.getElementById('tbo004-turb');
  const startTime = performance.now();
  const duration = 500;  /* ms */

  function step(now) {
    const t = Math.min((now - startTime) / duration, 1);
    const bf = 0.001 + t * 0.799;  /* 0.001 → 0.800 */
    turbEl.setAttribute('baseFrequency', bf.toFixed(4));
    if (t < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

/* Called when consent dialog is shown — ramp begins immediately */
function showConsentDialog() {
  document.getElementById('consent-dialog').style.display = 'block';
  startNoiseRamp();
}
</script>

/* TOCTOU timeline:
   t=0ms:   Dialog shown. baseFrequency=0.001. Consent text fully readable.
   t=250ms: baseFrequency≈0.4. Mid-frequency noise: words partially obscured.
   t=500ms: baseFrequency=0.8. High-frequency static. Text unreadable.
   t=2000ms: User moves to confirm button. Full static covering consent.

   Detection requires:
   - MutationObserver on feTurbulence baseFrequency attribute
   - Rate of change monitoring: baseFrequency increasing → noise ramp attack
   - Threshold: if baseFrequency rises to ≥0.3 within 1s, raise CRITICAL */

const turbEl = document.querySelector('#tbo004-turb');
const mo = new MutationObserver(muts => {
  for (const m of muts) {
    if (m.attributeName === 'baseFrequency') {
      const newBF = parseFloat(turbEl.getAttribute('baseFrequency'));
      if (newBF >= 0.3) {
        reportFinding('CRITICAL', 'SA-CSS-TBO-004', {
          baseFrequency: newBF,
          note: 'feTurbulence baseFrequency ramped to noise-attack level'
        });
      }
    }
  }
});
mo.observe(turbEl, { attributes: true, attributeOldValue: true });
CRITICAL
SA-CSS-TBO-004 — feTurbulence baseFrequency ramped from 0.001 to 0.8 over 500ms via requestAnimationFrame after dialog display. Consent readable at dialog open; unreadable by confirmation time. TOCTOU via temporal noise ramp. MutationObserver on baseFrequency with threshold ≥0.3 required.

Summary of SA-CSS-TBO findings

IDSeverityMechanismWCAGEvades
SA-CSS-TBO-001 CRITICAL feTurbulence turbulence + feBlend normal (low freq) Passes at dark pixels CSSOM checks, WCAG point-sample
SA-CSS-TBO-002 HIGH feTurbulence fractalNoise + feBlend multiply Spatially variable WCAG point-sample, knockout-operator rules
SA-CSS-TBO-003 HIGH High-frequency fractalNoise + feComposite arithmetic 50% mix Passes (dark pixels remain) WCAG, opacity, feComposite operator rules
SA-CSS-TBO-004 CRITICAL JS ramps baseFrequency 0.001→0.8 over 500ms Passes initially, fails at 500ms Static analysis, screenshot at rest

Related pages