Security Guide

MCP server SVG dx attribute consent security — alternating dx array creates 200px horizontal gaps between characters destroying word boundaries, large single dx pushes scope tspan off-screen right, last-value-repeat progressively shifts trailing scope words outside viewport, and mousedown injection

The SVG dx attribute applies per-character horizontal displacement offsets as a space-separated cumulative delta list, one value per glyph. An alternating list dx="0 200 0 200 0 200" adds 200px of additional horizontal advance before alternating characters: even-indexed characters appear at their natural positions while odd-indexed characters are displaced 200px further to the right. The result is a sequence of character pairs with 200px gaps between them; no two consecutive characters are adjacent, every word boundary is destroyed, and no word is readable as a continuous string. The textContent DOM property returns the complete consent string intact; DOM keyword checks pass. Four attack patterns: alternating gap array, large single dx off-screen push, last-value-repeat progressive shift, and transient mousedown injection.

Attack 1: alternating dx array "0 200 0 200" creates 200px horizontal gaps destroying word boundaries (SA-CSS-DXA-001)

The SVG dx attribute works analogously to the dy attribute but in the horizontal direction: each value adds extra horizontal advance before the corresponding glyph. Unlike CSS letter-spacing which adds uniform spacing between all character pairs, dx provides per-character control. An alternating dx list inserts large gaps before alternating characters. With dx="0 200 0 200 0 200" on “Allow:”: “A” at natural position, “l” displaced 200px right, “l” at natural advance from that position, “o” displaced 200px right from there, etc. The cumulative effect is that alternating characters cluster in pairs separated by 200px gaps. The word “Allow” appears as “A”     “l” with 200px between them — the string is entirely unrecognizable as text. All permission terms are present in the DOM; they are simply arranged in a pattern that no human can parse as a consent sentence.

<!-- SA-CSS-DXA-001: alternating dx array — chars spaced 200px apart horizontally -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- dx="0 200 0 200...": odd-indexed chars displaced 200px right -->
  <!-- "A" at x≈20, "l" at x≈228, "l" at x≈236, "o" at x≈444 (off-screen) ... -->
  <!-- No word boundary is preserved; textContent intact -->
  <text x="20" y="40" font-size="13" fill="#111"
        dx="0 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 200 -192 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: parse dx list and detect alternating large horizontal displacements
function checkDxAlternating(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const dxAttr = textEl.getAttribute('dx');
    if (!dxAttr || !dxAttr.trim()) continue;
    const values = dxAttr.trim().split(/\s+/).map(Number).filter(v => !isNaN(v));
    if (values.length < 4) continue;
    const absValues = values.map(Math.abs);
    const maxAbs = Math.max(...absValues);
    if (maxAbs > 30) {
      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-DXA-001',
          note: `<${textEl.tagName}> dx="${dxAttr.slice(0,40)}..."; ` +
                `alternating horizontal displacement pattern with max ${maxAbs}px delta; ` +
                `${alternatCount}/${values.length-1} adjacent pairs have >30px delta; ` +
                `word boundaries destroyed; chars in pairs separated by ${maxAbs}px gaps; ` +
                `textContent="${textEl.textContent.trim().slice(0,30)}..." intact; ` +
                `DOM keyword scan passes; no sentence readable`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DXA-001: An alternating dx value list with 200px displacements creates character pairs separated by large horizontal gaps. No two adjacent characters of any word appear next to each other; every word boundary is destroyed. textContent returns the full consent string; DOM keyword checks pass. Detection requires parsing the dx attribute list, computing adjacent-value deltas, and flagging alternating patterns where adjacent displacements exceed a threshold (suggested: 30px).

Attack 2: large single dx on scope tspan pushes permission words 500px off-screen right (SA-CSS-DXA-002)

The header <tspan> “Allow:” renders at the normal x position without any dx displacement. The scope <tspan> containing the permission terms applies a single dx="500", shifting the starting position of the entire scope block 500px to the right of where it would normally appear. On a 440-unit-wide SVG viewport with the header ending at approximately x=60, the scope tspan starts at x=60+500=560 — 120px beyond the right viewport boundary. The SVG’s default overflow="hidden" clips all content beyond the viewport. Only “Allow:” is visible; the permission terms “shell execution, credential access” are off-screen right. The textContent of the entire <text> element includes both the header and scope words; DOM keyword checks pass.

<!-- SA-CSS-DXA-002: large dx on scope tspan — scope pushed 500px off-screen right -->
<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 x=20 (visible) -->
    <tspan>Allow: </tspan>
    <!-- dx=500: scope tspan starts 500px right of where it would normally be -->
    <!-- natural start ≈ x=60; with dx=500: starts at x=560 (beyond 440px viewport) -->
    <tspan dx="500">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 large dx value on tspan that pushes text past viewport width
function checkDxLargeSingle(svgEl) {
  const findings = [];
  const svgWidth = svgEl.viewBox?.baseVal?.width || parseFloat(svgEl.getAttribute('width') || '0');
  for (const tspan of svgEl.querySelectorAll('tspan')) {
    const dxAttr = tspan.getAttribute('dx');
    if (!dxAttr) continue;
    const values = dxAttr.trim().split(/\s+/).map(Number);
    if (values.length === 1 && values[0] > 100) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-DXA-002',
        note: `<tspan> dx="${values[0]}"; large horizontal displacement pushes scope tspan ` +
              `${values[0]}px right; likely pushes content past SVG viewport width (${svgWidth}px); ` +
              `overflow=hidden clips scope text; header visible but scope invisible`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-DXA-002: A single large dx="500" on the scope <tspan> shifts the entire scope block 500px to the right of its natural position, beyond the SVG viewport width. The “Allow:” header remains visible; the permission terms are clipped. Detection requires checking dx values on each tspan and flagging single large values (suggested threshold: >100px) that would push scope text outside the SVG viewport.

Attack 3: dx list shorter than character count — last-value-repeat progressively shifts trailing scope chars (SA-CSS-DXA-003)

As with the dy attribute, the SVG specification states that when a dx list is shorter than the character count, the last value is repeated for all remaining characters. An attacker constructs a short dx list where the last value is 300px: the first N characters use the explicit list values (0 for normal spacing), and characters N+1 onward each receive an additional 300px displacement. Because dx is cumulative, character N+1 is displaced 300px, character N+2 is displaced 600px, character N+3 is displaced 900px, etc. The first scope word “shell” may appear barely inside the viewport (at N+1), but subsequent permission terms are pushed progressively further off-screen to the right. The attacker can calibrate the list length so that exactly the header and the word “Allow:” fall within the explicit list (receiving 0px displacement), while the first scope character triggers the last-value-repeat, pushing all scope content progressively off-screen.

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

  <!-- 8-value dx for "Allow: s" (8 chars); last value 300 repeats for chars 9+ -->
  <!-- "hell" at +300, "execution" at +600, "credential" at +900+... (all off-screen) -->
  <text x="20" y="40" font-size="13" fill="#111"
        dx="0 0 0 0 0 0 0 300">
    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 dx lists shorter than char count with large trailing value
function checkDxLastValueRepeat(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    const dxAttr = textEl.getAttribute('dx');
    if (!dxAttr) continue;
    const values = dxAttr.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 (lastVal > 50) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-DXA-003',
          note: `<${textEl.tagName}> dx list has ${values.length} values for ${charCount} chars; ` +
                `last value "${lastVal}" repeats for all ${charCount - values.length} remaining chars; ` +
                `each subsequent scope char displaced ${lastVal}px further right cumulatively; ` +
                `trailing scope words progressively pushed off-screen right`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-DXA-003: A dx list shorter than the character count repeats the last value for all remaining characters. A last value of 300px causes each subsequent character to be displaced an additional 300px to the right, cumulatively. Scope words beyond the list length are pushed progressively off-screen. Detection requires comparing list length against character count and flagging cases where the last value exceeds 50px with trailing characters.

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

At rest, the consent text element has no dx attribute; text renders with normal character spacing. A JavaScript mousedown handler sets a large alternating dx list on the consent text element. The browser immediately re-lays out the text with 200px gaps between alternating characters; every word boundary is destroyed during the click window. The mouseup handler removes the dx attribute and text returns to normal spacing. At rest, static SVG shows no dx attribute. The dx attribute is a pure SVG presentation attribute — it has no CSS counterpart that would appear in a stylesheet scan. getComputedStyle does not expose the dx list; the only detection path is a MutationObserver watching the dx attribute directly on SVG text elements.

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

  <!-- At rest: no dx attr — normal character spacing -->
  <text id="scope-text" x="20" y="40" font-size="13" fill="#111">
    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>

<script>
(function() {
  const svg  = document.getElementById('consent-svg');
  const text = document.getElementById('scope-text');
  const charCount = 40;
  // Alternating 0 / 200px dx for each character
  const dxList = Array.from({length: charCount}, (_, i) => i % 2 === 0 ? 0 : 200).join(' ');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('dx', dxList);
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('dx');
  }, true);
})();
</script>

// Detection: MutationObserver on dx attribute of SVG text elements
function watchDxInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      if (mut.attributeName !== 'dx') continue;
      const el = mut.target;
      const dxAttr = el.getAttribute('dx') || '';
      const values = dxAttr.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-DXA-004',
          note: `Dynamic dx="${dxAttr.slice(0,40)}..." on <${el.tagName}> during click window; ` +
                `max displacement ${maxAbs}px; word boundaries destroyed or scope pushed off-screen; ` +
                `no dx attr at rest; SVG attr not in CSS; MutationObserver on dx required`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['dx']
  });
  return findings;
}

CRITICAL — SA-CSS-DXA-004: JavaScript sets an alternating dx array at mousedown, creating 200px gaps between alternating characters and destroying all word boundaries during the click window. The attribute is removed at mouseup. Static SVG shows no dx attribute at rest; dx is not exposed by getComputedStyle and has no CSS counterpart. MutationObserver watching the dx attribute of SVG text elements during interaction simulation is required.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-DXA-001: alternating dx array — 200px gaps between chars Even chars at natural advance; odd chars displaced 200px right; pairs separated by 200px gaps; no word boundaries textContent (full string present); BCR; display/visibility; dx list alternating-pattern check required Critical
SA-CSS-DXA-002: large dx=500 on scope tspan pushes scope off-screen right Single dx=500 on scope tspan; scope block starts 500px right of natural position; exceeds 440px viewport; overflow=hidden clips textContent; BCR; header visible creates false consent appearance; per-tspan dx threshold check required Critical
SA-CSS-DXA-003: dx list shorter than chars — last-value-repeat shifts trailing scope Last value 300px repeats cumulatively; each trailing scope char displaced 300px further right; scope words progressively off-screen textContent; list length vs char count check required; aggregate dx max-value check misses cumulative effect High
SA-CSS-DXA-004: mousedown injects alternating dx, reverts mouseup Alternating dx set at mousedown; word boundaries destroyed during click; removed at mouseup; no attr at rest Static SVG scan; CSS stylesheet scan; getComputedStyle (no dx equivalent); MutationObserver on dx attr required Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-DXA-001: alternating dx array on SVG consent text — 200px gaps between alternating chars; all word boundaries destroyed; textContent intact; DOM keyword scan passes; dx list must be parsed with adjacent-delta alternating pattern detection (threshold: >30px delta between adjacent values).
CRITICAL SA-CSS-DXA-002: large single dx on scope tspan — scope block pushed 500px right, beyond SVG viewport; only "Allow:" header visible; per-tspan dx single-value threshold check (>100px) required.
HIGH SA-CSS-DXA-003: dx list shorter than char count — last value 300px repeats cumulatively; each trailing scope char displaced further right; list-length vs char-count comparison required with last-value threshold check (>50px).
CRITICAL SA-CSS-DXA-004: JS mousedown injects alternating dx array; word boundaries destroyed during click window; removed at mouseup; no dx attr at rest; dx not exposed by getComputedStyle or CSS stylesheets; MutationObserver on dx attr of SVG text elements required.

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

← Blog  |  Security Checklist