← All reports

[3] SVG animVal released without detaching its owner back-pointer

HighWebCore SVG property modelUAF

Three code paths dropped the pointer. Only the destructor remembered to detach.

eb617fc

High. Back-pointer invariant는 소멸자에서만 강제되는데, 일반적인 코드 경로 세 곳이 동일한 멤버를 해제합니다. 게다가 해제되는 객체는 script가 이미 참조를 들고 있는 대상입니다. 특권적인 위치가 전혀 필요하지 않으며, attacker가 free 시점과 dereference 시점을 모두 제어할 수 있습니다. 다만 crash를 넘어선 확장은 해제된 slot을 재확보할 수 있는지에 달려 있습니다.

SVG 속성은 script에 animated-value 객체 형태로 반영되며, 그 배후의 C++ 객체는 parent-child 쌍을 이룹니다. 이때 parent가 child를 strong하게 소유하고, child는 raw하고 unmanaged한 pointer로 parent를 다시 가리키는 구조입니다. 예를 들어 <rect>x 속성은 SVGAnimatedLength로 노출되는데, 여기에는 baseVal(파싱된 속성값)과 animVal(SMIL animation이 현재 산출하는 값)이 담깁니다. animVal sub-object는 script에 그대로 건네지고, 이 JS wrapper는 독립적인 strong reference를 갖게 됩니다. 이 구조가 안전하려면, parent가 소멸하기 전에 반드시 raw back-pointer가 먼저 해제되어야 한다는 invariant가 지켜져야 합니다.

관전 포인트: 어떤 웹페이지든 owner pointer가 해제된 heap을 가리키는 animVal 객체에 JS reference를 들고 있다가, 원하는 시점에 이를 dereference하여 attacker가 조작 가능한 메모리를 통한 virtual dispatch를 얻어낼 수 있습니다.

Commit message는 이 비대칭 구조를 다음과 같이 설명합니다.

SVGAnimatedValueProperty<T>::ensureAnimVal()|this|를 raw SVGProperty::m_owner back-pointer로 삼아 animVal SVGProperty를 생성합니다. 소멸자는 detach()를 통해 이를 해제하지만, stopAnimation()instanceStopAnimationImpl()은 detach 없이 m_animVal = nullptr만 수행합니다. animVal은 SVGAnimatedLength.animVal을 통해 script에 노출되고 JS wrapper가 독립적인 Ref를 보유하기 때문에, SVGAnimatedValueProperty보다 오래 살아남아 m_owner가 dangling 상태로 남을 수 있습니다. 이후 len.value를 읽으면 해제된 메모리를 거쳐 SVGProperty::contextElement()에 도달하게 됩니다. SVGAnimatedPropertyList<T>에도 동일한 패턴이 존재하며 동일한 방식으로 수정되었습니다.

Source/WebCore/svg/properties/SVGAnimatedValueProperty.h

void stopAnimation(SVGAttributeAnimator& animator) override
{
Base::stopAnimation(animator);
if (!this->isAnimating())
 
- m_animVal = nullptr;
+ detachAnimVal();
else if (m_animVal)
m_animVal->setValue(m_baseVal->value());
}
 
void instanceStartAnimationImpl(SVGAttributeAnimator& animator, SVGAnimatedValueProperty& animated) override
{
 
- if (!this->isAnimating())
+ if (!this->isAnimating()) {
+ detachAnimVal();
m_animVal = animated.animVal();
+ }
Base::startAnimation(animator);
}
 
void instanceStopAnimationImpl(SVGAttributeAnimator& animator) override
{
Base::stopAnimation(animator);
if (!this->isAnimating())
 
Background 문단 → Analysis → Insight → Audit directions 순서를 그대로 유지하며 번역하겠습니다.
 
```diff
- m_animVal = nullptr;
+ detachAnimVal();
}
...
+ void detachAnimVal()
+ {
+ // m_animVal은 우리가 참조를 해제한 뒤에도 bindings 쪽에서 계속 붙잡고 있을 수 있습니다. |this|가 파괴되고 나서
+ // 내부의 raw SVGProperty::m_owner 백포인터가 dangling되지 않도록 지금 detach합니다.
+ if (RefPtr animVal = std::exchange(m_animVal, nullptr))
+ animVal->detach();
+ }

Source/WebCore/svg/properties/SVGAnimatedPropertyList.h

+// (identical change)

LayoutTests/svg/animations/animVal-detach-after-stopAnimation-crash.html

+function grab()
+{
+ // 별도의 frame에서 실행해, conservative stack scan으로 중간 wrapper가 고정되지 않도록 합니다.
+ return document.getElementById("r").x.animVal;
+}
+function step1()
+{
+ // active interval에 진입시켜, stopAnimation()이 m_animVal = nullptr 분기를 타도록 합니다.
+ s.pauseAnimations();
+ s.setCurrentTime(0.5);
+ len = grab();
+ // SVGAnimatedValueProperty::stopAnimation()에 도달합니다.
+ a.remove(); r.remove(); s.remove();
+}
+function step2()
+{
+ if (window.GCController)
+ GCController.collect();
+ // SVGLength::valueForBindings -> SVGProperty::contextElement; m_owner가 dangling되면 안 됩니다.
+ len.value;
+}

두 개의 SVG property 템플릿에 detachAnimVal() helper가 새로 추가되었습니다. 이 helper는 각 클래스의 destructor가 이미 수행하던 동작을 그대로 반영합니다. m_animVal RefPtr을 해제하거나 덮어쓰던 세 곳이 함께 수정되었습니다. stopAnimation()instanceStopAnimationImpl()은 기존에 m_animVal = nullptr;을 실행했지만 이제는 detachAnimVal()을 호출합니다. instanceStartAnimationImpl()if (!this->isAnimating()) 내부에서 m_animVal = animated.animVal();을 단순 대입하던 방식이었지만, 이제는 재대입 전에 detachAnimVal()을 먼저 호출하도록 바뀌었습니다. 이에 따라 한 줄짜리 if 문이 중괄호 블록으로 확장되었습니다.

detachAnimVal()의 본체는 if (RefPtr animVal = std::exchange(m_animVal, nullptr)) animVal->detach();입니다. 기존 property에 대해 strong reference를 확보한 뒤 멤버를 비우고, 확보해둔 참조에 대해 SVGProperty::detach()를 호출하는 순서입니다. detach()SVGProperty::m_owner를 null로 만들고 access/state를 초기화합니다. 회귀 테스트는 애니메이션이 진행 중인 시점에 rect.x.animVal을 참조로 확보한 뒤, 애니메이션 요소와 그 조상들을 제거하고, GC를 강제로 수행시킨 다음 len.value를 읽습니다.

Lifetime invariant가 destructor에만 구현되어 있고, destructor가 아닌 해제 경로들은 마지막 owning reference를 내려놓으면서도 여전히 살아있는 자식 객체 안에 raw back-pointer를 그대로 남겨두는 패턴입니다.

SVG animated properties. <rect>x 같은 SVG 속성은 script 쪽에 SVGAnimatedLength로 노출되며, 여기에는 두 개의 하위 객체가 있습니다. baseVal은 파싱된 속성 값으로 읽기/쓰기가 모두 가능하고, animVal은 현재 SMIL 애니메이션이 만들어내는 값으로 읽기 전용입니다. WebCore 내부에서는 단일 값에 대해 SVGAnimatedValueProperty<T> 템플릿이, points처럼 리스트 형태의 속성에 대해 SVGAnimatedPropertyList<T>가 이를 담당합니다.

SVGProperty / SVGPropertyOwner. SVGProperty는 script에 노출되는 값 객체들의 base 클래스로, ThreadSafeRefCounted이기 때문에 독립적인 strong reference가 여러 개 동시에 존재할 수 있습니다. 내부에는 SVGPropertyOwner* m_owner라는 raw back-pointer가 있으며, attach()(또는 생성자)에서 설정되고 detach()에서 해제됩니다. SVGProperty::contextElement()는 이 back-pointer를 따라가면서 virtual 함수인 SVGPropertyOwner::attributeContextElement()를 호출해, 값 getter가 unit 해석에 필요로 하는 owning SVGElement를 찾습니다.

Ownership direction. Animated property는 자식들을 strong하게 소유합니다 (Ref<PropertyType> m_baseVal, RefPtr<PropertyType> m_animVal). 반면 자식들은 부모를 weak하게, 별도의 관리 없이 가리킵니다. 따라서 refcounting만으로는 자식이 부모와 독립적으로 살아남을 수 있고, back-pointer의 일관성은 오직 명시적인 detach() 호출로만 유지됩니다.

Bindings retention. script에서 rect.x.animVal을 읽으면, 반환된 SVGLength에 대해 생성된 JS wrapper가 내부의 SVGProperty에 대한 자체 Ref를 보유하게 됩니다. 이때부터 이 객체의 lifetime은 DOM tree가 아니라 JS reachability와 GC에 의해 결정됩니다.

SMIL start/stop. SVGAttributeAnimator가 animated property의 startAnimation()/stopAnimation()을 구동합니다. startAnimation()ensureAnimVal()을 호출해 animVal을 지연 생성하고, stopAnimation()은 애니메이션이 끝나거나 <animate> 요소가 문서에서 제거될 때 실행됩니다. instanceStartAnimationImpl()/instanceStopAnimationImpl()<use> shadow instance에 대한 동일한 역할을 담당하는데, 이 경우 instance는 원본 요소의 animVal 객체를 빌려 씁니다.

std::exchange. 값을 읽으면서 동시에 교체하는 단일 표현식입니다. 변수의 기존 값을 반환하는 동시에 새 값을 저장합니다. thread-safety나 atomicity를 보장하지는 않으며, 여기서는 기존 pointer를 확보하고 멤버를 비우는 두 동작을 한 번에 수행한 뒤 확보해둔 이전 값에 대해 후속 작업을 진행하는 용도로 사용됩니다.

이 취약점은 dangling된 raw back-pointer에서 비롯되는 use-after-free입니다.

  ensureAnimVal()          stopAnimation()             ~SVGAnimatedValueProperty
  ───────────────          ───────────────             ─────────────────────────
  create(this, ReadOnly)   m_animVal = nullptr   (수정 전: detach() 없음)
    m_owner = this           JS wrapper Ref가          free(this)
                             SVGLength를 계속 살려둠         │
                             m_owner는 여전히 this ─────────┘
                                     │
                    len.value ───────► contextElement()
                                         m_owner->attributeContextElement()
                                         └─► 해제된 메모리에 대한 virtual call

SVGAnimatedValueProperty<T>::ensureAnimVal()PropertyType::create(this, SVGPropertyAccess::ReadOnly, ...) 형태로 animVal을 생성하면서, 생성된 SVGProperty의 raw m_ownerthis를 설치합니다 (SVGProperty.h에서 SVGPropertyOwner* m_owner { nullptr };로 선언되어 있으며, 별도의 관리 없이 유지됩니다). list 버전은 ListType::create(m_baseVal, SVGPropertyAccess::ReadOnly) 형태로 생성되므로, 이 경우 owner는 animated property가 독점적으로 보유한 Ref<ListType> m_baseVal이 됩니다. 이 back-pointer에 대한 lifetime 계약은 단 한 곳, destructor의 if (m_animVal) m_animVal->detach();에서만 지켜지고 있었습니다. 하지만 m_animValRefPtr이고, animVal은 ThreadSafeRefCounted<SVGProperty>로서 script에 넘겨지기 때문에 bindings wrapper는 별도의 독립적인 Ref를 보유하게 됩니다 (이는 header 구조와 새로 추가된 테스트의 구성으로 볼 때 근거가 뒷받침되는 전제입니다). stopAnimation() / instanceStopAnimationImpl()m_animVal = nullptr;을 실행하거나, instanceStartAnimationImpl()m_animVal을 덮어쓸 때마다, animated property는 기존에 내보낸 property의 m_owner를 비우지 않은 채로 자신의 참조만 내려놓았습니다. 즉 destructor에만 있던 invariant가 destructor가 아닌 모든 해제 경로에서 우회되고 있었던 셈입니다.

참조가 해제된 뒤에도 animVal은 wrapper가 쥐고 있는 strong reference 위에서 계속 살아남고, m_owner는 여전히 이미 사라진 SVGAnimatedValueProperty를 가리킵니다. 이후 이 owner가 파괴되면 — element가 정리되거나 wrapper가 GC로 수거되면서 — pointee는 해제되지만, 아무도 m_owner를 비워주지 않습니다. SVGProperty::isAttached()는 여전히 true를 반환하므로, SVGProperty::contextElement()m_owner->attributeContextElement() 분기를 그대로 타게 됩니다. 이미 해제된 SVGPropertyOwner를 통한 virtual dispatch가 발생하는 지점입니다. SVGProperty::commitChange()도 동일한 구조를 가지고 있습니다 (m_owner->commitPropertyChange(this)). 다만 이쪽은 상대적으로 약한 경로에 해당합니다. animVal이 SVGPropertyAccess::ReadOnly로 생성되고, 수정 전 해제 경로가 m_access를 건드리지 않았기 때문에, 원래대로라면 read-only guard가 이렇게 dangling된 객체에 대한 setter 호출을 막아야 합니다. SVGProperty.h에서 온전히 뒷받침되는 경로는 contextElement() 쪽입니다.

추가된 테스트는 완전한 형태의 trigger 절차이며, 각 단계마다 의미가 있습니다.

  1. <svg> 안에 <rect id="r" x="10"><animate attributeName="x">가 존재합니다.
  2. s.pauseAnimations(); s.setCurrentTime(0.5)로 애니메이션을 active interval 안으로 진입시킵니다. 이 시점에는 startAnimation()이 이미 ensureAnimVal()을 실행한 상태이고, m_animVal이 존재하며 m_owner == this인 상태입니다.
  3. 별도의 grab() frame 안에서 document.getElementById("r").x.animVal을 호출하면, 독립적인 Ref<SVGLength>를 보유한 JS wrapper가 생성됩니다. frame을 분리한 이유는 중간 wrapper들이 stack에서 사라져, conservative scan에 의해 고정되지 않도록 하기 위해서입니다.
  4. a.remove(); r.remove(); s.remove()로 애니메이션을 종료시키면 stopAnimation()if (!this->isAnimating())에 도달합니다. 수정 전에는 여기서 m_animVal = nullptr;이 실행되었고, SVGLengthm_owner가 여전히 animated property를 가리키는 상태로 살아남았습니다.
  5. GCController.collect()(또는 일반적인 GC)가 SVGRectElement wrapper를 수거하면서 element와 그 property registry를 정리하고, 이 과정에서 SVGAnimatedValueProperty가 해제됩니다.
  6. len.valueSVGLength::valueForBindings()를 호출하고, 이는 다시 SVGProperty::contextElement()를 호출합니다. m_owner가 여전히 non-null이므로 해제된 객체에 대해 virtual attributeContextElement() 호출이 발생하고, 이 과정에서 dereference가 일어납니다.

instanceStartAnimationImpl()은 두 번째 경로를 제공합니다. <use> shadow instance로 애니메이션을 mirror하는 과정에서, 이전에 생성된 instance의 m_animVal을 detach 없이 덮어쓰기 때문에 애니메이션이 종료되는 과정 없이도 동일하게 dangling back-pointer가 만들어집니다.

Exploitability 측면에서 보면, 이 취약점은 완전히 웹에서 도달 가능하며 dereference 시점을 attacker가 직접 통제할 수 있습니다. 즉시 관찰되는 영향은 해제된 SVGPropertyOwner를 통한 virtual dispatch와, 그 결과로 반환되는 const SVGElement*에 대한 read입니다. 만약 해제된 SVGAnimatedValueProperty<SVGLength>가 groom 가능한 general-purpose heap size class에 놓인다면 — 제공된 header들만으로는 isolated-heap 여부가 확인되지 않습니다 — 그리고 attacker가 해당 slot을 controlled bytes로 재점유할 수 있다면, 이는 이론적으로 가능한 시나리오입니다. 4단계와 6단계 사이의 구간이 script로 통제 가능한 만큼 재점유 자체는 어렵지 않아 보이며, 이 경우 attributeContextElement()에서의 vtable load가 controlled indirect call로 이어질 가능성이 있습니다. 상대적으로 약하지만 오히려 더 안정적일 수 있는 경로도 있습니다. 재점유하는 객체가 SVGPropertyOwner의 필드 위치에 controlled pointer만 놓아도, contextElement()가 반환하는 값이 length 해석 코드에서 유효한 SVGElement처럼 취급될 수 있습니다. 이 경우 attacker가 지정한 주소를 기준으로 한 type-confused read primitive로 이어질 가능성이 있습니다. 두 시나리오 모두 재점유 성공을 전제로 한 projection이며, 재점유가 이루어지지 않는다면 결과는 attacker가 원하는 시점에 안정적으로 유발 가능한 renderer crash에 그칩니다.

이 취약점은 WebContent process 내부의 memory safety를 약화시킵니다. 여기서 깨지는 security model의 전제는, script에서 도달 가능한 DOM reflection 객체가 자신보다 오래 살 수 없는 C++ owner에 대한 raw pointer를 절대 보유해서는 안 된다는 것입니다. WebKit은 이 전제를 SVGProperty::detach()에 인코딩해두고, 모든 ownership 전환 지점에서 이 함수가 호출된다는 가정 위에서 동작합니다. 수정 전에는 일반적인 웹 콘텐츠만으로도 m_owner가 해제된 heap을 가리키는 animVal에 대한 JS 참조를 보유한 뒤, 원하는 시점에 이를 dereference할 수 있었습니다. 재점유에 성공한 attacker라면 renderer 내부의 attacker-groomed heap memory를 통한 virtual dispatch와 pointer-typed read를 확보할 수 있으며, 이는 read/write primitive 구축의 통상적인 출발점에 해당합니다.

구조적으로 드러나는 특징은, 다른 어떤 code path도 수행하지 않는 정리 작업을 destructor 혼자 수행하고 있었다는 점입니다. ~SVGAnimatedValueProperty()m_animVal->detach();를 실행했지만, destructor가 아닌 세 곳에서는 동일한 멤버를 detach 없이 해제하고 있었습니다. destructor가 back-pointer invariant의 유일한 집행자인 구조라면, 그보다 먼저 이루어지는 모든 해제 경로는 잠재적인 dangling-pointer 버그입니다. 이번 fix의 형태 — destructor의 작업을 named helper로 추출하고, 이를 모든 해제 지점에서 호출하도록 만드는 방식 — 가 이런 패턴에 대한 일반적인 해법입니다. 이번 패치의 결함이라기보다는 후속 확인이 필요한 사항으로 짚어둘 부분도 있습니다. instance 경로들은 m_animVal = animated.animVal()을 통해 원본 property로부터 animVal을 빌려 쓰는데, 이 객체의 m_owner다른 animated property를 가리킵니다. 이제 detachAnimVal()이 이 공유 객체에 대해 detach()를 호출하게 되므로, 원본 property의 animVal owner도 부수적으로 비워질 수 있습니다. <use> instance의 애니메이션이 멈춘 뒤에도 원본 element의 animVal.value가 여전히 자신의 context element를 정상적으로 resolve하는지는, 제공된 context만으로는 확인되지 않는 동작상의 질문이며, 별도의 targeted test가 필요해 보입니다.

발견 경위를 보면, destructor와 setter 사이의 비대칭성을 점검하는 방식의 pattern audit으로 읽힙니다. destructor 안에 이미 detach 호출이 있었기 때문에, m_animVal에 대한 다른 write를 검색하는 것만으로 세 곳이 곧바로 드러났을 것으로 보입니다. 테스트의 구성 — grab()을 별도 frame으로 분리해 conservative stack scan을 피한다는 주석을 명시적으로 달아둔 점, 그리고 수동으로 GCController.collect()를 호출한 점 — 은 fuzzer가 만들어낸 축소된 산출물이 아니라, 코드를 읽은 뒤 직접 작성한 PoC라는 인상을 줍니다.