← All reports

Clamp repeat count for SVG animation.

MediumWebCore SVG SMIL animation timing (IntegerOverflow

CVE: CVE-2026-65341 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: The issue was addressed with improved memory handling. Credit: Henock Habte

Severity: Medium | Component: WebCore SVG SMIL animation timing (SVGSMILElement) | 28da9c5 | Bugzilla 318405

Medium. A sub-millisecond dur plus one setCurrentTime() call turns a timeline seek into a near-2^32-trip loop that allocates on every pass, reachable from any cross-origin iframe. It stays Medium because the only established consumer of the overflowed counter is that loop — not an index, not an allocation size.

SVG's declarative animations do not run on a clock of their own; they read a position off the document timeline and work out arithmetically which iteration they ought to be on right now. SVGSMILElement performs that computation by dividing elapsed time by the author-declared length of one pass and narrowing the quotient into an unsigned iteration counter. The division is bounded only if both operands are — and here the numerator is bounded by the assumption that wall-clock time advances gradually, while the denominator is bounded by nothing at all.

The angle: A page with a sub-millisecond animation duration can jump the timeline forward once and make the renderer allocate on each of billions of loop iterations until it hangs or is killed for memory.

Source/WebCore/svg/animation/SVGSMILElement.cpp

@@ -1080,12 +1080,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) {
@@ -1261,20 +1262,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 = 1; i < repeat; ++i) {
- m_pendingRepeatIterations.append(i);
- smilEventSender().dispatchEventSoon(*this, eventNames().repeatEventEvent);
- }
-
- if (m_activeState == Inactive) {
- m_pendingRepeatIterations.append(repeat);
- 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);

LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html

+<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>

Three changes, two of them in SVGSMILElement.cpp.

SVGSMILElement::calculateAnimationPercentAndRepeat() fills an out-parameter unsigned& repeat from a floating-point quotient of two SMILTime values. Both assignments trade a raw static_cast<unsigned> for clampTo<unsigned>: repeatingDuration.value() / simpleDuration.value() in the branch taken when the interval has ended or the active time has run past the repeating duration, and activeTime.value() / simpleDuration.value() in the still-running branch. The --repeat adjustment beneath — applied when the repeating duration divides evenly into the simple duration — picks up a repeat && guard so the decrement is dominated by a test on the counter itself. The author labels the intent inline: "Clamp the page-controlled repeat count to prevent overflow."

SVGSMILElement::progress() loses its replay loop entirely. The old code ran for (unsigned i = 1; i < repeat; ++i), pushing i onto the m_pendingRepeatIterations vector and queueing a repeatEvent through smilEventSender() on every pass, then appended and dispatched one more time when the element had gone Inactive. All of that collapses into a single dispatchEventSoon() behind if (repeat > 1 || (repeat && m_activeState == Inactive)). Note what the coalesced form does not do: it queues the event without pushing anything onto m_pendingRepeatIterations.

The new LayoutTest is a fourteen-line reproduction — <animate dur="0.0001s" repeatCount="indefinite"> followed by svg.setCurrentTime(400000) — with an expectation file whose entire content is "Passes if it does not crash." The commit also records Originally-landed-as: 305413.1072@safari-7624.5-branch, which is why the shipping date on Apple's advisory precedes the main-branch date on this commit: the fix went out on the Safari branch first and was merged back afterwards.

SMIL animation in SVG. <animate>, <set> and <animateTransform> are declarative animations whose timing is written entirely in markup. dur gives the simple duration — the length of a single pass. repeatCount and repeatDur say how many times, or for how long, that pass repeats; begin and end bound the active interval. WebKit implements the whole timing model in SVGSMILElement, the shared base class for those elements, and SMILTimeContainer drives it on every animation tick.

Simple duration, repeating duration, active time. These three quantities are the arithmetic the timing model runs on. The simple duration is one iteration. The repeating duration is the simple duration multiplied by the repeat count, or indefinite when the author wrote repeatCount="indefinite". The active time is how far the element has progressed since its interval began. The iteration the element is currently on is therefore the active time divided by the simple duration — a quotient, recomputed from scratch each tick, rather than a counter that gets incremented.

SMILTime. WebCore's time type for SMIL. It is a double count of seconds plus a distinguished indefinite state, queried through isIndefinite(). value() hands back the raw double.

Seeking the timeline. SVGSVGElement::setCurrentTime(t) moves the document's SMIL timeline to an arbitrary position in one step. This is a discontinuous jump rather than a gradual advance: the timeline is simply somewhere else on the next line of script. SMILTimeContainer then calls SVGSMILElement::progress() on the scheduled animations to bring each of them up to the new time.

repeatEvent and the event sender. SMIL defines a repeatEvent raised when an element begins a new iteration. WebKit queues these through smilEventSender(), an EventSender — a helper that defers dispatch to a later point rather than firing synchronously — and records the associated iteration index in m_pendingRepeatIterations. ConditionEventListener::handleEvent reads that index back via lastDispatchedRepeatIteration() to resolve conditions of the form begin="other.repeat(3)".

Narrowing a double into an unsigned. Two C++ facts are load-bearing here. First, converting a double whose truncated value is not representable in the destination integer type is an out-of-range conversion: the language does not specify the result, and the machine behaviour differs by ISA — AArch64's fcvtzu saturates to UINT_MAX, while the common x86-64 lowerings of such a conversion yield an unspecified value. Second, unsigned arithmetic wraps modulo 2^32, so decrementing a zero-valued unsigned gives UINT_MAX. clampTo<T>(value) is the WTF helper that converts while saturating at T's bounds, making the narrowing total and deterministic instead of leaning on the language's conversion rules.

This is a ratio of two page-controlled quantities narrowed into a fixed-width counter, with that counter then used as the trip count of a loop whose body allocates.

  Before:                                  After:
  setCurrentTime(400000)                   setCurrentTime(400000)
    └─► activeTime / simpleDuration          └─► activeTime / simpleDuration
          = 4.0e9  (double)                        = 4.0e9  (double)
          └─► static_cast<unsigned>                └─► clampTo<unsigned>
                = UINT_MAX  -or-  unspec.                = UINT_MAX
                └─► for (i = 1; i < repeat; ++i)         └─► one dispatchEventSoon()
                      m_pendingRepeatIterations
                        .append(i)
                      dispatchEventSoon()

Walk the left column. The missing invariant is that the repeat counter must be a bounded, representable iteration count — and before the fix nothing established that bound at either end of the division. The denominator is dur, read straight out of the markup and free to be arbitrarily small. The numerator is a clock delta, and the code assumed it would grow slowly because that is what wall-clock time does. setCurrentTime() is the operation that breaks the assumption: it moves the timeline as far as the caller likes in a single call, so the ratio is not small / small but arbitrary / arbitrarily-small. At the narrowing site that quotient is handed to static_cast<unsigned>, and once it exceeds UINT_MAX the conversion is out of range — saturating on AArch64, unspecified on x86-64. Either result has escaped any relationship to a real iteration index. clampTo<unsigned> closes that specific hazard: the narrowing becomes total, and the value that comes out is a defined UINT_MAX rather than whatever the target ISA's convert instruction happened to leave in the register.

The second path into a bad counter runs in the opposite direction. In the ended branch, --repeat fired whenever fmod(repeatingDuration.value(), simpleDuration.value()) came back zero, guarded by a property of the durations rather than by a floor check on the counter. fmod(0, x) is zero, so a zero-valued repeating duration walked straight into a decrement of a zero unsigned — which wraps to UINT_MAX. Same terminal value as the overflow, reached without any overflow at all. The added repeat && restores the floor.

Both paths converge on the same consumer, and the consumer is where the cost lives. progress() treated repeat as the number of iterations the seek had skipped over and replayed them one at a time, appending an unsigned to m_pendingRepeatIterations and queueing a repeatEvent into the sender on each pass. With repeat sitting near 2^32 that is a multi-billion-iteration loop with an allocating body: tens of gigabytes of vector storage and a pending-event queue of matching size, all consumed before the animation does a single frame of actual work. The renderer is unresponsive for the whole climb.

The added LayoutTest is the shortest path to it:

  1. Parse an <animate> with dur="0.0001s" and repeatCount="indefinite", so the simple duration is 1e-4 seconds and the repeating duration is indefinite.
  2. Call svg.setCurrentTime(400000) — a single discontinuous jump to roughly four and a half days of timeline.
  3. SMILTimeContainer calls progress() on the scheduled animation to bring it to the new time.
  4. calculateAnimationPercentAndRepeat() divides an active time of ~4e5 by a simple duration of 1e-4 and narrows the resulting ~4e9 into repeat.
  5. progress() enters the replay loop with that trip count, allocating and enqueuing on every pass.

The expectation file says only "Passes if it does not crash", which is the honest statement of what is being regression-tested.

The two halves of the patch are complementary rather than belt-and-braces. Clamping alone would still leave a UINT_MAX-trip-count loop standing and reachable — a well-defined catastrophe instead of an undefined one. Coalescing alone would still let an unspecified repeat value flow out of the function to every other consumer of the out-parameter. Together they remove both the bad value and the attacker's influence over the trip count. What this weakens, in security-model terms, is the availability and resource-integrity boundary of the WebContent process: the affected code runs entirely in the renderer, so any effect stays inside the WebContent sandbox and would still need a separate escape to reach further — but SMIL animations run in cross-origin iframes with no script interaction with the embedder, so a third-party frame reaches this path unassisted. Apple's advisory records the impact as memory corruption; that is relayed as attributed. What the counter computation and the dispatch loop establish directly is a reliable renderer memory-exhaustion and controlled-crash primitive with a long, attacker-observable hang in front of it, not a read/write or type-confusion primitive. Escalation past denial of service would need a downstream consumer that treats the near-UINT_MAX counter as an index or an allocation size.

A page-controlled division produced the loop bound: one seek plus a sub-millisecond dur narrows to a near-UINT_MAX repeat count, and the old code allocated once per count.

This is the second pass over the same hazard — the commit message says it "tightens the fix in 301404@main" — and the two rounds fix different halves of it. The earlier round went after the value. This one goes after the value again (clampTo, plus the zero-floor the decrement never had) and then, more consequentially, after the consumer: rather than bounding the loop, it deletes the loop. That ordering is the durable one. A clamped page-controlled counter still leaves an O(2^32) loop one arithmetic slip away from being reachable again, whereas removing the attacker's influence over the trip count closes the shape. Worth noting too is how the semantics change was justified: instead of defending the per-interval replay as spec-mandated, the author went and checked, and found that SMIL's timing model never actually requires one repeatEvent per skipped interval across a discontinuous seek — turning what looked like a spec constraint into a permissible simplification. Any catch-up path that replays per-unit work after a clock jump deserves the same question asked of it.