[3] SVG animVal released without detaching its owner back-pointer
Three code paths dropped the pointer. Only the destructor remembered to detach.
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|를 rawSVGProperty::m_ownerback-pointer로 삼아 animValSVGProperty를 생성합니다. 소멸자는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
Source/WebCore/svg/properties/SVGAnimatedPropertyList.h
LayoutTests/svg/animations/animVal-detach-after-stopAnimation-crash.html
Patch Details
두 개의 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를 그대로 남겨두는 패턴입니다.
Background
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를 확보하고 멤버를 비우는 두 동작을 한 번에 수행한 뒤 확보해둔 이전 값에 대해 후속 작업을 진행하는 용도로 사용됩니다.
Analysis
이 취약점은 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_owner에 this를 설치합니다 (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_animVal은 RefPtr이고, 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 절차이며, 각 단계마다 의미가 있습니다.
<svg>안에<rect id="r" x="10">와<animate attributeName="x">가 존재합니다.s.pauseAnimations(); s.setCurrentTime(0.5)로 애니메이션을 active interval 안으로 진입시킵니다. 이 시점에는startAnimation()이 이미ensureAnimVal()을 실행한 상태이고,m_animVal이 존재하며m_owner == this인 상태입니다.- 별도의
grab()frame 안에서document.getElementById("r").x.animVal을 호출하면, 독립적인Ref<SVGLength>를 보유한 JS wrapper가 생성됩니다. frame을 분리한 이유는 중간 wrapper들이 stack에서 사라져, conservative scan에 의해 고정되지 않도록 하기 위해서입니다. a.remove(); r.remove(); s.remove()로 애니메이션을 종료시키면stopAnimation()이if (!this->isAnimating())에 도달합니다. 수정 전에는 여기서m_animVal = nullptr;이 실행되었고,SVGLength는m_owner가 여전히 animated property를 가리키는 상태로 살아남았습니다.GCController.collect()(또는 일반적인 GC)가SVGRectElementwrapper를 수거하면서 element와 그 property registry를 정리하고, 이 과정에서SVGAnimatedValueProperty가 해제됩니다.len.value가SVGLength::valueForBindings()를 호출하고, 이는 다시SVGProperty::contextElement()를 호출합니다.m_owner가 여전히 non-null이므로 해제된 객체에 대해 virtualattributeContextElement()호출이 발생하고, 이 과정에서 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 구축의 통상적인 출발점에 해당합니다.
Insight
구조적으로 드러나는 특징은, 다른 어떤 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라는 인상을 줍니다.
Audit directions
-
Invariant가 destructor에서만 강제되는 패턴. raw back-pointer를 null로 만들거나, 등록을 해제하거나, 연결을 끊는 작업이
~T()에서만 이루어지고, 다른 code path는 동일한 자식에 대한 마지막 owning reference를 그냥 해제하고 마는 경우입니다. 여기서 지켜야 할 invariant는 모든 ownership-drop 지점이 destructor와 동일한 정리 작업을 수행해야 한다는 것입니다. 좁게 보면:Source/WebCore/svg/properties/에서m_animVal,m_baseVal, 혹은 detach helper를 거치지 않는RefPtr<SVG*Property>멤버에 대한 대입을 검색해볼 필요가 있습니다.SVGAnimatedProperty.h,SVGValuePropertyList.h,SVGPropertyList.h가 바로 인접한 후보이며, 멤버 해제 구문의 타입이 해당 클래스 destructor 안의->detach()호출 옆에도 등장하는지가 판별 포인트입니다. 넓게 보면:clear(),invalidate(),unregister(),removeClient(),setParent(nullptr)처럼 수동으로 호출되는 다른 형태의 정리 작업에서도 같은 구조가 나타날 수 있습니다. raw parent pointer를 저장하는X타입에 대해RefPtr<X> m_child를 보유한 WebCore 클래스들을 찾아, destructor 본문과 해당 멤버에 대한 다른 모든 write를 서로 비교해볼 필요가 있습니다. 가장 넓게 보면: 이 invariant는 객체가 destruction 이외의 경로로도 해제될 수 있는 한, destructor에서만 이루어지는 정리는 버그라는 일반 원칙으로 확장됩니다. parent-child back-pointer를 사용하는 모든 refcounted/GC 기반 codebase에 적용될 수 있습니다 — Chromium의blink::Member/WeakMember쌍,Weak가 raw index로 대체된 Rust의Rc/Weakgraph, Objective-C의unsafe_unretaineddelegate 필드 등이 해당됩니다. 판별 기준: destructor가 어떤 멤버에 대해 정리 작업 X를 수행하는데, 다른 어떤 줄에서 동일한 멤버를 대입하거나 비운다면, 그 지점에는 X가 빠져 있는 것입니다. -
SVGProperty::m_owner가 stale해졌을 때 영향을 받는 또 다른 소비 지점들,contextElement()외에도.SVGProperty.h에는 owner를 dereference하는 지점이 두 곳 있습니다 —contextElement()(m_owner->attributeContextElement())와commitChange()(m_owner->commitPropertyChange(this))입니다. 여기에 더해detach()가 함께 초기화하는isAttached(),reattach(),access()/isReadOnly()state도 있습니다.SVGLength,SVGNumber,SVGPointList,SVGTransformList등에서 script에 노출된 모든 binding을 추적해,commitChange()에 도달하는 경로들(즉 owner가 이미 해제되었지만 여전히 attach 상태인 property에 대해 setter를 호출하는 경로)이 존재하는지 확인할 필요가 있습니다. 판별 기준:SVGProperty서브클래스의 public binding method가isAttached()나 read-only guard 확인 없이commitChange()를 호출하는 경우입니다. -
소유자 범위(owner-scoped) teardown을 borrower가 실행하는 패턴. 한 객체가 어떤 owner로부터 빌려져 다른 owner의 strong member에 저장되고, 이 borrower가 해제되는 시점에 빌려온 객체를 대상으로 owner-scoped cleanup이 실행됩니다. 좁게 보면,
SVGAnimatedValueProperty.h와SVGAnimatedPropertyList.h의instanceStartAnimationImpl/instanceStopAnimationImpl을 살펴봐야 합니다. 패치 이후에는 instance 경로에서detachAnimVal()이 호출되는데, 이때 대상은 원본 animated property가 생성하고 그 property를 다시 가리키고 있는 animVal입니다. 따라서 살펴볼 지점은, 로컬에서 생성된 것이 아니라 다른 객체의 accessor로부터 대입받은 member에 대해detach()/reset()이 적용되는 패턴입니다. 조금 더 넓게 보면,<use>계열의 instance mirroring이나 style-sharing, 캐시된 객체 공유가 이루어지는 곳이라면 어디서든 같은 형태가 나타날 수 있습니다. WebCore 전반에서other.something()형태의 accessor로부터 대입된 member가 이후 로컬 소유물처럼 정리되는 코드를 검색해볼 필요가 있습니다. 가장 넓은 관점에서 보면, 이 invariant는 다음과 같습니다 — 공유되는 객체에 대한 owner-scoped teardown은 오직 그 객체를 생성한 owner만 실행할 수 있고, borrower는 자신의 reference를 drop하는 것만 허용됩니다. 이 원칙은 shared handle과 non-idempotent close semantics를 가진 모든 시스템에 적용됩니다. 컴포넌트 간에 전달되는 file descriptor, 공유되는 GPU resource, 혹은 clone된 handle에 대해Drop이 외부 deregistration을 수행하는 Rust 타입 등이 여기에 해당합니다. 이때 확인해야 할 패턴은, 단순히 reference를 drop하는 것이 아니라 공유 객체의 상태 자체를 변경하는 cleanup이 그 객체를 생성하지 않은 코드 경로에서 호출되는 경우입니다. -
이 취약점 클래스가 관찰 가능해지는 전제인 teardown 순서 가정을 검증해야 합니다. Script가 parent보다 child를 더 오래 살아있게 유지할 수 있어야 합니다. 어떤 SVG DOM reflection이
ThreadSafeRefCountedsub-object를 binding에 넘겨주는지 조사할 필요가 있습니다 —SVGAnimatedLength.animVal/.baseVal, list item accessor,SVGTransformList의 item getter 등이 대상입니다. 각각에 대해, JS wrapper가 들고 있는Ref가 해당 property registry를 소유한 element보다 더 오래 살아남을 수 있는지 확인해야 합니다. 확인 대상은, WebIDL로 노출된 getter가Ref<SVGProperty subclass>를 반환하되 그 owner가 non-refcounted이거나 element-scoped C++ 객체인 경우입니다. 바로 이런 객체에서detach()누락이 internal 영역에 머무르지 않고 web에서 도달 가능한 문제로 이어집니다.