CVE-2026-65341
Overview
Background
- SVG SMIL animation
- Declarative SVG animation; a page controls attributes like dur (simpleDuration) and repeatCount, which drive timing math in SVGSMILElement.
- clampTo vs static_cast
- static_cast<unsigned> of a huge floating ratio wraps; clampTo<unsigned>() saturates to the max instead, preventing the overflow.
- Repeat-event dispatch
- progress() previously queued one repeat event per skipped iteration; a huge repeat count queued near-unbounded events.
Root Cause Analysis
This fixes an unsigned overflow / unbounded work driven by a page-controlled SMIL repeat count. In SVGSMILElement::calculateAnimationPercentAndRepeat, the repeat count was computed with static_cast<unsigned>(repeatingDuration/simpleDuration) (and activeTime/simpleDuration). For an animation with a tiny simpleDuration and an indefinite/huge repeat, seeking to a large currentTime makes that ratio enormous, and the raw cast overflows/wraps.
The fix uses clampTo<unsigned>(…) for both computations and guards the decrement with if (repeat && !fmod(...)) to avoid underflow when repeat is 0. More importantly, SVGSMILElement::progress() previously dispatched one repeat event per skipped iteration in a for (i = 0; i < repeat - 1; ++i) loop; with an astronomically large repeat this queued a near-unbounded number of events (and repeat-1 underflows when repeat==0), exhausting memory and corrupting state.
The fix coalesces that into a single dispatch: if (repeat > 1 || (repeat && m_activeState == Inactive)) dispatchEventSoon(repeatEvent).
The restored invariants are a bounded, non-wrapping repeat count and O(1) event dispatch regardless of repeat magnitude. The regression test animates dur=0.0001s repeatCount=indefinite and calls setCurrentTime(400000) to force a huge repeat. Established by the diff (advisory: memory corruption).
Attack Path
- Create a fast, indefinitely-repeating SMIL animation The page adds an <animate> with a very small dur (e.g. 0.0001s) and repeatCount=indefinite inside an SVG.
- Seek to a large time Script calls svg.setCurrentTime(400000), making elapsed/simpleDuration an astronomically large repeat count.
- Overflow the repeat count Pre-patch static_cast<unsigned> wraps the huge ratio, and repeat-1 can underflow.
- Dispatch unbounded repeat events progress() loops up to repeat-1 times queueing repeat events, exhausting memory / corrupting state and crashing.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
SVGSMILElement::calculateAnimationPercentAndRepeatSource/WebCore/svg/animation/SVGSMILElement.cpp |
modified | Uses clampTo<unsigned>() instead of static_cast<unsigned>() for the repeat count and guards the decrement with `if (repeat && !fmod(...))` to prevent overflow/underflow of a page-controlled value. |
SVGSMILElement::progressSource/WebCore/svg/animation/SVGSMILElement.cpp |
modified | Replaces the per-iteration `for (i<repeat-1) dispatchEventSoon` loop with a single coalesced repeat-event dispatch, so a huge repeat can no longer queue unbounded events or underflow when repeat==0. |
Files Changed
LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash-expected.txtLayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.htmlSource/WebCore/svg/animation/SVGSMILElement.cpp
Audit Directions
- Page-controlled counts cast to unsignedAudit SVG/animation timing for other static_cast<unsigned> of durations/ratios an author controls; use clampTo and validate against overflow/underflow.
- Per-iteration work scaled by author inputFind loops (event dispatch, allocation) whose iteration count derives from author-controlled repeat/duration values and bound or coalesce them.
Patch
diff --git a/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash-expected.txt b/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash-expected.txt
new file mode 100644
index 000000000000..cd68e3612acf
--- /dev/null
+++ b/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash-expected.txt
@@ -0,0 +1,3 @@
+Passes if it does not crash.
+
+
diff --git a/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html b/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html
new file mode 100644
index 000000000000..c30fa119a3e8
--- /dev/null
+++ b/LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html
@@ -0,0 +1,14 @@
+<body>
+ <p>Passes if it does not crash.</p>
+ <svg id="svg">
+ <rect width="100" height="100" fill="green">
+ <animate attributeName="x" from="0" to="10" dur="0.0001s" repeatCount="indefinite"/>
+ </rect>
+ </svg>
+ <script>
+ if (window.testRunner)
+ testRunner.dumpAsText();
+
+ svg.setCurrentTime(400000);
+ </script>
+</body>
diff --git a/Source/WebCore/svg/animation/SVGSMILElement.cpp b/Source/WebCore/svg/animation/SVGSMILElement.cpp
index 8bd88254df52..8a9d6c1670b1 100644
--- a/Source/WebCore/svg/animation/SVGSMILElement.cpp
+++ b/Source/WebCore/svg/animation/SVGSMILElement.cpp
@@ -1049,12 +1049,13 @@ float SVGSMILElement::calculateAnimationPercentAndRepeat(SMILTime elapsed, unsig
SMILTime activeTime = elapsed - m_intervalBegin;
SMILTime repeatingDuration = this->repeatingDuration();
+ // Clamp the page-controlled repeat count to prevent overflow.
if ((elapsed >= m_intervalEnd && !repeatingDuration.isIndefinite()) || activeTime > repeatingDuration) {
- repeat = static_cast<unsigned>(repeatingDuration.value() / simpleDuration.value());
- if (!fmod(repeatingDuration.value(), simpleDuration.value()))
+ repeat = clampTo<unsigned>(repeatingDuration.value() / simpleDuration.value());
+ if (repeat && !fmod(repeatingDuration.value(), simpleDuration.value()))
--repeat;
} else
- repeat = static_cast<unsigned>(activeTime.value() / simpleDuration.value());
+ repeat = clampTo<unsigned>(activeTime.value() / simpleDuration.value());
double percent;
if (elapsed >= m_intervalEnd || activeTime > repeatingDuration) {
@@ -1187,16 +1188,9 @@ bool SVGSMILElement::progress(SMILTime elapsed, SVGSMILElement& firstAnimation,
if (m_activeState == Inactive || m_activeState == Frozen)
smilEventSender().dispatchEventSoon(*this, eventNames().endEventEvent);
- if (repeat) {
- // We intentionally dispatch repeat - 1 events here because the first repeat
- // event (for the initial loop) is sent elsewhere during continuous animation run.
- // If repeat == 1, no events are dispatched here.
- for (unsigned i = 0; i < repeat - 1; ++i)
- smilEventSender().dispatchEventSoon(*this, eventNames().repeatEventEvent);
-
- if (m_activeState == Inactive)
- smilEventSender().dispatchEventSoon(*this, eventNames().repeatEventEvent);
- }
+ // Coalesce the skipped repeat iterations into a single event instead of one per interval.
+ if (repeat > 1 || (repeat && m_activeState == Inactive))
+ smilEventSender().dispatchEventSoon(*this, eventNames().repeatEventEvent);
}
m_nextProgressTime = calculateNextProgressTime(elapsed);