CVE-2026-65341
Overview
Impact:
Description: Processing maliciously crafted web content may lead to memory corruption
Researchers: Henock Habte
Key insight. A page-controlled ratio (repeatingDuration/simpleDuration) was cast to unsigned with static_cast, which wraps, and each skipped iteration dispatched its own event. The fix clamps the count with clampTo<unsigned>(), guards the decrement against underflow, and coalesces the per-iteration event loop into a single O(1) dispatch.
Patch Details
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);
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65341 |
| Bugzilla | 318405 |
| Component | WebCore |
| Bug Class | IntegerOverflow |
| Severity | medium |
| Commit | 34249048d66d342f… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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.
2. Seek to a large time
Script calls svg.setCurrentTime(400000), making elapsed/simpleDuration an astronomically large repeat count.
3. Overflow the repeat count
Pre-patch static_cast<unsigned> wraps the huge ratio, and repeat-1 can underflow.
4. Dispatch unbounded repeat events
progress() loops up to repeat-1 times queueing repeat events, exhausting memory / corrupting state and crashing.
Impact Assessment
Memory corruption / resource exhaustion driven entirely by page-controlled SVG animation parameters. A tiny duration plus an indefinite repeat and a large seek makes the repeat count overflow and queues an unbounded number of events, exhausting memory and corrupting state in the WebContent process — reachable from a static SVG with no user interaction.
Audit Directions
Page-controlled counts cast to unsigned
Audit 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 input
Find loops (event dispatch, allocation) whose iteration count derives from author-controlled repeat/duration values and bound or coalesce them.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
SVGSMILElement::calculateAnimationPercentAndRepeat |
Source/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::progress |
Source/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
Source Files
Source/WebCore/svg/animation/SVGSMILElement.cpp
Test Files
LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash-expected.txtLayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html
Regression Test
The fix ships this proof-of-concept / regression test:
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>