[5] Use-after-free in DataListButtonElement::defaultEventHandler
One click, a two-line listener, and a virtual call into freed heap.
High. 한 번의 클릭과 두 줄짜리 리스너만으로 renderer 안에서 해제된 heap을 통한 virtual call이 발생합니다. 이 상황이 깔끔한 control-flow primitive로 이어지지 못하는 이유는 TZone segregation 때문인데, 재사용되는 메모리가 같은 타입의 allocation 쪽으로 편향되기 때문입니다. 그래서 안정적으로 관찰되는 결과는 임의의 vtable이 아니라 stale object confusion입니다.
WebKit은 form control을 위해 내부적으로 user-agent shadow DOM을 구성하며, <input>의 타입별 동작은 element가 위임하는 refcounted 구현 객체 안에 들어 있습니다. <input type=text list=...>의 경우 이 구현 객체는 필드 옆에 렌더링되는 작은 드롭다운 표시 버튼도 함께 소유하며, 이 버튼은 클릭 시 suggestion picker를 열 수 있도록 구현 객체에 대한 back-pointer를 갖습니다. 문제는 두 객체의 lifetime이 서로 분리되어 있다는 점입니다. 버튼은 event dispatch 도중 취해진 참조에 의해 살아있는 Node인 반면, 구현 객체는 스크립트가 input.type을 변경하는 순간 해제됩니다.
관전 포인트: datalist 드롭다운 버튼에 대한 한 번의 클릭을 유도하는 페이지는 클릭 리스너 안에서 소유 input-type 객체를 해제시킬 수 있고, 이후 브라우저가 해제된 메모리를 통해 virtual call을 수행하도록 만들 수 있습니다.
DataListButtonElement는 자신의 owner를 raw DataListButtonOwner& m_owner로 저장합니다. DataListButtonOwner를 구현하는 유일한 클래스는 TextFieldInputType입니다. 소유 input element의 타입이 변경되면 HTMLInputElement::updateType()이 removeShadowSubtree()를 호출합니다. 이 호출은 m_dataListDropdownIndicator를 null로 만들지만, DataListButtonElement 쪽의 owner 멤버는 초기화하지 않습니다. click 리스너 안에서 타입을 변경하면 event dispatch가 진행 중인 상태에서 TextFieldInputType이 해제되는 결과로 이어집니다. 이후 DataListButtonElement::defaultEventHandler()가 호출되면서, 이미 해제된 m_owner에 대해 m_owner.dataListButtonElementWasClicked()를 호출하게 됩니다. 패치에서는 owner를 WeakPtr로 저장하고 removeShadowSubtree()에서 이를 초기화하도록 변경했으며, 이는 SpinButtonElement의 기존 구현 방식과 일치합니다.
Source/WebCore/html/shadow/DataListButtonElement.h
Source/WebCore/html/shadow/DataListButtonElement.cpp
Source/WebCore/html/TextFieldInputType.cpp
LayoutTests/fast/forms/datalist/datalist-button-change-input-type-on-click-crash.html
Patch Details
세 곳의 production 코드 변경과 regression test 하나로 구성되어 있습니다. DataListButtonElement.h에서는 back-pointer 멤버가 DataListButtonOwner& m_owner에서 WeakPtr<DataListButtonOwner> m_owner로 바뀌었고, 중첩된 DataListButtonOwner 인터페이스는 이제 AbstractRefCountedAndCanMakeWeakPtr<DataListButtonOwner>를 상속받아 weak reference를 strong reference로 승격시킬 수 있게 되었습니다. 또한 새로운 void removeOwner() { m_owner = nullptr; } accessor가 추가되었습니다.
DataListButtonElement.cpp에서는 defaultEventHandler에서 무조건 호출되던 m_owner.dataListButtonElementWasClicked()가 if (RefPtr owner = m_owner) owner->dataListButtonElementWasClicked();로 바뀌었습니다. null 체크와 함께, virtual call이 진행되는 동안 strong ref를 유지하는 방식입니다.
TextFieldInputType.cpp에서는 removeShadowSubtree()가 m_dataListDropdownIndicator를 초기화하기 전에 dataListDropdownIndicator->removeOwner()를 호출하도록 변경되었습니다. 이는 바로 위에 있던 기존의 autoFillButton->removeOwner() 라인과 동일한 패턴입니다. 새로 추가된 layout test는 input에 등록된 click 리스너가 input.type = 'button'을 설정하고 gc()를 호출하는 동안, -webkit-list-button shadow element를 활성화합니다.
Teardown 시점에 결코 무효화되지 않는 raw back-pointer가, owner를 파괴할 수 있는 스크립트 re-entrancy window 이후 dereference되는 패턴입니다.
Background
User-agent shadow subtree.
Form control은 control의 InputType이 소유하는 내부 shadow DOM을 구성합니다. <input type=text list=...>의 경우 여기에는 suggestion list를 여는, useragentpart='-webkit-list-button'을 가진 DataListButtonElement div가 포함됩니다.
InputType / TextFieldInputType.
HTMLInputElement는 타입별 동작을 refcounted InputType 객체에 위임합니다. TextFieldInputType은 text 계열 input을 뒷받침하는 서브클래스이며, DataListButtonOwner 인터페이스를 구현하는 유일한 클래스입니다. 이런 위임 구조가 존재하는 이유는, input.type을 변경할 때 하나의 monolithic element 클래스를 변형시키는 대신 구현 객체 하나를 다른 것으로 교체할 수 있도록 하기 위해서입니다.
HTMLInputElement::updateType().
스크립트에서 input.type에 값을 대입하면 InputType 객체가 교체되며, 이 과정에서 기존 객체의 removeShadowSubtree()가 호출되고 해당 객체는 해제됩니다.
Event dispatch ordering.
DOM dispatch는 먼저 capture/target/bubble phase에서 author 리스너를 실행합니다. 이후 같은 event에 대한 default (user-agent) handling의 일부로 node의 defaultEventHandler()가 실행됩니다.
Re-entrancy. Native 코드가 JavaScript로 제어를 넘기는 지점을 의미하며, 이 시점에 JavaScript가 C++ 객체 상태를 동기적으로 변경한 뒤 되돌아올 수 있습니다.
WeakPtr와 AbstractRefCountedAndCanMakeWeakPtr.
WeakPtr는 대상이 파괴되면 null로 읽히는, non-owning 방식의 WebKit smart pointer입니다. AbstractRefCountedAndCanMakeWeakPtr는 abstract interface에 대한 weak reference를 strong RefPtr로 승격시킬 수 있게 해주는 base 클래스입니다.
TZone allocation.
WTF_MAKE_TZONE_ALLOCATED는 클래스 인스턴스를 타입별로 분리된 heap zone에 배치하여, 해제된 storage가 같은 타입의 객체에 의해 우선적으로 재사용되도록 합니다. 이런 segregation의 목적은 attacker가 해제된 slot을 무엇으로 채울지 자유롭게 선택하지 못하도록 막는 데 있습니다.
Analysis
버튼은 owner를 생성자 initializer list에서 바인딩된 raw C++ reference로 저장하고 있었습니다. 두 객체의 lifetime은 서로 독립적입니다. Shadow div는 event dispatch 도중 취해진 참조에 의해 살아있는 Node인 반면, TextFieldInputType은 HTMLInputElement::updateType()이 다른 InputType으로 교체할 때 해제됩니다. Teardown 경로의 어느 부분도 버튼의 back-pointer를 끊어주지 않았습니다. removeShadowSubtree()는 owner 쪽의 forward pointer인 m_dataListDropdownIndicator만 null로 만들었을 뿐이고, 그 결과 살아남은 shadow element 안의 raw reference는 dangling 상태로 남겨졌습니다.
create click dispatch default handling
────── ───────────── ────────────────
DataListButtonElement
m_owner = &TextFieldInputType ──────────────────────────────┐
▼ │
author listener runs │
input.type = 'button' │
└─► updateType() │
└─► removeShadowSubtree() │
m_dataListDropdownIndicator = null │
(m_owner NOT cleared) ◄── the gap │
└─► last ref dropped, TextFieldInputType freed
▼ │
defaultEventHandler() │
isAnyClick branch │
m_owner.dataListButtonElementWasClicked() ◄───────┘ UAF virtual call
취약한 window는 정확히 author 리스너가 반환된 시점부터 default handling이 버튼에 도달하는 시점 사이입니다. Author JS는 같은 event에서 항상 먼저 실행되기 때문에, native handler가 owner를 사용하기 전에 스크립트가 이를 파괴할 기회가 반드시 주어집니다. 이번 패치는 이 문제를 양쪽에서 모두 닫습니다. 먼저 owner pointer를 weak로 바꿔 파괴 이후 null로 읽히도록 했고, shadow-subtree teardown 과정에서 파괴 이전에 선제적으로 이를 초기화하도록 했습니다. 그리고 호출 지점에서는 weak reference를 RefPtr로 승격시켜, dataListButtonElementWasClicked()가 실행되는 동안 owner가 고정되도록 만들었습니다. 이 함수 자체도 suggestion display를 통해 다시 스크립트로 re-entrant할 수 있는 지점입니다.
Web content로부터 도달 가능하지만, datalist 드롭다운 affordance에 대한 실제 클릭이 필요합니다. 취약한 분기는 isAnyClick(*mouseEvent)로 게이트되어 있고, 버튼은 닫힌 user-agent shadow tree 안에 있기 때문에, test에서는 이를 직접 타겟팅하기 위해 internals.shadowRoot()와 UIHelper.activateElement가 필요합니다. 다만 일반적인 web content라면 <input type=text list=...>의 list 버튼을 클릭하도록 사용자를 유도하는 것으로 충분합니다. 시각적으로 그럴듯한 컨트롤에 대한 한 번의 클릭이면 됩니다. Regression test를 따라가 보면 다음과 같습니다. (1) 페이지에 <input id=input type=text list=list>가 있으므로, TextFieldInputType이 DataListButtonElement를 포함하는 shadow subtree를 구성하고, 이 생성자는 m_owner(owner)를 raw reference로 저장합니다. (2) input에 click 리스너가 등록됩니다. (3) 사용자가 list 버튼을 클릭하면, dispatch가 버튼을 target으로 시작해 input까지 bubble됩니다. (4) 리스너가 input.type = 'button'을 설정하면 updateType()이 동기적으로 실행됩니다. removeShadowSubtree()는 m_dataListDropdownIndicator만 null로 만들 뿐 버튼의 back-pointer는 건드리지 않으며, 기존 TextFieldInputType에 대한 참조는 해제됩니다. (5) 리스너 안의 gc()는 해당 storage가 단순히 논리적으로만 죽은 상태가 아니라 실제로 회수될 가능성을 높입니다. (6) dispatch가 마무리되며 defaultEventHandler()에 도달하고, 이 함수가 isAnyClick 분기를 타면서 이미 dangling 상태인 m_owner를 통해 virtual call을 수행합니다.
Crash를 넘어서는 확장 여부는 조건부입니다. 만약 6단계 이전에 해제된 TextFieldInputType slot이 같은 타입의 allocation으로 재사용된다면 상황이 달라집니다. 이 클래스는 WTF_MAKE_TZONE_ALLOCATED_IMPL(TextFieldInputType)을 갖고 있어 재사용이 다른 TextFieldInputType 인스턴스 쪽으로 편향되며, 페이지는 같은 리스너에서 text input을 대량으로 삽입해 이런 인스턴스를 다수 만들어낼 수 있습니다. 이 경우 virtual dispatch는 유효한 vtable에 도달하겠지만 stale하거나 attacker가 선택한 객체를 대상으로 하게 되고, 이어지는 dataListButtonElementWasClicked()의 동작(m_suggestionPicker와 연관된 element()를 대상으로 한 suggestion display)이 잘못된 control을 상대로 이뤄질 가능성이 있습니다. 반대로 TZone page가 무관한 콘텐츠로 재활용된다면 vtable load가 attacker가 영향을 미치는 control flow로 이어질 가능성도 있지만, TZone segregation으로 인해 일반적인 general-purpose heap UAF보다는 훨씬 어려운 조건이 됩니다. 검증된 heap 재사용이 확인되지 않는 한, 안정적으로 관찰되는 효과는 해제된 메모리에 대한 virtual call, 즉 attacker가 유발 가능한 renderer crash입니다.
이 취약점은 WebContent process 내부의 memory safety를 약화시킵니다. 여기서 깨지는 security model의 전제는, user-agent shadow element가 소유 InputType에 대한 back-pointer의 유효 기간을 결코 벗어나지 않는다는 것입니다. 그런데 author JS가 같은 event에서 default event handling보다 먼저 실행되기 때문에, 스크립트가 event 리스너 안에서 input.type을 변경하는 것만으로도 이 invariant는 깨질 수 있습니다. 패치 이전에는 attacker가 통제하는 페이지가 해제된 heap 메모리를 통한 virtual call을 유발할 수 있었고, 유리한 heap 재사용 조건이 갖춰진다면 renderer 안에서 control-flow 또는 state-confusion primitive로 이어질 가능성이 있습니다. 최소한으로 보더라도 attacker가 유발 가능한 renderer crash에 해당합니다. 이로 인한 어떤 형태의 침해든 WebContent sandbox 내부에 국한되며, 이를 벗어나려면 별도의 sandbox escape가 필요합니다.
Insight
패치는 SpinButtonElement와 명시적으로 동일하게 맞췄으며, diff를 보면 같은 함수 안에서 새로 추가된 dataListDropdownIndicator->removeOwner() 바로 한 줄 위에 기존의 m_autoFillButton->removeOwner() 호출이 있습니다. 이 배치가 문제의 단서입니다. TextFieldInputType에 대한 back-pointer를 가진 세 개의 UA shadow child 중 두 개는 이미 안전한 패턴이 적용되어 있었고, datalist 버튼만 유일하게 그 전환에서 빠져 있었던 셈입니다. Teardown 루틴이 소유한 child 목록을 정리하면서 그중 일부에만 상호 back-pointer clear를 적용하고 있다면, 빠진 나머지가 바로 UAF 후보가 됩니다. 패치의 나머지 절반도 별도로 주목할 만합니다. 호출 지점에서 WeakPtr를 RefPtr로 승격시키는 것은 단순한 null 체크가 아니라, dataListButtonElementWasClicked()가 실행되는 동안 owner를 고정시키는 역할을 합니다. 이 함수 자체가 suggestion picker를 열면서 다시 스크립트로 re-entrant할 수 있는 지점이기 때문입니다.
Audit directions
-
Asymmetric teardown — Owner가 child 객체에 대한 forward pointer는 초기화하면서도 child가 가진 back-pointer는 결코 무효화하지 않아서, owner보다 오래 살아남은 child가 stale reference를 그대로 갖고 있는 패턴입니다. 좁게 보면:
TextFieldInputType::removeShadowSubtree()의 나머지 부분과, 다른InputType서브클래스(SearchInputType,NumberInputType,ColorInputType,FileInputType,RangeInputType)의 동등한 teardown 코드를 점검할 필요가 있습니다. 발견해야 할 패턴은m_someChild = nullptr;형태의 라인 앞에someChild->removeOwner()/someChild->clearOwner()호출이 없는 경우이며, 이때 child의 header에 owner 멤버가 선언되어 있는지도 함께 확인해야 합니다. 넓게 보면: WebCore 객체가 자신이 생성한 child에게*this를 넘기는 모든 곳에서 같은 형태가 나타날 수 있습니다.Source/WebCore에서Owner&또는Client&를 받아WeakPtr/CheckedPtr가 아니라T& m_owner/T* m_owner로 저장하는 생성자를 검색해 볼 필요가 있습니다. 검색 결과에서 확인해야 할 단서는, 생성자 initializer list에서 초기화되고 다른 어디에서도 대입 연산자가 존재하지 않는 reference 타입 멤버입니다. 이런 멤버는 결코 초기화될 수 없기 때문입니다. 가장 넓게 보면: 단방향 teardown을 갖는 일반적인 parent-child back-pointer 클래스 전체가 해당됩니다. 모든 back-pointer는 이를 무효화하는 코드 경로를 정확히 하나 가져야 하며, 그 경로는 owner가 파괴되는 모든 경로에서 실행되어야 합니다. 이 원칙은 Chromium의base::WeakPtrobserver wiring, Qt의 parent/child widget graph, 그리고 성능을 위해Weak쪽을 raw pointer로 흉내 낸Rc/Weak혼용 Rust 코드에도 그대로 적용됩니다. 어떤 코드베이스에서든 발견해야 할 단서는, N개의 소유 handle을 초기화하는 destructor나 teardown 메서드가 그중 N개보다 적은 수에만 back call을 수행하는 경우입니다. 코드 리뷰에서는Node서브클래스 안에T&로 선언된 멤버가 있다면, 참조 대상이 node보다 오래 살아남는다는 보장이 무엇인지 설명하는 주석을 남길 가치가 있습니다. -
defaultEventHandler이 form-control 내부 로직으로 다시 진입하는 user-agent shadow element들을 점검해야 합니다, author listener가 같은 이벤트에서 항상 default handling보다 먼저 실행되기 때문입니다. 좁게는,Source/WebCore/html/shadow/에서defaultEventHandler구현들을 검색해, 각각에서 재검증되지 않은 member dereference가 있는지 확인합니다.TextControlInnerElements.cpp의SearchFieldResultsButtonElement,SearchFieldCancelButtonElement,SpinButtonElement와AutoFillButtonElement부터 살펴보십시오. 단서는Node가 아닌 협력 객체에 대한 member pointer 또는 reference를, 중간의 null check 없이 호출하는 패턴입니다. 넓게는, native state를 캐시해 두었다가 이벤트를 dispatch하거나 다른 방식으로 script에 재진입한 뒤 그 캐시된 state를 사용하는 모든 WebCore 코드가 같은 범주에 해당합니다.MouseEventTypes/EventHandler의 default-handling 경로,HTMLFormElement의 submission 처리,HTMLMediaElement의 control-panel shadow handler를 살펴보십시오. 가장 넓게는, re-entrancy 경계 이전에 확보한 pointer는 재검증하거나 그 구간 동안 strong ref를 유지하지 않는 한, 경계 이후에 dereference해서는 안 됩니다 — 어떤 엔진에 적용하든(Blink의EventTarget::FireEventListeners, Gecko의 이벤트 처리, 또는 작업 도중 user code를 호출하는 임의의 embedder plugin API) 던져야 할 질문은 "user callback이 어떤 native 객체를 파괴했을 수 있는가?"입니다. -
input.typemutation 경로가 stack에 살아있거나 진행 중인 작업에서 도달 가능한 다른 객체까지 해제하는지 점검해야 합니다, shadow child에만 국한되지 않습니다. 좁게는,HTMLInputElement::updateType()을 추적해 교체되어 사라지는InputType이 소유하거나 그로부터 참조되는 모든 대상을 나열합니다 — suggestion picker,m_suggestionPicker, renderer/RenderTheme연결,DataListSuggestionsClient등록 여부가 여기 해당합니다. 각각에 대해 teardown 시점에 정리되는 대응 코드가 있는지 확인하십시오. 단서는 사라지는InputType의 member가 client list, event-loop task, chrome-client callback처럼 더 오래 살아남는 collection에 넘겨지는 경우입니다. 이때 그에 대응하는 제거 처리가 없다면 문제가 됩니다. 넓게는, script에서 보이는 attribute 교체가 구현 객체를 mutate하는 대신 통째로 새 객체로 바꿔치기하는 모든 WebCore API가 같은 범주에 해당합니다 —HTMLMediaElement의 source/type 변경,HTMLCanvasElement의 context 교체, custom-element upgrade와attributeChangedCallback경로가 여기 포함됩니다. 코드 검색에서 찾아야 할 형태는 attribute setter에서 도달 가능한 메서드 내부의m_impl = createSomething(...)형태 대입문입니다. 가장 넓게는, script에서 도달 가능한 setter로 polymorphic 구현 객체를 교체하는 행위는, 기존 구현을 가리키던 모든 대상 입장에서 destruction 이벤트로 취급되어야 합니다 — 이 원칙은 임의의 plugin/strategy-swap 아키텍처, handler 객체를 hot-swap하는 state machine, 런타임에 singleton을 다시 바인딩하는 DI container에도 그대로 적용됩니다. -
이 커밋에서 사용된 strong-ref-across-the-call idiom이 일관되게 적용되고 있는지 확인해야 합니다, weak back-pointer를 dereference해 script에 재진입할 수 있는 메서드를 호출하는 모든 지점에서 그렇습니다. 좁게는, shadow-element 이벤트 핸들러에서
if (auto owner = m_owner)/if (RefPtr x = m_something)패턴을 WebCore 전체에서 검색하고, 이를 raw-dereference된 weak pointer에 대해m_owner->foo()를 바로 호출하는 지점들과 비교합니다. 단서는 local strong ref 없이m_weakMember->method()또는m_weakMember.get()->method()형태로 호출하면서, 그method()가 내부적으로 이벤트를 dispatch하거나 style을 resolve하는 경우입니다. 넓게는,Source/WebCore/page와Source/WebCore/dom의 observer-delivery loop에서 이루어지는CheckedPtr/WeakRefdereference도 같은 범주에 해당합니다. 이 경우 referent가 호출 도중에 죽을 수 있기 때문에 null check만으로는 충분하지 않습니다. 가장 넓게는, weak reference에 대한 null check는 오직 그 확인 시점의 liveness만을 보장하며, user code로 재진입 가능한 호출은 호출이 끝날 때까지 ownership을 붙잡고 있어야 합니다 — upgrade-and-hold 방식과 check-and-use 방식의 이러한 구분은 Rust의Weak::upgrade(바로 이런 이유로 ownership을 갖는Arc를 반환합니다), C++의std::weak_ptr::lock, 그리고 Objective-C에서__weak읽기 결과를__stronglocal 변수에 대입하는 패턴에서도 동일하게 나타납니다.
Section [6]을 스타일 가이드에 맞춰 한국어로 번역하겠습니다.