Clamp repeat count for SVG animation.
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
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
LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html
Patch Details
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.
Background
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.
Analysis
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:
- Parse an
<animate>withdur="0.0001s"andrepeatCount="indefinite", so the simple duration is 1e-4 seconds and the repeating duration is indefinite. - Call
svg.setCurrentTime(400000)— a single discontinuous jump to roughly four and a half days of timeline. SMILTimeContainercallsprogress()on the scheduled animation to bring it to the new time.calculateAnimationPercentAndRepeat()divides an active time of ~4e5 by a simple duration of 1e-4 and narrows the resulting ~4e9 intorepeat.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.
Insight
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.
Audit directions
-
A time delta divided by a page-supplied duration, narrowed into a fixed-width integer. The invariant: a quotient of two externally controlled quantities is unbounded and must be clamped at the narrowing site, not assumed small because one operand usually grows slowly. Narrow — grep
Source/WebCore/svg/animation/andSource/WebCore/animation/forstatic_cast<unsigned>orstatic_cast<int>wrapping an expression containing/where either operand is aSMILTime,Seconds, ordoubleduration;SVGSMILElement::repeatingDuration(),SMILTimeContainer, and theSVGAnimationElementvalue-index computations are the first stops. Wider — the same shape turns up wherever an iteration index is recomputed from a timeline position rather than incremented: CSS animation iteration counts, Web Animations' current-iteration computation, media and text-track time-to-index mappings. The tell in search results is an integer cast wrapping a division whose denominator can be driven toward zero by content. Widest — never narrow a ratio of two attacker-influenced reals into a fixed-width integer without saturation. It holds in any engine mapping continuous time onto discrete iterations: Blink'sAnimationTimeDelta-to-iteration math, Gecko's SMIL implementation, and any simulation or scheduler deriving a tick index from(now - start) / step. -
A catch-up loop that replays one unit of work per skipped interval after a discontinuous clock jump, with an allocating or enqueuing body. The invariant: the cost of handling a time jump must be independent of the size of the jump. Narrow — in
SVGSMILElement.cppand theEventSendercall sites underSource/WebCore/dom/, look fordispatchEventSoonor a containerappend()inside afor/whilewhose bound derives from a time computation, and check the remaining SMIL event paths (beginEvent,endEvent) for the same shape. Wider — every "seek, then replay what was skipped" handler is a candidate: text-track cue activation after a largecurrentTimeassignment,requestAnimationFrameand timer catch-up after a suspended-then-resumed document, CSS animation and transition event dispatch across a large style-recalc delta. The shape to notice is a loop whose trip count is(newTime - oldTime) / unitwith a side-effecting body. Widest — the invariant transfers to any event-sourced or fixed-timestep system that can be handed a discontinuous clock: accumulator-based fixed-timestep game loops, cron and scheduler catch-up on clock skew, message-queue consumers replaying a backlog after a seek. The portable tell: if a time jump of size N costs O(N) work or memory, whoever controls N controls the process's liveness. -
An unsigned decrement whose zero case is reachable, guarded by a property of a different value. The invariant: every
--xorx - 1on an unsigned must be dominated by a test onxitself. Narrow — review the remaining arithmetic onrepeat,m_lastRepeat, and the iteration bookkeeping inSVGSMILElement.cppandSVGAnimationElement.cppfor decrements conditioned onfmod,isIndefinite(), or an interval comparison instead of on the counter; this bug'sif (!fmod(...)) --repeat;is the template. Wider — the shape is common across WebCore layout and collection code ascount - 1,size() - 1, orindex - 1onunsigned/size_twhere the empty case is reachable through an unusual attribute configuration; the search shape is a subtraction on an unsigned whose guard mentions a sibling variable. Widest — this is the general wrapping-unsigned-underflow class, live in any language with modular unsigned arithmetic — C/C++, Rust in a release profile, Go — wherever the wrapped value then becomes a length, capacity, or loop bound. Trace whether the underflowed maximum can reach an allocation size or a loop bound: if it can, the underflow is a resource-exhaustion or OOB candidate rather than a cosmetic off-by-one. -
A queue and its sidecar metadata updated at paired call sites, where one site later drops its half. The invariant here is that every queued
repeatEventhas a matching entry describing which iteration it represents. Narrow — verify that the coalesceddispatchEventSoon(*this, eventNames().repeatEventEvent)this patch introduces cannot be consumed whilem_pendingRepeatIterationsis empty, and check whatlastDispatchedRepeatIteration()reports for a coalesced event:ConditionEventListener::handleEventcompares it againstm_condition->m_repeatsto resolvebegin="other.repeat(N)", so a stale or absent iteration number changes which conditions fire. Wider — audit the other WebCoreEventSenderusers for the same split between "enqueue the event" and "record the data the handler will need", which is the shape that produces empty-sidecar reads when one side is optimized away. Ceiling: this one is scoped to WebCore's deferred-dispatch design, so the ladder stops inside WebKit rather than naming an out-of-engine analogue. The tell is a dispatch call site that no longer has a paired push, or a handler that indexes a container without first checking it is non-empty. -
A hardening fix that constrains a value without constraining its consumer. This commit's own history is the worked example — it "tightens the fix in 301404@main". Narrow — grep
Source/WebCoreforclampTo<unsigned>andstd::min<unsigned>and, for each hit, trace whether the clamped value reaches a loop bound, areserveCapacity, or an allocation size. Wider — revisit other WebKit fixes that landed as a pure clamp-or-cast change and ask the same question of the downstream consumer; a counter clamped toUINT_MAXis still catastrophic if something iterates over it. Ceiling: the concrete targets here are WebKit fix history, so this ladder stays in-engine by construction. The tell: substitute the clamped maximum literally into the consumer — if the result would still allocate or iterate more than a process can survive, the clamp bounded the undefined behaviour but not the resource cost.