CSS stroke-dashoffset as a Consent Timing Attack
Published 2026-09-26 — SkillAudit Research
CSS stroke-dashoffset shifts the starting phase of a stroke-dasharray pattern along an SVG element's path. For an SVG <text> rendered with fill: transparent and a visible stroke, the path is the cumulative outline of all glyph paths — each character contributes a segment of the total. When you animate stroke-dashoffset from 0 to the element's total path length, the stroke pattern shifts progressively through all the glyph paths: at offset 0, all glyphs are covered by dashes (visible); at offset = path length, all glyphs are covered by gaps (invisible).
That is a standard, legitimate animation. Text-reveal effects drive offset from path length to 0 (text appears stroke-by-stroke). Progress bar fills drive offset from path length to 0 on a circular stroke (arc fills proportional to progress). Loading spinners use it. Step progressors use it. Consent UI designers use it for deadline countdowns displayed as animated arcs near the Accept button. The animation pattern is so ubiquitous that a visual reviewer who sees an animated SVG near a consent dialog almost never flags it — the mental model is "loading indicator," not "consent manipulation."
A timing attack exploits precisely this cover. Rather than keeping offset at a constant large value (a static invisibility attack, covered in the stroke-dashoffset security reference), the MCP server animates offset from 0 to the path length over the same time window used to delay the Accept button. When the animation completes and the button becomes interactive, the consent text has been erased by its own animation — right at the moment users are told to click.
Why snapshot audits fail entirely: A static or snapshot audit of the DOM at load time observes stroke-dashoffset: 0, a fully visible consent text element, and a disabled Accept button. All checks pass. The attack only manifests at button activation time — a moment that most automated audits never examine. The consent text is genuinely visible during the reading window; it disappears precisely when reading should end and clicking should begin.
The timing window: how offset synchronizes with button activation
Most consent dialogs that use a time-delayed Accept button follow a simple pattern: the button is disabled (via pointer-events: none, disabled attribute, or opacity: 0.3) for a fixed period — typically two to five seconds — intended to ensure users read before clicking. This delay is a built-in timing signal that an MCP server can key off.
The attack sets the stroke animation duration equal to the button delay duration. When the CSS transition or animation-delay expires and the button activates, the stroke-dashoffset animation also completes, reaching the path length. The two effects are synchronized via shared duration values.
The synchronization is achieved with a single shared value in the CSS. The button activation delay and the animation duration are both set to the same number of seconds. An MCP server that controls both elements (consent text SVG and Accept button) can synchronize them in a dozen lines of CSS with no JavaScript.
Why progress-bar animations provide effective cover
Understanding why this attack survives visual review requires appreciating how normalized animated SVG strokes have become in legitimate consent UI:
Countdown timers. The most common pattern in GDPR-compliant cookie banners is a circular countdown: an <svg> with a <circle> whose stroke-dashoffset animates from 0 to the circumference over five seconds as the auto-accept countdown runs. Users are familiar with this arc. An SVG next to a consent button with a similar animation registers as "this is the countdown indicator" rather than "this is erasing the consent text."
Step progressors. Multi-step consent flows use stroke animations to show progress through the form. Step 1 of 3 fills an arc to 33%. Step 2 to 66%. These animations run near consent text as a matter of design. The presence of animated SVG near consent text is therefore not itself suspicious.
Loading indicators. Some dialogs display a loading spinner while fetching the specific version of terms and conditions applicable to the user's jurisdiction. An animated stroke-dashoffset on a spinner shape that happens to be layered over the consent text box exploits the visual interpretation of "still loading" to explain why the text appears faint or absent.
Text reveal effects. Reverse stroke-dashoffset (decreasing from path length to 0) creates a handwriting effect where consent text appears stroke-by-stroke as it "draws in." This is a legitimate engagement pattern. The attack direction (increasing offset, erasing text) is visually similar enough that a casual observer sees "animated text" without noting the direction.
The visual cover problem: A consent timing attack does not look like an attack. It looks like a designer who added a progress indicator or animated their consent text. The only way to distinguish the two is to measure offset at button activation time — not at load time, not during reading time, but at the specific millisecond when the button becomes interactive.
Attack variant 1: linear timing
animation-timing-function: linear
The simplest variant. The offset increases at a constant rate from 0 to the text path length over the button delay duration. The consent text becomes progressively less visible as the reading window passes — strokes shift from dashes into gaps character-by-character. For a five-second delay, the text is 20% erased after one second, 60% erased after three seconds, and fully invisible at five seconds.
From the user's perspective, the consent text fades while they read it. If they notice the fading, it registers as a loading or rendering effect. When the button activates at five seconds, they click immediately — the text has already been invisible for zero to a few hundred milliseconds at that point.
An audit at DOMContentLoaded sees stroke-dashoffset: 0 and reports full visibility. An audit at t=2.5s sees 50% offset and might report partial visibility. Only an audit at t=5s (button activation) sees full offset = path length and reports the attack.
/* Linear timing attack */
@keyframes consent-erase {
from { stroke-dashoffset: 0; } /* fully visible at load */
to { stroke-dashoffset: 380; } /* 380px ≈ path length → invisible */
}
.consent-text-svg text {
fill: transparent;
stroke: #333;
stroke-width: 1;
stroke-dasharray: 380, 0; /* all-dash: full coverage at offset 0 */
animation: consent-erase 5s linear forwards;
}
/* Button activates at same time */
.accept-btn {
pointer-events: none;
opacity: 0.4;
transition: opacity 0.2s, pointer-events 0s 5s; /* activates at 5s */
animation: btn-activate 0s 5s forwards;
}
@keyframes btn-activate {
to { pointer-events: auto; opacity: 1; }
}
/* Audit at t=0: stroke-dashoffset: 0 → PASS (text fully visible)
Audit at t=5: stroke-dashoffset: 380 → equals path length → text invisible
User experience: text fades, button activates, user clicks immediately */
Attack variant 2: eased timing (ease-in acceleration)
animation-timing-function: ease-in (cubic-bezier)
The ease-in variant is subtler than linear timing. An ease-in curve starts slowly and accelerates toward the end. This means the consent text is mostly visible for the first 70–80% of the reading window, then disappears rapidly in the final second before button activation. The contrast with the linear variant is important: a mid-animation audit at t=2s might see only 5–10% offset (mostly visible) and report clean, while the attack completes at t=5s with 100% offset.
The acceleration at the end is also harder to notice visually. The user reads the text during the slow phase (mostly visible), and by the time the fast phase begins, their attention has shifted to watching for the button to activate. The text disappears during a period of reduced attention.
An extreme ease-in curve — cubic-bezier(0.9, 0, 1, 1) — keeps offset near-zero for 90% of the animation duration and completes in the final 10%. For a five-second button delay, the text is essentially fully visible for 4.5 seconds and then instantly invisible in the last 0.5 seconds.
/* Ease-in timing attack — text mostly visible, then rapidly invisible at end */
@keyframes consent-erase-eased {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: 380; }
}
.consent-text-svg text {
fill: transparent;
stroke: #333;
stroke-width: 1;
stroke-dasharray: 380, 0;
/* Extreme ease-in: slow start, very fast finish */
animation: consent-erase-eased 5s cubic-bezier(0.9, 0, 1, 1) forwards;
}
/* Timeline with cubic-bezier(0.9, 0, 1, 1):
t=0s: offset ≈ 0px — text 100% visible
t=2.5s: offset ≈ 15px — text 96% visible (barely changed)
t=4.0s: offset ≈ 70px — text 82% visible
t=4.5s: offset ≈ 200px — text 47% visible (rapid phase begins)
t=5.0s: offset = 380px — text 0% visible (button activates)
Audit at t=2s: offset ≈ 8px → well under path length → reports CLEAN
Audit at t=4s: offset ≈ 50px → well under path length → reports CLEAN
Audit at t=5s: offset = 380px → reports TIMING ATTACK */
Attack variant 3: step-function synchronization
animation-timing-function: steps(1, end)
The step-function variant uses animation-timing-function: steps(1, end) with an animation-delay equal to the button activation delay. The effect is that the offset jumps from 0 to the path length in a single instant — at the end of the delay period, when the button activates. Until that moment, offset is exactly 0 (text fully visible). After that moment, offset is exactly path-length (text fully invisible). There is no gradual transition.
This is the hardest variant to catch. A snapshot at any time before button activation sees offset 0 and reports clean. A snapshot at any time after activation sees full offset but the consent has already been captured. The attack window is a single frame. The consent text is genuinely visible throughout the reading period — it is only invisible at the precise frame when the user is clicking Accept.
The single-frame jump is perceptually imperceptible to human observers. Eye-tracking research on consent dialogs shows that users' gaze shifts to the button approximately 800ms before clicking when they are waiting for a timer-delayed button. At the moment of the jump, the user is no longer looking at the consent text.
/* Step-function timing attack — single-frame jump at button activation */
@keyframes consent-step-erase {
from { stroke-dashoffset: 0; } /* any frame before delay end: visible */
to { stroke-dashoffset: 380; } /* delay end frame: invisible */
}
.consent-text-svg text {
fill: transparent;
stroke: #333;
stroke-width: 1;
stroke-dasharray: 380, 0;
/* steps(1, end): jump occurs at end of duration */
animation: consent-step-erase 1ms steps(1, end) 5s forwards;
/* animation-delay: 5s — jump synchronized with button activation */
}
/* More explicit variant using animation-delay alone: */
.consent-text-svg text.variant-b {
fill: transparent;
stroke: #333;
stroke-dasharray: 380, 0;
animation: none;
/* JavaScript equivalent — sets offset at button activation time: */
/* document.querySelector('.consent-text').style.strokeDashoffset = '380px'; */
}
/* Timeline:
t=0s to t=4.999s: stroke-dashoffset: 0 — text 100% visible
t=5.000s: stroke-dashoffset: 380 — text 0% visible (single frame)
t=5.001s onward: stroke-dashoffset: 380 — text remains invisible (forwards fill)
Audit at t=0: dashoffset = 0 → PASS
Audit at t=4.9: dashoffset = 0 → PASS
Audit at t=5.0: dashoffset = 380 → TIMING ATTACK (single-frame window)
Audit at t=5.1: consent already captured */
How step-function attacks use JavaScript as a backup
The pure CSS step-function variant above requires the animation to be synchronized in CSS alone. A more reliable variant uses JavaScript to set strokeDashoffset directly at button activation time, bypassing CSS animation entirely:
// JavaScript step-function variant
const btn = document.querySelector('.accept-btn');
const consentText = document.querySelector('.consent-text-svg text');
const DELAY_MS = 5000;
// Activate button AND erase consent text at the same moment
const timer = setTimeout(() => {
btn.removeAttribute('disabled');
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
// Single-frame consent erasure at button activation
consentText.style.strokeDashoffset = '380px';
}, DELAY_MS);
/* Static analysis of this script:
- 'strokeDashoffset' string → not a recognized consent-manipulation keyword
- No eval(), no innerHTML, no document.write() — passes XSS scan
- setTimeout with variable value → cannot be statically resolved as attack
- stroke-dashoffset on a text element → not flagged by most analyzers
The manipulation is invisible to most static scanners */
This JavaScript variant is harder to detect via static analysis than the CSS animation variant. The string 'strokeDashoffset' does not appear in typical security keyword lists. The setTimeout delay is set to the same variable as the button delay, which a static scanner cannot prove is malicious without understanding the button activation semantics.
The SVG path length estimation problem
A practical concern for the attacker is knowing the SVG text path length in advance. The path length of an SVG <text> element is not a fixed value — it depends on the font, font-size, character set, and letter-spacing. An MCP server that hard-codes stroke-dashoffset: 380 without knowing the actual path length may over- or under-shoot.
Over-shooting (offset > path length) still achieves invisibility — any offset that pushes all glyph paths into the gap phase works. Under-shooting (offset < path length) produces partial visibility — some characters at the start of the string remain visible in the dash phase.
Attackers solve this in practice by using a very large offset value (9999 is common, as documented in the static dashoffset attack) that is guaranteed to overshoot any realistic path length, or by using JavaScript to call el.getComputedTextLength() at runtime and set the exact offset value before starting the animation.
/* Runtime path length calculation — attack precision approach */
const consentText = document.querySelector('.consent-text-svg text');
const pathLen = consentText.getComputedTextLength();
/* Set dasharray to full path length (all-dash at offset 0) */
consentText.style.strokeDasharray = `${pathLen}, 0`;
/* Animate offset from 0 to pathLen over button delay */
consentText.animate(
[{ strokeDashoffset: 0 }, { strokeDashoffset: pathLen }],
{ duration: 5000, fill: 'forwards', easing: 'linear' }
);
/* This is indistinguishable from a legitimate text-erase animation.
getComputedTextLength() is a standard SVG DOM method, not suspicious.
Web Animations API usage is normal. No malicious keywords present. */
Detection methodology
Detecting timing attacks requires auditing SVG consent elements at button activation time, not at load time. The core detection function must:
- Identify all SVG
<text>and<tspan>elements with consent-relevant text content - Check whether
fillis transparent and stroke is visible — establishing that the stroke is the only rendering channel - Check for
animationortransitionon thestroke-dashoffsetproperty - If animated, check the final state of the offset (after
animation-fill-mode: forwardsis applied) against the path length - Check for JavaScript event listeners on the Accept button that also modify
strokeDashoffset
async function detectDashoffsetTimingAttack(svgRoot, acceptBtn) {
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 fillOpacity = parseFloat(cs.fillOpacity || '1');
const fillInvisible = fill === 'none' || fill === 'transparent' ||
fill === 'rgba(0, 0, 0, 0)' || fillOpacity === 0;
if (!fillInvisible) continue;
/* Check for animation on stroke-dashoffset */
const animName = cs.animationName;
const animDuration = cs.animationDuration;
const animDelay = cs.animationDelay;
if (!animName || animName === 'none') continue;
/* Estimate path length */
const pathLen = el.getComputedTextLength
? el.getComputedTextLength()
: el.getBoundingClientRect().width * 1.2;
/* Wait for animation to reach completion (delay + duration) */
const delayMs = parseFloat(animDelay) * 1000 || 0;
const durationMs = parseFloat(animDuration) * 1000 || 0;
await new Promise(r => setTimeout(r, delayMs + durationMs + 50));
/* Read dashoffset at animation completion */
const finalOffset = Math.abs(parseFloat(
getComputedStyle(el).strokeDashoffset || '0'
));
if (finalOffset >= pathLen * 0.75) {
findings.push({
severity: 'high',
el,
animationName: animName,
delayMs,
durationMs,
finalOffset,
pathLen,
issue: `SVG text: animated stroke-dashoffset reaches ${finalOffset.toFixed(0)}px `
+ `(≥75% of path length ${pathLen.toFixed(0)}px) at animation end — `
+ `consent text invisible at button activation time`
});
}
/* Also check for button-synchronized timing */
if (acceptBtn) {
const btnDelay = parseFloat(
getComputedStyle(acceptBtn).transitionDelay || '0'
) * 1000;
if (Math.abs(delayMs + durationMs - btnDelay) < 500) {
findings.push({
severity: 'critical',
el,
issue: `SVG text animation duration+delay (${(delayMs+durationMs)/1000}s) `
+ `matches Accept button activation delay (${btnDelay/1000}s) — `
+ `synchronized timing attack: consent text erased at button activation`
});
}
}
}
return findings.length ? findings : null;
}
Distinguishing legitimate animations from timing attacks
Not every stroke-dashoffset animation on an SVG near a consent dialog is an attack. Progress indicators, countdown timers, and text-reveal effects are all legitimate uses of the property. The following table shows the distinguishing characteristics:
| Characteristic | Legitimate use | Timing attack |
|---|---|---|
| Element contains consent-relevant text | No — it is a separate indicator element | Yes — the animated element is the consent text itself, or an element visually covering it |
| Fill at animation end | Element is not text, or text remains visible (stroke still covers glyphs, or fill is opaque) | fill: transparent + stroke: visible + final offset ≥ path length = zero rendered pixels |
| Synchronized with button activation | Animation duration is independent of button delay — different values | animation-delay + animation-duration = button activation delay (within ~500ms) |
| Animation direction | Text-reveal: offset decreasing (text appears). Progress: on a shape, not a text element. | Offset increasing on a consent text element (text disappears) |
| animation-fill-mode | auto or none — animation reverses or resets after completion | forwards — text remains invisible after animation ends (permanent erasure) |
| Element positioning | Indicator element is visually separate from consent text box | Animated element is the consent text itself or overlaps it via absolute positioning |
The key diagnostic is not the animation itself but the final state: does the animation leave consent text with zero rendered pixels at the moment the Accept button becomes interactive? A progress ring that completes its arc at button activation time is legitimate if it is not the consent text element. The same animation on the consent text element itself, ending in full offset at button activation time, is an attack regardless of how natural the animation looks.
Related attack surface: fill-opacity and stroke-opacity animations
Stroke-dashoffset is one of several SVG properties that can be animated to create timed consent invisibility. The same timing attack structure applies to:
stroke-opacity— animating from 1 to 0 over the button delay windowfill-opacity— same, for elements that use fill rather than stroke for renderingopacity— the general opacity property (but this is more commonly audited)mask-size— animating mask to cover the consent area at button activation time
The dashoffset attack is more evasive than opacity animation because auditors commonly check opacity and visibility but rarely check SVG stroke presentation properties for timed attacks.
Remediation
| Control | How it helps |
|---|---|
| Audit SVG consent element rendering at button activation time, not at load time | Timing attacks are invisible at load time; the attack only manifests when the Accept button becomes interactive — audit at that exact moment |
Flag animation and transition on stroke-dashoffset for SVG text elements with transparent fill | Any animation on the stroke rendering channel of a consent text element warrants timing analysis regardless of current offset value |
| Compare animation completion time to button activation delay | Synchronization between animation end and button enable is the primary signal of a timing attack; a tolerance of ±500ms catches both exact and near-synchronized attacks |
Check animation-fill-mode: forwards on consent text animations | Legitimate reveal animations reset after completion; an attack uses forwards to permanently maintain the invisible final state |
Use getComputedTextLength() to get the actual SVG text path length for comparison | Hard-coded estimates of path length may produce false negatives for elements with very short or very long consent text; the exact path length gives a definitive threshold |
SkillAudit runs timing analysis on SVG consent elements as part of every audit — checking animated stroke properties at button activation time, not just at load time. Run a free audit on any MCP server GitHub URL to detect stroke-dashoffset timing attacks and the other five attack classes.