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. 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
LayoutTests/svg/animations/smil-seek-huge-repeat-count-crash.html
Patch Details
변경은 세 곳이며, 그중 둘은 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으로 병합된 것입니다.
Background
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을 모든 입력에 대해 정의된 항상 동일한 동작으로 만듭니다.
Analysis
페이지가 제어하는 두 값을 나눈 결과가 고정 폭 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는 여기에 도달하는 가장 짧은 경로입니다.
dur="0.0001s"와repeatCount="indefinite"를 가진<animate>를 파싱합니다. simple duration은 1e-4초이고 repeating duration은 indefinite입니다.svg.setCurrentTime(400000)을 호출합니다. timeline 기준 약 4일 반에 해당하는 위치로 한 번에 이동하는 불연속 점프입니다.SMILTimeContainer가 스케줄된 animation에 대해progress()를 호출해 새 시각까지 따라잡게 합니다.calculateAnimationPercentAndRepeat()가 약 4e5의 active time을 1e-4의 simple duration으로 나누고, 그 결과인 약 4e9를repeat으로 narrowing합니다.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되고, 기존 코드는 그 횟수만큼 할당을 반복했습니다.
Insight
같은 위험 지점을 두 번째로 다룬 수정입니다. 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 경로라면 어디든 같은 질문을 던져볼 가치가 있습니다.
Audit directions
-
페이지가 제공한 duration으로 시간 차이값을 나눈 뒤 고정 폭 정수로 narrowing하는 패턴. invariant는 이렇습니다. 외부에서 제어되는 두 값의 몫은 경계가 없으므로, 한쪽 피연산자가 보통 천천히 증가한다는 이유로 작다고 가정할 것이 아니라 narrowing 지점에서 clamp해야 합니다. Narrow —
Source/WebCore/svg/animation/과Source/WebCore/animation/에서static_cast<unsigned>나static_cast<int>가/를 포함한 식을 감싸고 있고, 피연산자 중 하나가SMILTime,Seconds, 또는doubleduration인 지점을 검색합니다.SVGSMILElement::repeatingDuration(),SMILTimeContainer, 그리고SVGAnimationElement의 value index 계산이 먼저 살펴볼 곳입니다. Wider — 회차 번호를 증가시키는 대신 timeline 위치에서 다시 계산하는 곳이라면 어디든 같은 패턴이 나타납니다. CSS animation의 iteration count, Web Animations의 current iteration 계산, media와 text track의 시간-인덱스 매핑 등이 해당합니다. 검색 결과에서 눈여겨볼 단서는, 콘텐츠가 분모를 0에 가깝게 만들 수 있는 나눗셈을 정수 cast가 감싸고 있는 형태입니다. Widest — 공격자가 영향을 줄 수 있는 두 실수의 비율은 saturation 없이 고정 폭 정수로 narrowing하지 않아야 합니다. 연속적인 시간을 이산 회차로 매핑하는 엔진이라면 어디에나 적용됩니다. Blink의AnimationTimeDelta기반 회차 계산, Gecko의 SMIL 구현, 그리고(now - start) / step으로 tick 인덱스를 구하는 모든 시뮬레이션과 scheduler가 여기에 포함됩니다. -
불연속적인 시계 점프 이후, 건너뛴 interval마다 단위 작업을 하나씩 재생하면서 body에서 할당이나 큐잉을 수행하는 catch-up loop. invariant는 이렇습니다. 시간 점프를 처리하는 비용은 점프의 크기와 무관해야 합니다. Narrow —
SVGSMILElement.cpp와Source/WebCore/dom/아래의EventSender호출 지점에서, 반복 횟수가 시간 계산에서 유도되는for/while안에dispatchEventSoon이나 컨테이너append()가 들어 있는 곳을 찾습니다. 남아 있는 SMIL event 경로(beginEvent,endEvent)에도 같은 패턴이 있는지 확인합니다. Wider — "seek한 뒤 건너뛴 만큼 재생한다"는 구조의 handler는 모두 후보입니다. 큰 값을currentTime에 대입한 뒤의 text track cue 활성화, 문서가 suspend되었다가 resume된 뒤의requestAnimationFrame과 timer catch-up, style recalc 간격이 큰 상황에서의 CSS animation 및 transition event dispatch가 그렇습니다. 주목할 형태는 반복 횟수가(newTime - oldTime) / unit이고 body에 side effect가 있는 loop입니다. Widest — 이 invariant는 불연속적인 시계를 입력으로 받을 수 있는 event sourcing 시스템이나 fixed timestep 시스템 전반에 그대로 적용됩니다. accumulator 기반의 fixed timestep 게임 루프, clock skew 상황에서의 cron 및 scheduler catch-up, seek 이후 backlog를 재생하는 message queue consumer 등이 그렇습니다. 어디서나 통하는 단서는 이렇습니다. 크기 N의 시간 점프가 O(N)의 작업이나 메모리를 요구한다면, N을 제어하는 쪽이 그 프로세스의 생존성을 제어합니다. -
0에 도달할 수 있는 unsigned 감소 연산을, 정작 다른 값의 성질로 방어한 패턴. 지켜야 할 invariant는 단순합니다. unsigned에 대한
--x나x - 1은 반드시x자체에 대한 검사가 선행되어야 합니다. Narrow —SVGSMILElement.cpp와SVGAnimationElement.cpp에 남아 있는repeat,m_lastRepeat관련 연산과 iteration 관리 코드를 살펴볼 필요가 있습니다. 카운터가 아니라fmod나isIndefinite(), 혹은 interval 비교를 조건으로 삼는 감소 연산이 대상입니다. 이번 버그의if (!fmod(...)) --repeat;가 전형적인 형태입니다. Wider — 같은 형태는 WebCore의 layout 및 collection 코드 전반에서unsigned/size_t에 대한count - 1,size() - 1,index - 1형태로 흔하게 나타납니다. 이때 비어 있는 상태가 흔치 않은 attribute 조합을 통해 도달 가능한 경우가 문제입니다. 검색해야 할 형태는, guard가 정작 옆에 있는 다른 변수를 검사하고 있는 unsigned 뺄셈입니다. Widest — 좀 더 일반화하면 modular unsigned 연산을 제공하는 언어라면 어디서나 성립하는 wrapping unsigned underflow 계열입니다. C/C++, release profile의 Rust, Go가 모두 해당합니다. 초점은 wrap된 값이 이후 length나 capacity, loop bound로 쓰이는 지점에 있습니다. underflow로 만들어진 최대값이 allocation size나 loop bound까지 도달하는지 추적해야 합니다. 도달한다면 겉보기 off-by-one이 아니라 resource exhaustion 또는 OOB 후보에 해당합니다. -
queue와 그에 딸린 metadata를 짝지어진 호출 지점에서 함께 갱신하다가, 한쪽이 나중에 자기 몫을 빠뜨리는 패턴. 여기서 지켜야 할 invariant는, queue에 들어간 모든
repeatEvent가 자신이 어느 iteration에 해당하는지를 기술하는 항목을 함께 가진다는 것입니다. Narrow — 이번 패치가 도입한 병합된dispatchEventSoon(*this, eventNames().repeatEventEvent)가m_pendingRepeatIterations가 비어 있는 상태에서 처리되는 일이 없는지 확인해야 합니다. 그리고 병합된 이벤트에 대해lastDispatchedRepeatIteration()이 무엇을 반환하는지도 함께 살펴볼 필요가 있습니다.ConditionEventListener::handleEvent는 그 값을m_condition->m_repeats와 비교해begin="other.repeat(N)"을 해석합니다. 따라서 iteration 번호가 stale하거나 아예 없으면, 어떤 condition이 발동하는지가 달라집니다. Wider — WebCore의 다른EventSender사용처에서도 "이벤트를 enqueue하는 쪽"과 "handler가 필요로 할 데이터를 기록하는 쪽"이 이렇게 갈라져 있는지 점검해야 합니다. 한쪽이 최적화로 사라질 때 비어 있는 metadata를 읽게 되는 것이 바로 이 형태입니다. Ceiling: 이 항목은 WebCore의 deferred dispatch 설계에 한정되므로, 사다리는 engine 바깥의 유사 사례를 지목하지 않고 WebKit 안에서 멈춥니다. 단서는 짝이 되는 push가 사라진 dispatch 호출 지점, 혹은 비어 있는지 먼저 확인하지 않고 컨테이너를 인덱싱하는 handler입니다. -
값 자체는 제한하면서, 그 값을 받아 쓰는 쪽은 제한하지 않은 hardening fix. 이 commit의 이력 자체가 그 사례에 해당합니다. 스스로 "301404@main의 fix를 더 조인다"고 밝히고 있기 때문입니다. Narrow —
Source/WebCore에서clampTo<unsigned>와std::min<unsigned>를 검색한 뒤, 걸린 지점마다 clamp된 값이 loop bound나reserveCapacity, allocation size까지 도달하는지 추적해야 합니다. Wider — 순수하게 clamp 또는 cast 변경만으로 반영된 다른 WebKit fix들도 다시 살펴보고, 그 값을 넘겨받는 쪽에 같은 질문을 던져야 합니다.UINT_MAX로 clamp된 카운터라도, 무언가가 그 값을 기준으로 순회한다면 결과는 여전히 치명적입니다. Ceiling: 여기서의 구체적인 대상이 WebKit의 fix 이력인 만큼, 이 사다리는 구조상 engine 내부에 머뭅니다. 단서는 간단합니다. clamp된 최대값을 사용하는 쪽에 문자 그대로 대입해 보면 됩니다. 그 결과가 프로세스가 감당할 수 없는 수준의 allocation이나 순회로 이어진다면, clamp는 undefined behaviour만 막았을 뿐 resource 비용은 막지 못한 셈입니다.