← 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. 1밀리초도 되지 않는 dur에 setCurrentTime() 호출 한 번이면, 타임라인 seek가 2^32에 가까운 반복 loop로 바뀝니다. 게다가 매 회차마다 allocation이 발생하며, cross-origin iframe 어디서든 도달 가능합니다. 다만 overflow된 counter를 실제로 넘겨받는 지점이 이 loop 하나뿐이라는 점에서 Medium에 머뭅니다. 인덱스로 쓰이지도, allocation 크기로 쓰이지도 않습니다.

SVG의 선언적 animation은 자체 clock 위에서 동작하지 않습니다. 대신 document timeline에서 현재 위치를 읽어온 뒤, 지금 몇 번째 iteration에 있어야 하는지를 산술적으로 계산합니다. SVGSMILElement가 이 계산을 담당하는데, 경과 시간을 저자가 선언한 한 회차 길이로 나눈 다음 그 몫을 unsigned iteration counter로 좁혀 담습니다. 나눗셈 결과가 범위 안에 머무르려면 두 피연산자 모두가 제한되어야 합니다. 문제는 분자의 경우 wall-clock 시간이 점진적으로만 흐른다는 가정에 기대고 있고, 분모는 아무런 제한도 받지 않는다는 점입니다.

관전 포인트: 1밀리초 미만의 animation duration을 가진 페이지라면, 타임라인을 한 번 앞으로 점프시키는 것만으로 렌더러가 수십억 회에 달하는 loop 반복마다 allocation을 수행하게 만들 수 있습니다. 결국 hang 상태에 빠지거나 메모리 사용량 때문에 강제 종료되는 지점까지 이어집니다.

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();
 
+ // 페이지가 제어하는 repeat count를 clamp하여 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);
 
- }
 
- }
+ // 건너뛴 repeat iteration들을 interval마다 하나씩이 아니라 단일 event로 합칩니다.
+ 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>

변경은 세 곳이며, 그중 둘은 SVGSMILElement.cpp에 있습니다.

SVGSMILElement::calculateAnimationPercentAndRepeat()는 두 SMILTime 값의 부동소수점 나눗셈 결과로 out-parameter unsigned& repeat를 채웁니다. 두 대입 지점 모두에서 raw static_cast<unsigned>가 clampTo<unsigned>로 바뀌었습니다. 하나는 interval이 종료되었거나 active time이 repeating duration을 넘어선 경우에 진입하는 분기입니다. 여기서는 repeatingDuration.value() / simpleDuration.value()가 사용됩니다. 다른 하나는 아직 실행 중인 분기이며, activeTime.value() / simpleDuration.value()가 사용됩니다. 그 아래에 있는 --repeat 보정에는 repeat && guard가 추가되었습니다. 이 보정은 repeating duration이 simple duration으로 나누어떨어질 때 적용되는데, 이제는 counter 자체에 대한 검사가 감소 연산을 지배하게 됩니다. 작성자는 의도를 코드 안에 그대로 적어 두었습니다. "Clamp the page-controlled repeat count to prevent overflow."

SVGSMILElement::progress()에서는 replay loop가 통째로 사라졌습니다. 기존 코드는 for (unsigned i = 1; i < repeat; ++i)를 돌면서 매 회차마다 i를 m_pendingRepeatIterations vector에 추가하고 smilEventSender()로 repeatEvent를 큐에 넣었습니다. 여기에 더해 element가 Inactive 상태가 된 경우에는 한 번 더 추가하고 dispatch했습니다. 이 전부가 if (repeat > 1 || (repeat && m_activeState == Inactive)) 뒤의 dispatchEventSoon() 한 번으로 축약되었습니다. 합쳐진 형태가 하지 않는 일도 짚어둘 필요가 있습니다. event를 큐에 넣기는 하지만, m_pendingRepeatIterations에는 아무것도 추가하지 않습니다.

새로 추가된 LayoutTest는 14줄짜리 재현 코드입니다. <animate dur="0.0001s" repeatCount="indefinite"> 뒤에 svg.setCurrentTime(400000)를 호출하는 것이 전부이고, expectation 파일의 내용은 "Passes if it does not crash." 한 줄뿐입니다. commit에는 Originally-landed-as: 305413.1072@safari-7624.5-branch도 기록되어 있는데, Apple advisory의 배포 날짜가 이 commit의 main branch 날짜보다 앞서는 이유입니다. 수정이 Safari branch에서 먼저 나간 뒤 나중에 main으로 병합된 것입니다.

SVG의 SMIL animation. <animate>, <set>, <animateTransform>은 타이밍을 전부 markup에 적어 두는 선언형 animation입니다. dur은 한 회차의 길이, 즉 simple duration을 지정합니다. repeatCount와 repeatDur은 그 회차가 몇 번 또는 얼마 동안 반복되는지를 정하고, begin과 end는 active interval의 경계를 정합니다. WebKit은 이 타이밍 모델 전체를 위 요소들의 공통 base 클래스인 SVGSMILElement에 구현해 두었고, animation tick마다 SMILTimeContainer가 이를 구동합니다.

Simple duration, repeating duration, active time. 타이밍 모델이 다루는 산술은 이 세 값으로 이루어집니다. simple duration은 한 회차의 길이입니다. repeating duration은 simple duration에 repeat count를 곱한 값이며, 작성자가 repeatCount="indefinite"라고 적었다면 indefinite가 됩니다. active time은 interval이 시작된 이후 element가 얼마나 진행했는지를 나타냅니다. 따라서 현재 회차 번호는 active time을 simple duration으로 나눈 몫입니다. 증가시켜 나가는 counter가 아니라, tick마다 처음부터 다시 계산되는 나눗셈 결과인 셈입니다.

SMILTime. SMIL에서 사용하는 WebCore의 시간 타입입니다. 초 단위 double 값에 별도의 indefinite 상태가 더해진 형태이고, 이 상태는 isIndefinite()로 확인합니다. value()는 raw double을 반환합니다.

Timeline seek. SVGSVGElement::setCurrentTime(t)는 문서의 SMIL timeline을 한 번에 임의의 위치로 이동시킵니다. 점진적인 전진이 아니라 불연속적인 점프입니다. 스크립트의 다음 줄에서 timeline은 이미 전혀 다른 위치에 존재합니다. 이후 SMILTimeContainer가 스케줄된 animation들에 대해 SVGSMILElement::progress()를 호출해 각각을 새 시각까지 따라잡게 합니다.

repeatEvent와 event sender. SMIL은 element가 새 회차를 시작할 때 발생하는 repeatEvent를 정의합니다. WebKit은 이 event를 smilEventSender()를 통해 큐에 넣습니다. EventSender는 동기적으로 dispatch하지 않고 이후 시점으로 미루는 helper입니다. 이때 해당 회차 번호는 m_pendingRepeatIterations에 기록됩니다. ConditionEventListener::handleEvent는 lastDispatchedRepeatIteration()으로 그 번호를 다시 읽어, begin="other.repeat(3)" 형태의 조건을 해석합니다.

double을 unsigned로 narrowing하기. 여기서는 C++의 두 가지 사실이 핵심입니다. 먼저 double을 잘라낸 값이 대상 정수 타입으로 표현될 수 없다면, 이는 범위를 벗어난 변환입니다. 언어 차원에서 결과가 규정되어 있지 않고, 실제 동작은 ISA에 따라 달라집니다. AArch64의 fcvtzu는 UINT_MAX로 saturate하지만, x86-64에서 흔히 생성되는 코드에서는 unspecified 값이 나옵니다. 다음으로 unsigned 산술은 2^32로 wrap하므로, 값이 0인 unsigned를 감소시키면 UINT_MAX가 됩니다. clampTo<T>(value)는 T의 경계에서 saturate하면서 변환을 수행하는 WTF helper입니다. 언어의 변환 규칙에 기대는 대신, narrowing을 모든 입력에 대해 정의된 항상 동일한 동작으로 만듭니다.

페이지가 제어하는 두 값을 나눈 결과가 고정 폭 counter로 narrowing되고, 그 counter가 다시 loop의 반복 횟수로 사용됩니다. 그리고 그 loop의 body는 매번 메모리를 할당합니다.

  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()

왼쪽 열을 따라가 보겠습니다. 빠져 있던 invariant는 repeat counter가 표현 가능한 범위 안의, 경계가 정해진 회차 번호여야 한다는 것입니다. 패치 이전에는 나눗셈의 어느 쪽에서도 그 경계를 보장하지 않았습니다. 분모는 markup에서 그대로 읽어 온 dur이며, 얼마든지 작은 값이 될 수 있습니다. 분자는 시계 차이값입니다. 코드는 wall-clock time이 그렇듯 이 값이 천천히 증가한다고 가정했습니다. 이 가정을 깨뜨리는 것이 setCurrentTime()입니다. 호출 한 번으로 caller가 원하는 만큼 timeline을 이동시키기 때문입니다. 그래서 이 비율은 small / small이 아니라 arbitrary / arbitrarily-small이 됩니다. narrowing 지점에서 이 몫은 static_cast<unsigned>로 전달됩니다. 값이 UINT_MAX를 넘어서는 순간 변환은 범위를 벗어나게 되는데, AArch64에서는 saturate되고 x86-64에서는 unspecified 값이 됩니다. 어느 쪽이든 실제 회차 번호와의 관계는 이미 끊어진 상태입니다. clampTo<unsigned>는 바로 이 위험 지점을 막습니다. narrowing이 모든 입력에 대해 정의되고, 결과값도 대상 ISA의 변환 명령이 레지스터에 남긴 임의의 값이 아니라 정의된 UINT_MAX가 됩니다.

잘못된 counter로 가는 두 번째 경로는 반대 방향으로 진행됩니다. 종료 분기에서는 fmod(repeatingDuration.value(), simpleDuration.value())가 0을 반환할 때마다 --repeat이 실행되었습니다. counter의 하한을 검사한 것이 아니라 duration 쪽의 성질에 기대어 guard를 건 셈입니다. fmod(0, x)는 0이므로, repeating duration이 0이면 값이 0인 unsigned를 감소시키는 경로로 곧장 들어갑니다. 결과는 UINT_MAX로 wrap됩니다. overflow는 전혀 없었는데도 overflow와 동일한 최종 값에 도달하는 것입니다. 추가된 repeat &&가 이 하한을 복원합니다.

두 경로는 같은 소비 지점으로 모이고, 비용이 발생하는 곳도 바로 그 지점입니다. progress()는 repeat을 seek이 건너뛴 회차 수로 해석해 그만큼을 하나씩 재생했습니다. 매 회차마다 m_pendingRepeatIterations에 unsigned 하나를 추가하고, sender에 repeatEvent를 큐잉했습니다. repeat이 2^32 근처에 있다면 수십억 회를 도는 loop가 되고, 그 body는 매번 메모리를 할당합니다. 수십 GB의 vector 저장 공간과 그에 상응하는 크기의 pending event 큐가 만들어지는데, animation이 실제로 한 프레임도 처리하기 전에 이 모든 비용이 먼저 지불됩니다. 그 사이 renderer는 계속 응답하지 못합니다.

추가된 LayoutTest는 여기에 도달하는 가장 짧은 경로입니다.

  1. dur="0.0001s"와 repeatCount="indefinite"를 가진 <animate>를 파싱합니다. simple duration은 1e-4초이고 repeating duration은 indefinite입니다.
  2. svg.setCurrentTime(400000)을 호출합니다. timeline 기준 약 4일 반에 해당하는 위치로 한 번에 이동하는 불연속 점프입니다.
  3. SMILTimeContainer가 스케줄된 animation에 대해 progress()를 호출해 새 시각까지 따라잡게 합니다.
  4. calculateAnimationPercentAndRepeat()가 약 4e5의 active time을 1e-4의 simple duration으로 나누고, 그 결과인 약 4e9를 repeat으로 narrowing합니다.
  5. progress()는 그 반복 횟수로 replay loop에 진입하고, 매 회차마다 할당과 큐잉을 수행합니다.

expectation 파일에 적힌 것은 "Passes if it does not crash" 한 줄뿐인데, 무엇을 regression test하는지 솔직하게 드러낸 표현입니다.

패치의 두 축은 중복 방어가 아니라 서로를 보완하는 관계입니다. clamp만 했다면 UINT_MAX를 반복 횟수로 갖는 loop는 그대로 남아 도달 가능한 상태로 유지됩니다. undefined였던 재앙이 잘 정의된 재앙으로 바뀔 뿐입니다. 반대로 event를 합치기만 했다면 unspecified한 repeat 값이 함수 밖으로 흘러나가 out-parameter를 사용하는 다른 모든 지점에 그대로 전달됩니다. 둘을 함께 적용해야 잘못된 값과 반복 횟수에 대한 공격자의 영향력이 동시에 제거됩니다. 보안 모델의 관점에서 약화되는 것은 WebContent process의 가용성과 자원 무결성 경계입니다. 해당 코드는 전부 renderer 안에서 실행되므로, 영향도 WebContent sandbox 안에 머무릅니다. 더 나아가려면 별도의 escape가 여전히 필요합니다. 다만 SMIL animation은 embedder와 스크립트로 상호작용하지 않는 cross-origin iframe에서도 동작합니다. 그래서 third-party frame만으로도 이 경로에 도달할 수 있습니다. Apple advisory는 영향을 memory corruption으로 기록하고 있으며, 이 내용은 출처를 밝혀 그대로 인용합니다. counter 계산과 dispatch loop에서 직접 확인되는 것은 renderer의 메모리 고갈과 제어된 crash를 안정적으로 유발하는 primitive입니다. 그 앞에는 공격자가 관찰할 수 있는 긴 hang이 놓여 있습니다. read/write나 type confusion primitive에 해당하지는 않습니다. denial of service를 넘어서는 확장이 성립하려면, UINT_MAX에 가까운 counter를 인덱스나 할당 크기로 사용하는 하위 소비 지점이 필요합니다.

페이지가 제어하는 나눗셈이 loop의 반복 횟수를 결정한 패턴입니다. seek 한 번과 밀리초 미만의 dur만으로 repeat count가 UINT_MAX 근처까지 narrowing되고, 기존 코드는 그 횟수만큼 할당을 반복했습니다.

같은 위험 지점을 두 번째로 다룬 수정입니다. commit message에도 "tightens the fix in 301404@main"이라고 적혀 있습니다. 두 번의 수정은 각각 다른 절반을 담당합니다. 앞선 수정은 값을 겨냥했습니다. 이번 수정도 값을 다시 다루지만(clampTo와, 감소 연산에 애초에 없었던 0 하한 검사), 더 중요한 것은 소비 지점을 겨냥했다는 점입니다. loop의 경계를 정하는 대신 loop 자체를 제거했습니다. 오래 버티는 쪽은 후자입니다. clamp된 counter라 해도 페이지가 제어하는 값인 이상, 산술 실수 하나면 O(2^32) loop가 다시 도달 가능해집니다. 반면 반복 횟수에 대한 공격자의 영향력을 없애면 이 패턴 자체가 닫힙니다. semantics 변경을 정당화한 방식도 눈여겨볼 만합니다. 작성자는 interval마다 재생하는 동작이 spec상 필수라고 방어하는 대신 실제로 확인했습니다. 그 결과 SMIL 타이밍 모델은 불연속 seek 구간에서 건너뛴 interval마다 repeatEvent를 하나씩 요구하지 않는다는 점이 드러났습니다. spec 제약처럼 보이던 것이 허용 가능한 단순화로 바뀐 셈입니다. 시계가 점프한 뒤 단위 작업을 하나씩 재생하는 catch-up 경로라면 어디든 같은 질문을 던져볼 가치가 있습니다.