Security Guide

MCP server SVG stroke-dashoffset consent security — stroke-dashoffset=1e9 shifts all dashes beyond the glyph path length leaving zero ink, dashoffset and dasharray cooperation, per-tspan scope with normal header, and mousedown dashoffset injection

When SVG consent text is rendered stroke-only — fill="none" with a dark stroke color and a stroke-dasharray defining a dash pattern — the stroke-dashoffset presentation attribute can shift the entire dash pattern far beyond the glyph path length, ensuring that no dash segment ever falls within any glyph outline. The SVG stroke rendering algorithm traces each glyph outline path and places dash segments according to the dasharray period, beginning the pattern at position -(dashoffset) along the path. With stroke-dashoffset="1e9", the pattern starts 10&sup9; units before the path, meaning the rendering engine consumes the entire glyph path (typically 40–80 SVG units for 13px text) within a single enormous gap before a dash is ever reached. No ink is painted. Critically, this attack requires both stroke-dasharray and stroke-dashoffset to be set: a scanner checking only dasharray finds a valid pattern (e.g., "10 20") and concludes that visible dashes will be produced — without checking dashoffset, it cannot know those dashes have been shifted off the path. Four attack patterns: giant-offset full invisible, dasharray-period cooperation, per-tspan scope, and mousedown injection.

Attack 1: stroke-dashoffset=1e9 with stroke-dasharray="10 20" on fill=none text — all dashes shifted beyond path (SA-CSS-SDO-001)

The SVG specification defines the stroke dash rendering algorithm as follows: the dash pattern (alternating dash and gap lengths from stroke-dasharray) is first applied to an infinite virtual line; then stroke-dashoffset is used to shift that pattern backward along the path. Equivalently, the rendering starts at path position -(dashoffset) in the pattern and paints whichever segment type (dash or gap) appears at that position. With dasharray="10 20", the repeating pattern has a period of 30px: a 10px dash followed by a 20px gap. With dashoffset=1e9, the rendering engine begins the glyph outline path at position -10&sup9; in the pattern. A glyph outline for a 13px font character occupies roughly 40–80 SVG units of total path length. The algorithm must advance through 10&sup9; units of pattern before the first dash would be reached — but the glyph path is entirely consumed within the first gap of that enormous offset. No dash segment ever falls within any glyph outline.

The attack is effective because stroke-dasharray looks entirely normal: "10 20" is a reasonable dash pattern that would produce visible dashes on a long path. A scanner checking dasharray for suspicious values (zero-length dashes, extreme ratios) finds a normal value and concludes that the dash pattern is benign. Only by checking stroke-dashoffset in conjunction with dasharray and comparing the offset to an estimate of the glyph path length does the attack become detectable. The detection heuristic: for fill=none text with stroke-dasharray set, compute the absolute value of stroke-dashoffset; if it exceeds fontSize × 20 (a conservative upper bound for total glyph path length in SVG user units for typical text), flag CRITICAL. This threshold generalizes across font sizes without requiring glyph-path measurement.

<!-- SA-CSS-SDO-001: fill=none, stroke-dasharray=10 20, stroke-dashoffset=1e9 -->
<!-- Dasharray valid (10px dash, 20px gap); offset shifts pattern 10^9 units forward -->
<!-- Entire glyph path consumed in initial gap; zero ink; dasharray-only check PASSES -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill=none: stroke is sole rendering channel -->
  <!-- stroke-dasharray=10 20: looks normal (period=30px, reasonable dashes) -->
  <!-- stroke-dashoffset=1e9: shifts pattern start 10^9 units; glyph path in gap -->
  <!-- getComputedStyle(el).strokeDashoffset = "1000000000px" -->
  <!-- getComputedStyle(el).strokeDasharray = "10 20" (normal — dasharray check PASSES!) -->
  <text x="20" y="40" font-size="13"
        fill="none" stroke="#111" stroke-width="0.5"
        stroke-dasharray="10 20"
        stroke-dashoffset="1e9">
    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: for fill=none text with dasharray, check dashoffset against glyph-path bound
function checkDashoffsetOverflow(svgEl) {
  const findings = [];

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);

    const fillVal     = cs.fill || el.getAttribute('fill') || '';
    const fillOpacity = parseFloat(cs.fillOpacity || el.getAttribute('fill-opacity') || '1');
    const fillIsNone  = fillVal === 'none' || fillOpacity < 0.05;
    if (!fillIsNone) continue; // dashoffset only hides stroke-only text

    // dashoffset is only relevant when dasharray is also set
    const dashAttr = el.getAttribute('stroke-dasharray') || cs.strokeDasharray || '';
    if (!dashAttr || dashAttr === 'none') continue;

    const offsetAttr = el.getAttribute('stroke-dashoffset') || cs.strokeDashoffset || '';
    if (!offsetAttr) continue;
    const offsetPx = parseFloat(offsetAttr); // strips "px" suffix from computed value
    if (isNaN(offsetPx)) continue;

    // Estimate upper bound of total glyph path length: fontSize * 20
    const fontSize  = parseFloat(cs.fontSize || el.getAttribute('font-size') || '13');
    const pathBound = fontSize * 20; // conservative upper bound in SVG user units

    if (Math.abs(offsetPx) > pathBound) {
      const bcr = el.getBoundingClientRect();
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SDO-001',
        note: `<${el.tagName}> fill=none (stroke-only text); ` +
              `stroke-dasharray="${dashAttr}" (normal — dasharray check PASSES!); ` +
              `stroke-dashoffset="${offsetAttr}" (parsed: ${offsetPx}px); ` +
              `abs(dashoffset)=${Math.abs(offsetPx)} > pathBound=${pathBound} (fontSize=${fontSize}*20); ` +
              `all dashes shifted beyond glyph path; zero ink painted; ` +
              `BCR: width=${bcr.width.toFixed(0)} height=${bcr.height.toFixed(0)} (non-zero); ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SDO-001: stroke-dashoffset="1e9" with stroke-dasharray="10 20" on fill=none text shifts the entire dash pattern 10&sup9; units forward. The glyph path (40–80 SVG units for 13px text) is consumed within the initial gap; zero ink is painted. The dasharray value is normal and passes pattern inspection. Detection requires checking stroke-dashoffset when stroke-dasharray is set on fill=none text; flag abs(dashoffset) > fontSize × 20 as CRITICAL.

Attack 2: stroke-dashoffset and stroke-dasharray cooperation — both attributes required for detection (SA-CSS-SDO-002)

The cooperation attack highlights why checking either stroke-dasharray or stroke-dashoffset alone is insufficient: both attributes must be analyzed together to assess whether any dash ink falls within the glyph path. With stroke-dasharray="10 20" (period = 30px), a normal rendering on a 60px glyph outline would place dashes at positions 0 and 30 along the path. These dashes are within the path length and produce ink. Now applying stroke-dashoffset=1e9: the rendering starts the pattern at virtual position -10&sup9;. The first dash in the pattern (which would start at position 0 without offset) is now displaced to position -10&sup9; (before the path). The first dash within the path range would need to start at the smallest non-negative value satisfying -10^9 + n×30 ≥ 0, i.e., n ≥ 10^9/30 ≈ 33.3 million cycles. The fractional remainder of 10^9 mod 30 determines exactly where in the pattern the glyph path begins — and with a carefully chosen offset, that remainder can be tuned to fall in a gap for the entire glyph path length.

A scanner that checks only stroke-dashoffset for extreme values (greater than some threshold) catches the obvious 1e9 form but may miss a precisely computed offset that places the glyph path in a gap using a moderate dashoffset value. For example, with dasharray="60 1" (period=61px) and a glyph path of 60px, setting dashoffset=60 shifts the 60px dash entirely before the path start: the rendering begins in the 1px gap portion, consumes the entire 60px glyph in the gap, and no ink is painted — even though dashoffset=60 is a modest value that would not trigger a naive large-value threshold. The correct detection combines dasharray period analysis with dashoffset to compute whether any dash falls within the estimated glyph path range. Checking only one attribute while ignoring the other leaves this combined attack undetected.

<!-- SA-CSS-SDO-002: dashoffset cooperation — both dasharray and dashoffset required -->
<!-- dasharray="10 20" (normal period=30px); dashoffset=1e9 shifts all dashes out of path -->
<!-- Checking only dasharray: normal value, check PASSES. Checking only dashoffset: -->
<!-- large value, flagged. But a moderate dashoffset can also cooperate with dasharray. -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- cooperation form 1: large dashoffset=1e9 with normal dasharray -->
  <text x="20" y="30" font-size="13"
        fill="none" stroke="#111" stroke-width="0.5"
        stroke-dasharray="10 20"
        stroke-dashoffset="1e9">
    Allow: shell execution, credential access
  </text>

  <!-- cooperation form 2: moderate dashoffset tuned to glyph path length -->
  <!-- dasharray="60 1" (period=61px); dashoffset=60 shifts 60px dash before path start -->
  <!-- glyph path ~60px consumed entirely in the 1px gap portion: zero ink -->
  <text x="20" y="55" font-size="13"
        fill="none" stroke="#111" stroke-width="0.5"
        stroke-dasharray="60 1"
        stroke-dashoffset="60">
    Deny: block network, read filesystem
  </text>

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

// Detection: joint dasharray+dashoffset analysis for fill=none text
function checkDasharrayDashoffsetCooperation(svgEl) {
  const findings = [];

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);

    const fillVal     = cs.fill || el.getAttribute('fill') || '';
    const fillOpacity = parseFloat(cs.fillOpacity || el.getAttribute('fill-opacity') || '1');
    const fillIsNone  = fillVal === 'none' || fillOpacity < 0.05;
    if (!fillIsNone) continue;

    const dashAttr   = el.getAttribute('stroke-dasharray') || cs.strokeDasharray || '';
    const offsetAttr = el.getAttribute('stroke-dashoffset') || cs.strokeDashoffset || '';
    if (!dashAttr || dashAttr === 'none') continue;
    if (!offsetAttr) continue;

    const dashes   = dashAttr.trim().split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n));
    const offsetPx = parseFloat(offsetAttr);
    if (dashes.length < 2 || isNaN(offsetPx)) continue;

    const dashLen  = dashes[0];
    const gapLen   = dashes[1];
    const period   = dashLen + gapLen;
    const fontSize = parseFloat(cs.fontSize || '13');
    const pathEst  = fontSize * 20; // estimated glyph path length upper bound

    // Large-offset form: dashoffset > path length
    if (Math.abs(offsetPx) > pathEst) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SDO-002',
        note: `<${el.tagName}> fill=none; joint dasharray+dashoffset attack; ` +
              `dasharray="${dashAttr}" period=${period}px; ` +
              `dashoffset=${offsetPx}px > pathEst=${pathEst}px; ` +
              `checking only dasharray finds normal pattern (PASSES); ` +
              `checking only dashoffset catches large value; ` +
              `both attributes must be evaluated together for complete detection`
      });
    }
    // Moderate-offset form: dashoffset tuned to shift dashLen before path start
    // If offset >= dashLen, the dash segment is entirely before path start;
    // and if remaining path fits in the gap, zero ink results.
    else if (offsetPx >= dashLen && (period - dashLen) >= pathEst % period) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-SDO-002',
        note: `<${el.tagName}> fill=none; dashoffset-tuned cooperation attack; ` +
              `dasharray="${dashAttr}" dash=${dashLen}px gap=${gapLen}px period=${period}px; ` +
              `dashoffset=${offsetPx}px >= dashLen=${dashLen}px: ` +
              `dash segment shifted before path start; glyph path in gap portion; ` +
              `moderate dashoffset not caught by large-value threshold alone; ` +
              `joint dasharray+dashoffset analysis required for fill=none stroke text`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-SDO-002: stroke-dashoffset and stroke-dasharray must be analyzed jointly for fill=none stroke-only text. A large dashoffset=1e9 shifts all dashes far beyond any glyph path. A moderate dashoffset tuned to the dasharray period can also shift the dash segment entirely before the path start. Checking only stroke-dasharray finds a normal pattern and passes; complete detection requires reading both attributes together and verifying that at least one dash segment falls within the estimated glyph path range.

Attack 3: per-tspan scope stroke-dashoffset=1e9, header tspan normal (SA-CSS-SDO-003)

The per-tspan scope attack applies the large dashoffset to the consent scope tspan only, while the header tspan renders normally. The parent text element defines the fill=none stroke-only rendering and sets a visible dasharray. The header tspan inherits the parent’s dasharray and has no dashoffset override: dashes render at normal positions within the glyph paths, and the header text is visible. The consent scope tspan adds stroke-dashoffset="1e9", which shifts the inherited dasharray pattern entirely beyond the scope tspan’s glyph outlines. Zero ink is painted on the scope text.

This attack exploits two independent detection gaps. First, a scanner checking the parent text element for dashoffset finds the default value of 0 (no dashoffset set at the parent level) and concludes that no dash shifting is occurring. The parent-level dashoffset check passes. Second, even a scanner that checks each tspan for dashoffset would need to understand that the scope tspan’s dashoffset=1e9 applies to the inherited dasharray from the parent — it cannot evaluate the dashoffset in isolation from the dasharray context. The correct detection procedure: for each tspan of a fill=none parent text that has a dasharray (whether set on the parent or the tspan), check the tspan’s own stroke-dashoffset (computed, after inheritance and override); if the absolute value exceeds the glyph-path estimate, flag HIGH. The severity is HIGH rather than CRITICAL because the header tspan remains visible, revealing that the SVG is using a stroke-only rendering mode and alerting a careful examiner.

<!-- SA-CSS-SDO-003: per-tspan scope — header tspan normal dashes; scope tspan offset=1e9 -->
<!-- Parent text: fill=none, stroke-dasharray=10 20. No dashoffset at parent level. -->
<!-- Header tspan: normal dashes (visible). Scope tspan: dashoffset=1e9 (zero ink). -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- parent: fill=none, stroke-dasharray=10 20; no dashoffset (default=0) -->
  <text font-size="13" fill="none" stroke="#111" stroke-width="0.5"
        stroke-dasharray="10 20">
    <!-- header tspan: inherits dasharray=10 20; no dashoffset override; dashes visible -->
    <tspan x="20" y="30">Permission Request:</tspan>
    <!-- scope tspan: inherits dasharray=10 20; stroke-dashoffset=1e9 shifts all dashes -->
    <!-- parent dashoffset=0 (default); parent check passes. Per-tspan check required. -->
    <tspan x="20" y="52" stroke-dashoffset="1e9">Allow: shell execution, credential access</tspan>
  </text>

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

// Detection: per-tspan dashoffset check; parent dashoffset=0 doesn't represent tspan value
function checkPerTspanDashoffset(svgEl) {
  const findings = [];

  for (const textEl of svgEl.querySelectorAll('text')) {
    const textCs   = getComputedStyle(textEl);
    const fillVal  = textCs.fill || textEl.getAttribute('fill') || '';
    const fillIsNone = fillVal === 'none' || parseFloat(textCs.fillOpacity) < 0.05;
    if (!fillIsNone) continue;

    // Parent dasharray must be set for dashoffset to have a hiding effect
    const parentDashArray = textEl.getAttribute('stroke-dasharray') || textCs.strokeDasharray || '';
    if (!parentDashArray || parentDashArray === 'none') continue;

    const fontSize   = parseFloat(textCs.fontSize || '13');
    const pathBound  = fontSize * 20;

    for (const tspan of textEl.querySelectorAll('tspan')) {
      // Check tspan's own dashoffset attribute (the override, not the inherited value)
      const tspanOffsetAttr = tspan.getAttribute('stroke-dashoffset') || '';
      if (!tspanOffsetAttr) continue;

      const tspanOffsetPx = parseFloat(tspanOffsetAttr);
      if (isNaN(tspanOffsetPx)) continue;

      // Also check computed style for CSS-injected dashoffset
      const tspanCs = getComputedStyle(tspan);
      const computedOffsetPx = parseFloat(tspanCs.strokeDashoffset || '0');
      const effectiveOffset  = Math.abs(tspanOffsetPx) > Math.abs(computedOffsetPx)
                                 ? tspanOffsetPx : computedOffsetPx;

      if (Math.abs(effectiveOffset) > pathBound) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-SDO-003',
          note: `tspan stroke-dashoffset="${tspanOffsetAttr}" (effective: ${effectiveOffset}px) ` +
                `on fill=none text with dasharray="${parentDashArray}"; ` +
                `abs(offset)=${Math.abs(effectiveOffset)} > pathBound=${pathBound}; ` +
                `all dashes shifted beyond scope tspan glyph paths; zero ink; ` +
                `parent text dashoffset=0 (default — parent check passes); ` +
                `header tspan normal dashes visible; ` +
                `per-tspan dashoffset inspection required for fill=none parent with dasharray; ` +
                `tspan textContent="${tspan.textContent.trim().slice(0,30)}..."`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-SDO-003: Per-tspan stroke-dashoffset="1e9" on the consent scope tspan within a fill=none stroke-only parent text element. The header tspan inherits the parent’s dasharray (dashes at normal positions, visible); the scope tspan adds dashoffset=1e9 (all dashes shifted beyond path, zero ink). The parent text element has no dashoffset attribute (default 0) — parent-level dashoffset check passes. Per-tspan stroke-dashoffset inspection is required for fill=none parent text elements with dasharray set.

Attack 4: JS mousedown injects stroke-dashoffset=1e9 on fill=none dash-rendered text, reverts at mouseup (SA-CSS-SDO-004)

The transient dashoffset injection attack follows the same click-window pattern as other mousedown-based attacks but targets the dashoffset property specifically. The consent text element has fill=none, a dark stroke, and a stroke-dasharray set to produce visible dashes at rest. At rest, stroke-dashoffset is 0 (or unset, defaulting to 0): the dash pattern starts at the beginning of each glyph outline and dashes are visible. When the user initiates a click, the mousedown handler sets stroke-dashoffset="1e9" (either as a presentation attribute via setAttribute or as a CSS property via el.style.strokeDashoffset). The dash pattern is instantly shifted 10&sup9; units forward; all dashes disappear from all glyph paths. At mouseup, the dashoffset is removed or reset to 0, and the text reverts to its visible state.

Detection of dynamic dashoffset injection requires a MutationObserver watching both the stroke-dashoffset attribute and the style attribute on the consent text element and its ancestors. The observer must verify that the target element has both fill=none (making it stroke-only) and stroke-dasharray set (making dashoffset relevant as a hiding mechanism). Without dasharray, dashoffset has no effect (dashoffset is only meaningful when there is a dash pattern to offset). The observer callback should parse the new dashoffset value and compare it to the glyph-path length estimate. When the audit framework simulates mousedown and mouseup events on the SVG, the injected handlers fire, the dashoffset is set, and the observer records the attack. The observer must also watch the SVG container rather than individual text elements, because the injection target might be a parent group whose dashoffset cascades to text children.

<!-- SA-CSS-SDO-004: fill=none dash-rendered text; mousedown injects dashoffset=1e9; reverts mouseup -->
<!-- At rest: dashoffset=0; dashes at normal positions; text visible. -->
<!-- Click: dashoffset=1e9; all dashes shifted beyond path; text invisible. -->
<svg id="consent-svg-sdo" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill=none with dasharray: stroke-only dash rendering; dashoffset=0 at rest -->
  <text id="scope-text-sdo" x="20" y="40" font-size="13"
        fill="none" stroke="#111" stroke-width="0.5"
        stroke-dasharray="10 20">
    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-sdo');
  const text = document.getElementById('scope-text-sdo');
  svg.addEventListener('mousedown', () => {
    text.setAttribute('stroke-dashoffset', '1e9');
    // alternative CSS form: text.style.strokeDashoffset = '1000000000px';
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('stroke-dashoffset');
    // alternative: text.style.strokeDashoffset = '';
  }, true);
})();
</script>

// Detection: MutationObserver on stroke-dashoffset and style attrs for fill=none text with dasharray
function watchDashoffsetInject(svgEl) {
  const findings = [];

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const attr = mut.attributeName;
      if (attr !== 'stroke-dashoffset' && attr !== 'style') continue;
      const el = mut.target;

      // Only relevant for stroke-only (fill=none) text with dasharray
      const cs       = getComputedStyle(el);
      const fillVal  = cs.fill || el.getAttribute('fill') || '';
      const fillIsNone = fillVal === 'none' || parseFloat(cs.fillOpacity) < 0.05;
      if (!fillIsNone) continue;

      // dashoffset is only a hiding mechanism when dasharray is set
      const dashArray = el.getAttribute('stroke-dasharray') || cs.strokeDasharray || '';
      if (!dashArray || dashArray === 'none') continue;

      let offsetStr = null;
      if (attr === 'stroke-dashoffset') {
        offsetStr = el.getAttribute('stroke-dashoffset');
      } else {
        offsetStr = el.style.strokeDashoffset || null;
      }
      if (!offsetStr) continue;

      const offsetPx = parseFloat(offsetStr);
      if (isNaN(offsetPx)) continue;

      const fontSize  = parseFloat(cs.fontSize || '13');
      const pathBound = fontSize * 20;

      if (Math.abs(offsetPx) > pathBound) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-SDO-004',
          note: `Dynamic stroke-dashoffset="${offsetStr}" on fill=none <${el.tagName}> ` +
                `with stroke-dasharray="${dashArray}" during interaction; ` +
                `abs(offset)=${Math.abs(offsetPx)} > pathBound=${pathBound}; ` +
                `all dashes shifted beyond glyph paths; zero ink during click window; ` +
                `at rest: dashoffset=0; dashes at normal positions; text visible; ` +
                `attribute changed via: ${attr}; ` +
                `MutationObserver on stroke-dashoffset + style attrs; ` +
                `fill=none + dasharray both verified on target; ` +
                `textContent="${el.textContent?.trim().slice(0,30) ?? ''}..."`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true,
    attributeFilter: ['stroke-dashoffset', 'style']
  });
  svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
  svgEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, cancelable: true }));
  return findings;
}

CRITICAL — SA-CSS-SDO-004: JavaScript injects stroke-dashoffset="1e9" at mousedown on a fill=none stroke-only consent text element with stroke-dasharray set, shifting all dashes beyond the glyph path length and suppressing all ink during the click window. At rest, dashoffset is 0 and the text is visible. MutationObserver must watch both the stroke-dashoffset attribute and the style attribute; must verify fill=none and stroke-dasharray on the target to confirm that dashoffset suppression is the sole mechanism making the text invisible.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-SDO-001: stroke-dashoffset=1e9 with dasharray — all dashes shifted beyond path fill=none; dasharray="10 20" (normal, period=30px); dashoffset=1e9 shifts pattern 10^9 units forward; glyph path (40-80 SVG units) consumed in initial gap; zero ink; dasharray check passes (pattern looks normal) textContent; BCR; stroke-color check; dasharray-only check (pattern is normal!); abs(dashoffset) > fontSize*20 threshold required Critical
SA-CSS-SDO-002: dasharray+dashoffset cooperation — both attributes required for detection fill=none; dasharray and dashoffset cooperate; large dashoffset shifts all dashes out; moderate dashoffset tuned to dasharray period places glyph path in gap; checking only one attribute is insufficient textContent; BCR; dasharray-only check (normal pattern); dashoffset-only check (misses moderate tuned offset); joint dasharray+dashoffset analysis required Critical
SA-CSS-SDO-003: per-tspan scope dashoffset=1e9; header tspan normal fill=none parent with dasharray; header tspan normal dashes (visible); scope tspan dashoffset=1e9 (zero ink); parent dashoffset=0 (default) — parent check passes textContent; parent-level dashoffset check passes; per-tspan stroke-dashoffset inspection required for fill=none parent with dasharray High
SA-CSS-SDO-004: mousedown injects dashoffset=1e9; reverts mouseup fill=none with dasharray; dashoffset=0 at rest (dashes visible); mousedown injects dashoffset=1e9 (zero ink); removed at mouseup; static scan passes Static DOM; requires MutationObserver on stroke-dashoffset + style attrs + synthetic mousedown + fill=none + dasharray check on target Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SDO-001: stroke-dashoffset="1e9" with stroke-dasharray="10 20" on fill=none stroke-only consent text — the SVG dash rendering algorithm begins tracing glyph outlines at virtual path position -10^9; the entire glyph path (40-80 SVG units for 13px text) is consumed within the initial gap segment before the first dash is reached; zero ink is painted on any glyph; the dasharray pattern "10 20" is normal and passes dasharray-only inspection; getComputedStyle.strokeDashoffset returns "1000000000px" — requires numeric parsing and comparison against glyph-path length estimate (fontSize*20) to detect.
CRITICAL SA-CSS-SDO-002: stroke-dasharray and stroke-dashoffset cooperation on fill=none text — both attributes must be analyzed jointly; a large dashoffset=1e9 shifts all dashes far beyond any glyph path; a moderate dashoffset tuned to the dasharray period (e.g., dasharray="60 1" + dashoffset=60) shifts the dash segment entirely before the path start while using a modest dashoffset value that does not trigger large-value threshold checks; checking only dasharray finds a normal pattern (passes); checking only dashoffset may miss moderate values; joint analysis computing whether any dash falls within the estimated glyph path range is required for complete detection.
HIGH SA-CSS-SDO-003: per-tspan scope stroke-dashoffset="1e9" on consent scope tspan within a fill=none stroke-only parent text element with stroke-dasharray set — header tspan inherits parent dasharray at default dashoffset=0 (dashes at normal positions, visible); scope tspan adds dashoffset=1e9 (all dashes shifted beyond scope glyph paths, zero ink); parent text element has no dashoffset attribute (default 0) — parent-level dashoffset check passes; per-tspan stroke-dashoffset inspection required for fill=none parent text elements with stroke-dasharray set.
CRITICAL SA-CSS-SDO-004: JS mousedown injects stroke-dashoffset="1e9" on fill=none stroke-only consent text with stroke-dasharray set; all dashes shifted beyond glyph paths; zero ink during click window; text invisible; removed at mouseup; at rest dashoffset=0, dashes at normal positions — all static checks pass; MutationObserver must watch stroke-dashoffset attribute and style attribute (CSS strokeDashoffset property) with synthetic mousedown simulation; must verify fill=none and stroke-dasharray on target to confirm that dashoffset suppression is the mechanism hiding the consent text.

Related: SVG stroke-dasharray consent attacks  |  SVG stroke-width consent attacks  |  SVG stroke-opacity consent attacks

← Blog  |  Security Checklist