MCP server CSS stroke-dashoffset security: SVG consent text gap-shift invisibility, precise word targeting, progress-bar timing attack, and negative offset reversal
Published 2026-09-26 — SkillAudit Research
CSS stroke-dashoffset shifts the starting position of the stroke-dasharray pattern along an SVG element's path. For a simple shape or line, this shifts which portion of the path is rendered as a dash versus a gap. For SVG <text> elements, the "path" is the cumulative outline of all glyph paths — each character contributes a segment of the total path. When fill: transparent is combined with a non-zero stroke-dasharray and a stroke-dashoffset large enough to shift all character glyph paths into gap phases, the text element renders no visible pixels despite having non-zero dash lengths in its dasharray.
This attack is a significant advancement over simple zero-dasharray attacks (covered in the stroke-dasharray security page) because dasharray-only auditors that check for non-zero dash values will pass an element with stroke-dasharray: 10, 5. Only by also checking stroke-dashoffset relative to the element's path length can an auditor determine whether any dash actually falls within a character glyph's path segment.
Dasharray vs dashoffset attacks: A dasharray-only check confirms that the dasharray pattern includes non-zero dash lengths. A dashoffset attack moves the entire pattern so that all dashes land in gaps between characters, while all gaps land over character glyph paths. Both checks are necessary: non-zero dasharray AND dashoffset within the path length. Auditors that only check dasharray will miss dashoffset-based invisibility attacks.
Attack findings
An SVG text element has
fill: transparent, stroke: #333, stroke-dasharray: 10, 5 (non-zero dash lengths — passes dasharray check), and stroke-dashoffset: 9999. The offset of 9999 shifts the pattern start by 9999 units along the path. For any typical consent sentence (e.g., 50 characters at 7px average width = ~350px total path length), an offset of 9999 places the pattern start far past the end of the actual path — all glyph paths fall within the offset's gap phase. Zero visible stroke pixels are rendered. The text is invisible. An auditor checking strokeDasharray finds "10, 5" (non-zero) and reports clean. Only checking strokeDashoffset against the computed path length reveals the attack.
<text style=" fill: transparent; stroke: #333; stroke-width: 1; stroke-dasharray: 10, 5; /* non-zero dash — passes dasharray check */ stroke-dashoffset: 9999; /* shifts all glyphs into gap phase — invisible */ "> By clicking Accept, you agree to the above terms. </text> <!-- strokeDasharray: "10, 5" → non-zero dash → PASS (incorrect) strokeDashoffset: "9999" → vs path length ~350px → 9999 >> 350 → all gaps textContent: full consent string → DOM check: PASS (incorrect) getBoundingClientRect(): positive rect → PASS (incorrect) Rendered pixels: 0 — dashoffset-only check reveals this -->
An MCP server calculates the approximate path length of the authorization framing portion of the consent string ("By clicking Accept, you agree to") and sets
stroke-dashoffset to that value. With a dasharray of fill-sentence-length, 0 (all dash, no gap) combined with the offset, the visible portion starts exactly at "binding arbitration in Delaware" — the acceptance clause. The framing sentence is in the gap-offset phase (invisible); the acceptance clause is in the dash phase (visible). Users see only "binding arbitration in Delaware" without the "By clicking Accept, you agree to" framing that establishes the consent. The partial visibility makes this harder to detect than full invisibility.
<!-- Approximate path length of "By clicking Accept, you agree to " = 240px --> <text style=" fill: transparent; stroke: #333; stroke-width: 1; /* Offset = length of framing sentence path */ stroke-dashoffset: 240; /* Dasharray: long dash (shows everything after offset), no gap */ stroke-dasharray: 9999, 0; "> By clicking Accept, you agree to binding arbitration in Delaware. </text> <!-- Visible: "binding arbitration in Delaware." Invisible: "By clicking Accept, you agree to " textContent: complete string → PASS (incorrect) Rendered: only acceptance clause — framing context removed -->
Animated
stroke-dashoffset from 0 to the total path length is a standard SVG progress-bar or text-reveal animation used in legitimate UI design. An MCP server exploits this pattern by timing the animation so that the dashoffset is at or beyond the path length exactly when the user is expected to click the Accept button. At load time, consent text is fully visible (offset = 0). As the user reads, the animation progresses. When the 2-second reading window expires, the animated accept button activates — and the consent text's dashoffset has reached the path length, shifting all text into the gap phase. At click time, the text is invisible. A snapshot audit at load time sees offset = 0 and reports clean.
@keyframes progress-consent {
from { stroke-dashoffset: 0; } /* visible at load */
to { stroke-dashoffset: 350; } /* ≈ path length → invisible at end */
}
<text style="
fill: transparent;
stroke: #333;
stroke-dasharray: 350, 0; /* all dash = fully visible at offset:0 */
animation: progress-consent 5s linear forwards;
">
By clicking Accept, you agree to the above terms.
</text>
<!-- t=0: visible (offset:0, all dashes aligned with glyphs)
t=2s: partially visible (normal reading time, appears as progress)
t=5s: invisible (offset:350 ≈ path length, all gaps over glyphs)
Accept button activates at t=5s → user clicks when text is invisible -->
The SVG specification explicitly allows negative values for
stroke-dashoffset. A negative offset shifts the pattern start in the reverse direction along the path. For circular or closed-path SVG elements, this is equivalent to shifting forward by the total path length minus the absolute value. For open paths like text glyphs, a large negative offset has the same practical effect as a large positive offset: the pattern phase at each glyph is shifted by the offset amount, potentially placing all glyph paths in gap phases. An auditor that only checks whether strokeDashoffset is positive may miss negative-offset attacks.
<text style=" fill: transparent; stroke: #333; stroke-dasharray: 10, 5; stroke-dashoffset: -9999; /* negative — shifts reverse direction */ <!-- At glyph path starting position P, effective phase = P - (-9999) = P + 9999 P + 9999 >> total path length → all in gap phase → invisible Spec: "Negative values are permitted." Auditor checking offset > 0: -9999 < 0 → not flagged → PASS (incorrect) Correct check: abs(strokeDashoffset) relative to path length --> "> By clicking Accept, you agree to the above terms. </text>
Detection
function checkStrokeDashoffset(svgRoot) {
const textEls = svgRoot.querySelectorAll('text, tspan');
const findings = [];
for (const el of textEls) {
if (!el.textContent.trim()) continue;
const cs = getComputedStyle(el);
const fill = cs.fill || '';
const fOp = parseFloat(cs.fillOpacity || '1');
const sda = cs.strokeDasharray || '';
const offset = parseFloat(cs.strokeDashoffset || '0');
const fillInvisible = fill === 'none' || fill === 'transparent' ||
fill === 'rgba(0, 0, 0, 0)' || fOp === 0;
if (!fillInvisible || !sda || sda === 'none') continue;
/* Estimate path length from bounding rect width × character count factor */
const bbox = el.getBoundingClientRect();
const approxPathLen = bbox.width * 1.2; /* rough: text path ≈ 1.2× width */
const absOffset = Math.abs(offset);
/* Check 1: offset >> path length → all glyphs in gap phase */
if (absOffset > approxPathLen * 0.8) {
findings.push({
severity: 'high',
el,
issue: `SVG text: fill:transparent + stroke-dashoffset:${offset} (abs=${absOffset.toFixed(0)}) ≥ 80% of estimated path length ${approxPathLen.toFixed(0)}px — likely all glyphs shifted into gap phase`
});
}
/* Check 2: animated dashoffset — check for animation targeting this element */
const animName = cs.animationName || '';
if (animName !== 'none' && offset > 0) {
findings.push({
severity: 'medium',
el,
issue: `SVG text: animated stroke-dashoffset (animation: ${animName}); current offset ${offset} — may be a progress-bar timing attack hiding text at click time`
});
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Check stroke-dashoffset in addition to stroke-dasharray when fill is transparent | A non-zero dasharray passes dasharray-only checks; a large dashoffset can shift all glyphs into the gap phase, making text invisible even with non-zero dash lengths |
| Check absolute value of dashoffset — the specification permits negative values | Negative dashoffset shifts in reverse direction with the same practical effect; auditors checking only positive offsets miss negative-offset attacks |
Flag animated stroke-dashoffset on SVG consent elements | The progress-bar animation pattern is legitimate for non-consent elements; on consent text it can time the invisible transition to coincide with the expected user click |
| Estimate SVG text path length and compare absolute dashoffset against it | An offset greater than approximately 80% of the path length is a reliable signal that the offset is being used to place all glyphs in gap phases |
SkillAudit audits SVG stroke properties on consent elements — including stroke-dasharray, stroke-dashoffset, stroke-opacity, and stroke-width — checking for gap-phase attacks, timing attacks, and word-precise masking. Run a free audit on any MCP server GitHub URL.