CVE-2026-65341
Overview
Background
- SVG SMIL animation
- WebCore’s declarative-animation engine (
<animate>,<set>, etc.).SVGSMILElementdrives each animation element’s timing model (begin/end intervals, repeats) and is advanced byprogress()on every sampling tick. - `repeatingDuration()`
- the total time the animation is active across all repeats. With
repeatCount="indefinite"this isSMILTime::indefinite(); otherwise it isrepeatCount * simpleDuration. - `simpleDuration()`
- the duration of one iteration (the
durattribute). It is fully page-controlled and may be arbitrarily small (dur="0.0001s"). - `setCurrentTime()`
- a scriptable DOM API that lets the page seek the SVG timeline to any
elapsedvalue instantly (the PoC seeks to 400000 seconds). - `repeat` (local `unsigned`)
- the integer index of the current repeat iteration, derived by dividing an elapsed/duration
SMILTime(adouble) bysimpleDuration. It is later used to drive a loop that dispatches onerepeatEventper skipped iteration.
Root Cause Analysis
In SVGSMILElement::calculateAnimationPercentAndRepeat (Source/WebCore/svg/animation/SVGSMILElement.cpp), the repeat index was computed with a bare narrowing cast: repeat = static_cast<unsigned>(repeatingDuration.value() / simpleDuration.value()) (and the symmetric activeTime.value() / simpleDuration.value() branch). All operands are page-controlled doubles: by combining a tiny dur with a huge seek via setCurrentTime(400000), the quotient becomes an enormous value. Casting a double that exceeds UINT_MAX to unsigned is undefined behaviour in C++ and in practice yields a garbage/huge iteration count. The patch replaces both casts with clampTo<unsigned>(...), which saturates the value into the representable unsigned range, and additionally guards the --repeat decrement with if (repeat && ...) so it cannot wrap 0 to 0xFFFFFFFF.
The second hunk, in SVGSMILElement::progress, shows why the unbounded repeat was dangerous. The old code looped for (unsigned i = 0; i < repeat - 1; ++i) and called smilEventSender().dispatchEventSoon(*this, repeatEventEvent) once per iteration — so an attacker-controlled repeat of (effectively) billions enqueued that many DOM events in a single synchronous progress tick, an unbounded allocation/compute amplification driven entirely by page content. The patch removes the per-iteration loop entirely and coalesces the skipped iterations into a single event: if (repeat > 1 || (repeat && m_activeState == Inactive)) dispatchEventSoon(...). Note the advisory labels this “memory corruption”; the diff substantiates an unsigned integer overflow feeding an unbounded event-dispatch loop (resource exhaustion). A clean controlled write is not shown by the diff; any heap corruption would be a downstream consequence of the unbounded allocation (inferred).
double iteration count was narrowed to unsigned with an unchecked static_cast and then used as a loop bound for synchronous DOM-event dispatch, so a single setCurrentTime seek on a near-zero-duration animation produced a practically unbounded number of event allocations.Attack Path
- Setup
Attacker serves a page containing an
<svg>with<animate dur="0.0001s" repeatCount="indefinite">on a child element. - Trigger
Script calls
svg.setCurrentTime(400000), seekingelapsedfar past the interval. - Overflow
calculateAnimationPercentAndRepeatdivides the hugeactiveTime/repeatingDurationby the tinysimpleDuration; thedoubleresult vastly exceedsUINT_MAXand thestatic_cast<unsigned>yields a garbage/hugerepeat. - Amplification
progress()executesfor (i = 0; i < repeat - 1; ++i) dispatchEventSoon(repeatEvent), enqueuing an unbounded number of SMIL events in one tick. - Primitive Unbounded memory/CPU consumption inside the renderer (denial of service); escalation to memory corruption is not demonstrated by the diff (inferred).
Impact Assessment
setCurrentTime reaches the sink. The demonstrated primitive is an integer-overflow-driven unbounded event-dispatch loop: a renderer hang / out-of-memory denial of service. The Apple advisory’s “memory corruption” wording is not substantiated as a direct controlled write by this diff; the committed regression test only asserts “Passes if it does not crash,” consistent with a crash/DoS fix. Any further corruption would depend on allocator behaviour under exhaustion and is inferred, not shown.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifSource/WebCore/svg/animation/SVGSMILElement.cpp |
modified |
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
- Double→integer narrowing in SMIL timingAudit other
static_cast<unsigned>/static_cast<int>ofSMILTime::value()quotients acrossSVGSMILElementandSVGAnimationElement(interval/percent/next-progress-time math) for the same unchecked narrowing. - Loop bounds derived from page-controlled countsLook for other sites that loop
0..countdispatching events or allocating wherecountcomes from durations,repeatCount, orsetCurrentTime-seekable state withoutclampTo. - Event-sender amplificationReview
SMILEventSender/dispatchEventSooncall sites that can be driven in a single synchronous tick for unbounded enqueue.
Patch
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);
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>
Regression Test / PoC
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>