Security Guide

MCP server SVG dy attribute consent security — alternating dy array interleaves characters across 100px vertical split making consent unreadable, large single dy pushes remainder off-screen below, per-character last-value-repeat clips trailing scope words, and mousedown injection

The SVG dy attribute on <text> and <tspan> elements applies per-character vertical displacement offsets as a space-separated list, one value per character. Each value is a cumulative delta from the current y position of that character. An alternating list dy="0 100 0 100 0 100" displaces even-indexed characters 0px (at baseline) and odd-indexed characters 100px below baseline, splitting the consent string into two interleaved rows 100px apart. Reading the top row gives every other character — not a coherent word. The full sentence is scrambled across two non-adjacent rows. textContent returns the complete consent string intact; DOM keyword checks pass. Four attack patterns: alternating array interleave, large single dy off-screen, last-value-repeat clip, and transient mousedown injection.

Attack 1: alternating dy array "0 100 0 100 0 100" interleaves consent characters 100px apart vertically (SA-CSS-DYA-001)

The SVG dy attribute accepts a whitespace-separated list of displacement values, each applying to one glyph in the text string. The displacement is applied as a delta relative to the current y position of that glyph. With the alternating list dy="0 100 0 100 0 100", glyph 0 (“A”) is displaced 0px (stays at baseline), glyph 1 (“l”) is displaced 100px below baseline, glyph 2 (“l”) returns to baseline (dy=0 is relative — from glyph 1’s position at +100, a dy=0 keeps it there; the semantics are cumulative — actually each value is applied from the previous glyph’s position). Correcting for cumulative semantics: glyph 0 at y, glyph 1 at y+100, glyph 2 at y+100+0=y+100, glyph 3 at y+100+100=y+200... Depending on SVG implementation, the exact interleave depends on whether dy values reset per-character or accumulate. At minimum, extreme alternating values produce two rows of interleaved characters separated by the displacement distance. Reading any row gives every other character of the consent string — not a coherent word. The full consent string is in the DOM; no character is missing.

<!-- SA-CSS-DYA-001: alternating dy array — chars split across two rows 100px apart -->
<svg width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="200" fill="white"/>

  <!-- dy list: 0 100 -100 100 -100 100 ... alternating baseline and +100px -->
  <!-- Row 1 (y=40): chars at dy=0 positions — "A","l","w",":"," ","h"... (every other) -->
  <!-- Row 2 (y=140): chars at dy=100 offset — "l","o"," ","s","e","l"... (every other) -->
  <!-- textContent = "Allow: shell execution, credential access" (intact) -->
  <text x="20" y="40" font-size="13" fill="#111"
        dy="0 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100 -100 100">
    Allow: shell execution, credential access
  </text>

  <rect x="20"  y="155" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="175" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="155" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="175" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: parse dy list and check for large alternating displacement values
function checkDyAlternating(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const dyAttr = textEl.getAttribute('dy');
    if (!dyAttr || !dyAttr.trim()) continue;
    const values = dyAttr.trim().split(/\s+/).map(Number).filter(v => !isNaN(v));
    if (values.length < 4) continue;
    // Check for alternating large/small pattern
    const absValues = values.map(Math.abs);
    const maxAbs = Math.max(...absValues);
    if (maxAbs > 30) {
      // Check alternating character: significant variance between adjacent values
      let alternatCount = 0;
      for (let i = 0; i < values.length - 1; i++) {
        if (Math.abs(values[i] - values[i+1]) > 30) alternatCount++;
      }
      if (alternatCount > values.length / 3) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-DYA-001',
          note: `<${textEl.tagName}> dy="${dyAttr.slice(0,40)}..."; ` +
                `alternating displacement pattern with max ${maxAbs}px delta; ` +
                `${alternatCount}/${values.length-1} adjacent pairs have >30px delta; ` +
                `consent chars split across rows ${maxAbs}px apart; ` +
                `textContent="${textEl.textContent.trim().slice(0,30)}..." intact; ` +
                `DOM keyword scan passes; sentence unreadable from either row`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DYA-001: An alternating dy value list with displacements of 100px splits the consent string into two rows 100px apart. Reading any single row gives every other character of the string — not coherent words. textContent returns the full consent string; DOM keyword checks pass. Detection requires parsing the dy attribute list, computing adjacent-value deltas, and flagging alternating patterns where adjacent character displacements exceed a threshold (suggested: 30px).

Attack 2: large single dy on mid-tspan pushes remainder of consent text off-screen below (SA-CSS-DYA-002)

A single large dy value applied to a mid-string <tspan> shifts the current y position for all subsequent characters. The header “Allow:” renders at the original baseline. The scope <tspan> containing “shell execution, credential access” applies dy="200": the entire scope tspan is rendered 200px below the baseline. On a 120px-tall SVG viewport, y + 200 exceeds the viewport height and the scope text is clipped. The subsequent character layout continues from this off-screen position. The SVG’s default overflow="hidden" clips the scope text. Only “Allow:” is visible; the permission terms are invisible.

<!-- SA-CSS-DYA-002: large dy on scope tspan — entire scope block pushed 200px below viewport -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <text x="20" y="40" font-size="13" fill="#111">
    <!-- header renders at y=40 (visible) -->
    <tspan>Allow: </tspan>
    <!-- dy=200: scope tspan at y=40+200=240 — beyond 120px viewport height -->
    <tspan dy="200">shell execution, credential access</tspan>
  </text>

  <rect x="20"  y="70" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: check single dy value on tspan that would push text outside SVG viewport
function checkDyLargeSingle(svgEl) {
  const findings = [];
  const svgHeight = svgEl.viewBox?.baseVal?.height || parseFloat(svgEl.getAttribute('height') || '0');
  for (const tspan of svgEl.querySelectorAll('tspan')) {
    const dyAttr = tspan.getAttribute('dy');
    if (!dyAttr) continue;
    const values = dyAttr.trim().split(/\s+/).map(Number);
    // Single-value dy on tspan that is large positive
    if (values.length === 1 && values[0] > 50) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DYA-002',
        note: `<tspan> dy="${values[0]}"; large single vertical displacement pushes ` +
              `scope tspan ${values[0]}px below parent baseline; ` +
              `likely exceeds SVG viewport height (${svgHeight}px); ` +
              `scope text clipped by overflow=hidden; header visible but scope invisible`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DYA-002: A single large dy value on the scope <tspan> shifts the entire scope block below the SVG viewport. The “Allow:” header remains visible at the original baseline; the permission terms are clipped. Detection requires checking dy attribute values on each tspan and flagging single values that would push text below the SVG viewport height boundary.

Attack 3: dy list shorter than character count — last-value-repeat clips trailing scope words (SA-CSS-DYA-003)

The SVG specification states that when a dy list has fewer values than characters in the text string, the last value is repeated for all remaining characters. An attacker constructs a dy list that starts with normal values (0 for the header characters) and ends with a large displacement value (e.g., 200) that applies to the first few scope characters. Because the last value repeats, every subsequent character after the explicit list is also displaced by 200px cumulatively, pushing every permission-scope word and all trailing characters progressively further below the viewport. The attacker uses a short list (e.g., 10 values for a 40-character string): the first 10 characters render normally (or near-normally), and characters 11+ all receive the repeated 200px displacement.

<!-- SA-CSS-DYA-003: dy list shorter than chars — last value repeats on trailing scope words -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- 8-value dy list for "Allow: s" (8 chars), then last value (200) repeats -->
  <!-- "Allow: " (7 chars) at normal y positions; "s" at dy=200 (first scope char) -->
  <!-- All chars after index 7 ("hell execution credential access") also at dy=200 cumul -->
  <text x="20" y="40" font-size="13" fill="#111"
        dy="0 0 0 0 0 0 0 200">
    Allow: shell execution, credential access
  </text>

  <rect x="20"  y="70" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: flag dy lists shorter than char count with large trailing value
function checkDyLastValueRepeat(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const dyAttr = textEl.getAttribute('dy');
    if (!dyAttr) continue;
    const values = dyAttr.trim().split(/\s+/).map(Number).filter(v => !isNaN(v));
    const charCount = textEl.textContent.trim().length;
    if (values.length < charCount && values.length > 0) {
      const lastVal = values[values.length - 1];
      if (Math.abs(lastVal) > 30) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-DYA-003',
          note: `<${textEl.tagName}> dy list has ${values.length} values for ${charCount} chars; ` +
                `last value "${lastVal}" repeats for all ${charCount - values.length} remaining chars; ` +
                `characters at indices ${values.length}+ displaced ${lastVal}px cumulatively; ` +
                `scope words beyond dy list length clipped by SVG viewport`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-DYA-003: A dy list shorter than the character count causes the SVG renderer to repeat the last value for all remaining characters. A last value of 200 applied to characters 11–40 pushes all permission-scope words far below the viewport. Detection requires comparing the dy list length against the character count and flagging cases where the last value exceeds 30px and there are trailing characters without explicit values.

Attack 4: JS mousedown injects alternating dy array, reverts at mouseup (SA-CSS-DYA-004)

At rest, the consent text element has no dy attribute; the text renders at the normal baseline. A JavaScript mousedown handler sets dy="0 100 -100 100 -100 100..." (alternating values), causing the browser to re-layout the text with alternating per-character displacements during the click window. The consent string is split into two interleaved rows 100px apart; no sentence is readable. The mouseup handler removes the dy attribute and text snaps back to normal. At rest, static SVG shows no dy attribute. The dy attribute is an SVG presentation attribute — not present in any CSS stylesheet. A MutationObserver watching the dy attribute on SVG text elements is required to catch the transient injection.

<!-- SA-CSS-DYA-004: mousedown injects alternating dy array; reverts mouseup -->
<svg id="consent-svg" width="440" height="200" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="200" fill="white"/>

  <!-- At rest: no dy attr — normal baseline rendering -->
  <text id="scope-text" x="20" y="40" font-size="13" fill="#111">
    Allow: shell execution, credential access
  </text>

  <rect x="20"  y="155" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="175" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="155" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="175" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

<script>
(function() {
  const svg  = document.getElementById('consent-svg');
  const text = document.getElementById('scope-text');
  // Build alternating dy list for 40-char string
  const charCount = 40;
  const dyList = Array.from({length: charCount}, (_, i) => i % 2 === 0 ? 0 : 100).join(' ');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('dy', dyList);
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('dy');
  }, true);
})();
</script>

// Detection: MutationObserver on dy attribute of SVG text elements
function watchDyInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'dy') continue;
      const el = mut.target;
      const dyAttr = el.getAttribute('dy') || '';
      const values = dyAttr.trim().split(/\s+/).map(Number).filter(v => !isNaN(v));
      const maxAbs = Math.max(...values.map(Math.abs));
      if (maxAbs > 30) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-DYA-004',
          note: `Dynamic dy="${dyAttr.slice(0,40)}..." on <${el.tagName}> during click window; ` +
                `max displacement ${maxAbs}px; consent chars displaced off-baseline or off-screen; ` +
                `no dy attr at rest; SVG attr not in CSS; MutationObserver on dy required`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['dy']
  });
  return findings;
}

CRITICAL — SA-CSS-DYA-004: JavaScript sets an alternating dy array at mousedown, splitting the consent string into interleaved rows during the click window. The attribute is removed at mouseup. Static SVG shows no dy attribute at rest; dy is an SVG attribute not present in CSS stylesheets. MutationObserver watching the dy attribute of SVG text elements during interaction simulation is required.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-DYA-001: alternating dy array interleaves chars across two rows Even-indexed chars at baseline; odd-indexed chars 100px below; each row contains every other char — no coherent word textContent (full string present); BCR; display/visibility; dy list alternating-pattern check required Critical
SA-CSS-DYA-002: large single dy on scope tspan pushes scope off-screen below Single dy=200 on scope tspan; scope block at y+200, beyond SVG viewport height; overflow=hidden clips textContent; BCR; header visible creates false impression of complete consent; per-tspan dy threshold check required Critical
SA-CSS-DYA-003: dy list shorter than chars — last-value-repeat clips trailing scope words Last value repeats for chars beyond list length; large last value cumulatively displaces all scope words textContent; list length vs char count check required; aggregate dy scan misses partial lists High
SA-CSS-DYA-004: mousedown injects alternating dy, reverts mouseup Alternating dy set at mousedown; consent split into rows during click; removed at mouseup; no attr at rest Static SVG scan; CSS stylesheet scan; MutationObserver on dy attr required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-DYA-001: alternating dy array on SVG consent text — even/odd chars split across two rows; reading one row gives every other character, not coherent words; textContent intact; DOM keyword scan passes; dy list must be parsed with adjacent-delta alternating pattern detection (threshold: >30px delta).
CRITICAL SA-CSS-DYA-002: large single dy on scope tspan — entire scope block pushed below SVG viewport height; only "Allow:" header visible; per-tspan dy single-value threshold check (>50px) required.
HIGH SA-CSS-DYA-003: dy list shorter than char count — last value repeats on all trailing scope characters; large last dy value clips all permission terms beyond list length; list-length vs char-count comparison required.
CRITICAL SA-CSS-DYA-004: JS mousedown injects alternating dy array; consent chars split across rows during click window; removed at mouseup; no dy attr at rest; SVG attr not in CSS stylesheet; MutationObserver on dy attr of SVG text elements required.

Related: SVG dx attribute consent attacks  |  SVG lengthAdjust consent attacks  |  SVG bidirectional and typography attack families

← Blog  |  Security Checklist