← All reports

[5] Use-after-free in DataListButtonElement::defaultEventHandler

HighWebCore HTML formsUAF

One click, a two-line listener, and a virtual call into freed heap.

be08720

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

 
- class DataListButtonOwner {
+ class DataListButtonOwner : public AbstractRefCountedAndCanMakeWeakPtr<DataListButtonOwner> {
public:
virtual ~DataListButtonOwner() = default;
virtual void dataListButtonElementWasClicked() = 0;
};
...
+ void removeOwner() { m_owner = nullptr; }
...
 
- DataListButtonOwner& m_owner;
+ WeakPtr<DataListButtonOwner> m_owner;

Source/WebCore/html/shadow/DataListButtonElement.cpp

if (isAnyClick(*mouseEvent)) {
 
- m_owner.dataListButtonElementWasClicked();
+ if (RefPtr owner = m_owner)
+ owner->dataListButtonElementWasClicked();
event.setDefaultHandled();
}

Source/WebCore/html/TextFieldInputType.cpp

void TextFieldInputType::removeShadowSubtree()
...
m_autoFillButton = nullptr;
+ if (RefPtr dataListDropdownIndicator = m_dataListDropdownIndicator)
+ dataListDropdownIndicator->removeOwner();
m_dataListDropdownIndicator = nullptr;
m_container = nullptr;

LayoutTests/fast/forms/datalist/datalist-button-change-input-type-on-click-crash.html

+<input id="input" type="text" list="list">
+<datalist id="list"><option value="a"><option value="b"></datalist>
+ input.addEventListener('click', (e) => {
+ input.type = 'button';
+ gc();
+ }, { once: true });
+ let shadow = internals.shadowRoot(input);
+ let listButton = shadow.querySelector("div[useragentpart='-webkit-list-button']");
+ await UIHelper.activateElement(listButton);

세 곳의 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되는 패턴입니다.

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++ 객체 상태를 동기적으로 변경한 뒤 되돌아올 수 있습니다.

WeakPtrAbstractRefCountedAndCanMakeWeakPtr. 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을 무엇으로 채울지 자유롭게 선택하지 못하도록 막는 데 있습니다.

버튼은 owner를 생성자 initializer list에서 바인딩된 raw C++ reference로 저장하고 있었습니다. 두 객체의 lifetime은 서로 독립적입니다. Shadow div는 event dispatch 도중 취해진 참조에 의해 살아있는 Node인 반면, TextFieldInputTypeHTMLInputElement::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>가 있으므로, TextFieldInputTypeDataListButtonElement를 포함하는 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가 필요합니다.

패치는 SpinButtonElement와 명시적으로 동일하게 맞췄으며, diff를 보면 같은 함수 안에서 새로 추가된 dataListDropdownIndicator->removeOwner() 바로 한 줄 위에 기존의 m_autoFillButton->removeOwner() 호출이 있습니다. 이 배치가 문제의 단서입니다. TextFieldInputType에 대한 back-pointer를 가진 세 개의 UA shadow child 중 두 개는 이미 안전한 패턴이 적용되어 있었고, datalist 버튼만 유일하게 그 전환에서 빠져 있었던 셈입니다. Teardown 루틴이 소유한 child 목록을 정리하면서 그중 일부에만 상호 back-pointer clear를 적용하고 있다면, 빠진 나머지가 바로 UAF 후보가 됩니다. 패치의 나머지 절반도 별도로 주목할 만합니다. 호출 지점에서 WeakPtrRefPtr로 승격시키는 것은 단순한 null 체크가 아니라, dataListButtonElementWasClicked()가 실행되는 동안 owner를 고정시키는 역할을 합니다. 이 함수 자체가 suggestion picker를 열면서 다시 스크립트로 re-entrant할 수 있는 지점이기 때문입니다.

Section [6]을 스타일 가이드에 맞춰 한국어로 번역하겠습니다.