← All reports

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

HighWebCore HTML forms / UA shadow DOMUAF

CVE: CVE-2026-64783 · Safari 26.6 · 2026년 7월 27일 출시 Impact: 조작된 웹 콘텐츠를 처리하는 과정에서 예기치 않은 Safari crash가 발생할 수 있습니다. Apple's description: 메모리 관리를 개선하여 use-after-free 문제를 수정하였습니다. Credit: 杉山 壮太, lattice, Behzad Najjarpour Jabbari (@G4ru), Junyeong Lee, Mooth.ai, OGINOME Tomohito, Using GLM From Z.AI, Gia Bui (@yabeow) from Calif.io

be08720 | Bugzilla 313521

High. 대상보다 오래 살아남는 raw back-pointer가 pure-virtual call로 dereference되는 구조로, form control의 teardown 과정을 indirect branch로 바꿔놓는 패턴입니다. Crash를 넘어서는 확장에는 동일 타입의 heap reclaim이 필요하지만, 배포된 test case는 이를 시도하지 않습니다.

WebKit에서 복잡한 form control은 단일 element가 아닙니다. 각 <input>은 자신의 동작을 polymorphic한 구현 객체에 위임하고, 페이지에서 렌더링된 모습은 보이지만 직접 스크립트로 접근할 수 없는 helper node로 이루어진 내부 shadow tree를 갖게 됩니다. 이 helper node들은 자신을 생성한 구현 객체를 가리키는 back-pointer를 가지고 있는데, element의 type이 바뀌는 순간 구현 객체 자체가 통째로 교체됩니다. <input list=...>에 삽입되는 dropdown indicator인 DataListButtonElement는 이 back-pointer를 생성자에서 한 번 바인딩된 raw C++ reference로 보관했으며, 클래스 어디에도 이를 해제할 수 있는 경로가 없었습니다.

관전 포인트: datalist dropdown 화살표에 클릭 한 번만 받아도, 페이지는 listener 안에서 input의 구현 객체를 파괴한 뒤 native code가 해제된 pointer를 통해 virtual call을 수행하도록 만들 수 있습니다.

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

if (RefPtr autoFillButton = m_autoFillButton.get())
autoFillButton->removeOwner();
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>
+addEventListener("load", async () => {
+ input.addEventListener('click', (e) => {
+ input.type = 'button';
+ gc();
+ }, { once: true });
+
+ if (window.internals) {
+ let shadow = internals.shadowRoot(input);
+ let listButton = shadow.querySelector("div[useragentpart='-webkit-list-button']");
+ await UIHelper.activateElement(listButton);
+ }
+ debug("PASS if no crash.");
+ finishJSTest();
+});

Production code 변경 세 곳과 regression test 하나로 구성되어 있습니다. 이 세 가지 production 변경은 서로 중복되지 않으며, 동일한 취약점을 lifetime 상의 세 지점에서 각각 막아냅니다.

DataListButtonElement.h에서는 member 타입이 DataListButtonOwner& m_owner에서 WeakPtr<DataListButtonOwner> m_owner로 변경되었습니다. 이 변경이 성립하려면, 중첩된 DataListButtonOwner interface class가 AbstractRefCountedAndCanMakeWeakPtr<DataListButtonOwner>를 상속해야 합니다. 이는 abstract interface를 weak reference로 참조하면서도 다시 strong reference로 승격시킬 수 있게 해주는 WTF mixin입니다. 새로 추가된 public 한 줄짜리 함수 void removeOwner() { m_owner = nullptr; }는 owner가 명시적으로 연결을 끊을 수 있는 수단을 제공하며, 이를 위해 <wtf/AbstractRefCountedAndCanMakeWeakPtr.h>가 include되었습니다.

DataListButtonElement.cpp에서는 무조건 실행되던 m_owner.dataListButtonElementWasClicked()if (RefPtr owner = m_owner) owner->dataListButtonElementWasClicked();로 바뀌었습니다. 이 한 줄에는 두 가지 동작이 담겨 있습니다. 먼저 weak pointer의 null 여부를 확인하고, 아직 살아 있는 경우에는 strong reference로 승격시켜 virtual call이 끝날 때까지 유지합니다.

TextFieldInputType.cpp에서는 removeShadowSubtree()m_dataListDropdownIndicator를 null로 만들기 전에 dataListDropdownIndicator->removeOwner() 호출을 추가로 수행합니다. 이 코드는 기존에 있던 autoFillButton->removeOwner() 줄 바로 아래에 위치하며, 형태상 그와 동일한 구조를 갖습니다.

  removeShadowSubtree(), before:        after:
    autoFillButton->removeOwner()         autoFillButton->removeOwner()
    m_autoFillButton = nullptr            m_autoFillButton = nullptr
    ─── (no removeOwner) ───              dataListDropdownIndicator->removeOwner()
    m_dataListDropdownIndicator = null    m_dataListDropdownIndicator = nullptr
    m_container = nullptr                 m_container = nullptr

Layout test는 최소한의 재현 조건을 구성합니다. list attribute를 가진 text input, input.type = 'button'으로 재할당하고 gc()를 호출하는 일회성 click listener, 그리고 internals.shadowRoot()로 접근한 -webkit-list-button node를 UIHelper.activateElement()로 실제 활성화시키는 과정으로 이루어져 있습니다.

User-agent shadow DOM. WebKit은 host element에 내부 shadow tree를 붙이는 방식으로 복잡한 form control을 구현합니다. 이 node들은 다른 node와 마찬가지로 렌더링되고 이벤트를 받지만, 페이지 스크립트는 일반적인 DOM traversal로는 접근할 수 없습니다. 이 node들은 useragentpart attribute(여기서는 -webkit-list-button)로 태그되어 있으며, test harness는 internals.shadowRoot()를 통해 접근합니다.

InputType and type swapping. <input> element는 자신의 동작을 직접 구현하지 않습니다. 대신 type attribute에 따라 선택되는 polymorphic한 InputType 객체에 위임합니다. TextFieldInputType은 텍스트 계열 type들의 base class이며, DataListButtonElement::DataListButtonOwner interface를 구현하는 유일한 클래스입니다. type content attribute나 IDL attribute가 변경되면 HTMLInputElement::updateType()이 실행됩니다. 이 함수는 기존 type이 만든 shadow tree를 해체하는 teardown hook인 removeShadowSubtree()를 먼저 호출한 뒤, InputType 객체 자체를 완전히 교체합니다.

<input list> and the dropdown indicator. text input이 <datalist>를 참조하면, TextFieldInputType은 shadow subtree에 클릭 가능한 dropdown indicator를 추가합니다. 이 indicator가 바로 DataListButtonElement이며, DataListButtonElement(Document&, DataListButtonOwner& owner) : m_owner(owner)와 같이 owner에 대한 reference를 받아 생성됩니다. 이 indicator를 클릭하면 control이 owner에게 suggestion list를 표시하도록 요청하는 방식으로 동작합니다.

defaultEventHandler. 이는 event dispatcher가 각 node의 내장 동작을 구현하기 위해 호출하는 per-node hook입니다. 해당 이벤트에 대한 author listener들이 모두 실행된 이후에 실행됩니다.

Event dispatch re-entrancy. Author JavaScript listener는 native event dispatch 도중 synchronous하게 실행됩니다. Native code가 dispatch에 진입한 시점부터 default handler로 복귀하는 시점 사이에는, 임의의 script가 DOM과 engine 상태를 변경할 기회를 갖게 됩니다.

Smart pointers. RefPtr는 대상을 살아 있게 유지하는 strong reference-counted pointer입니다. WeakPtr는 소유권을 갖지 않으며, 대상 객체가 파괴되면 자동으로 null이 됩니다. AbstractRefCountedAndCanMakeWeakPtr<T>는 abstract interface class에 이 두 가지 성질을 동시에 부여하는 WTF mixin입니다.

TZone allocation. TextFieldInputTypeWTF_MAKE_TZONE_ALLOCATED_IMPL을 사용하여, instance들이 type별로 분리된 heap zone에 배치됩니다. 이런 zone에서 해제된 메모리는 공격자가 임의로 선택한 객체가 아니라 동일한 type의 allocation에 의해 우선적으로 재사용됩니다.

이 버그는 비대칭적인 back-pointer lifecycle에서 비롯됩니다. child→owner edge는 생성 시점에 맺어지지만 owner의 teardown 경로에서는 전혀 끊어지지 않아서, child가 자신이 가리키는 객체보다 더 오래 살아남게 됩니다.

  DataListButtonElement (event target)   TextFieldInputType (owner)
  ────────────────────────────────────   ──────────────────────────
  dispatch begins, node ref'd ────────►  alive
    author click listener runs
      input.type = 'button' ──────────►  updateType()
                                           removeShadowSubtree()
                                             m_dataListDropdownIndicator = null
                                             (back-pointer NOT cleared)  ◄── hole
                                           InputType replaced → ~TextFieldInputType()
  defaultEventHandler resumes            FREED
    m_owner.dataListButtonElementWasClicked()  ──► virtual call on freed memory

왼쪽 열이 전체 시퀀스 동안 살아남는 데는 분명한 이유가 있습니다. Event dispatch는 target node를 계속 참조하고 있으므로, DataListButtonElement는 dispatch 시작부터 default handler 종료까지 살아 있음이 보장됩니다. 바로 이 보장 때문에 버그가 도달 가능해집니다. Element 자신은 안전하게 살아 있지만, 그것이 가리키는 대상은 그렇지 않기 때문입니다. Invariant가 깨지는 지점은 오른쪽 열입니다. removeShadowSubtree()는 owner의 strong reference인 m_autoFillButton, m_dataListDropdownIndicator, m_container를 해제합니다. Autofill button에 대해서는 removeOwner()를 호출하지만, datalist indicator의 back-pointer에 대해서는 아무 조치도 하지 않습니다. Raw reference member에는 애초에 setter가 없었기 때문에, 코드베이스 어디에서도 이 연결을 끊어주지 않았습니다.

updateType()HTMLInputElement가 기존 TextFieldInputType을 소유하던 reference를 해제하면, 해당 객체는 파괴됩니다. 이는 단순한 bookkeeping 상의 no-op이 아니라 실제 destructor입니다. ~TextFieldInputType()closeSuggestions()를 실행하고 m_suggestionPicker를 detach하므로, 메모리는 실제로 반환됩니다. 이후 제어 흐름은 DataListButtonElement::defaultEventHandler로 되돌아가고, click branch에 도달해 m_owner를 통한 호출을 조건 없이 실행합니다.

이 호출은 pure-virtual dispatch에 해당합니다. dataListButtonElementWasClicked()는 interface에서 virtual void ... = 0으로 선언되어 있으므로, 이를 호출하려면 해제된 객체의 첫 번째 word에서 vtable pointer를 읽어와 그것을 통해 간접 호출해야 합니다. "dangling reference"에서 "회수된 메모리에서 읽어온 indirect call target"까지의 거리는 단 한 번의 load에 불과합니다.

세 가지 production 변경은 각각 독립적으로 배포된 test case를 막아냅니다. 단일 fix가 아니라 defense in depth로 볼 수 있는 이유입니다.

  1. removeShadowSubtree()removeOwner()는 detach 시점에 즉시 edge를 해제합니다. Test가 검증하는 경로입니다.
  2. WeakPtr는 owner가 파괴되는 시점에 member를 자동으로 null 처리하므로, removeShadowSubtree()를 거치지 않고 TextFieldInputType이 파괴되는 어떤 경로든 커버합니다.
  3. dataListButtonElementWasClicked()는 suggestion picker를 열며, 그 자체로 script를 실행할 수 있는 지점입니다. 따라서 이 함수 내부에서 re-entrancy가 발생할 가능성이 있습니다. RefPtr owner = m_owner는 호출이 진행되는 동안 owner를 살아 있는 상태로 고정하므로, callback 도중에 객체가 해제되는 상황을 막을 수 있습니다.

Impact 측면에서 보면, 이 버그는 전적으로 WebContent process 내부에 머물러 있으며 그 자체로는 sandbox 경계를 넘지 않습니다. Trigger를 위해서는 dropdown indicator의 실제 activation이 필요하므로 user gesture가 체인에 포함되지만, 페이지 상에서 안정적으로 재현 가능한 crash입니다. 공격자가 virtual dispatch 이전에 해제된 TextFieldInputType slot을 reclaim할 수 있다면, 이를 renderer 안에서의 controlled indirect call로 바꿀 가능성이 있습니다. Read/write primitive를 구축하는 전형적인 출발점에 해당합니다. 다만 이 확장은 이론적으로 가능한 수준이며 아직 검증되지 않았고, TZone zone 특성상 동일 타입의 reclaim으로 제약됩니다. Apple이 "예기치 않은 Safari crash"로 설명한 것은 관찰된 최소 impact를 반영한 것으로 보입니다.

UA shadow element가 자신을 소유한 InputType을 가리키는 raw back-pointer는 owner의 teardown 이후에도 살아남았습니다. 그 결과 input.type을 재할당하는 click listener 하나만으로, default handler의 pure-virtual call이 해제된 메모리에 대한 dereference로 바뀌게 됩니다.

안전한 idiom은 이미 같은 파일 안에, 버그가 있는 지점 바로 두 줄 위에 존재하고 있었습니다. removeShadowSubtree()는 이미 autoFillButton->removeOwner()를 호출하고 있었고, fix의 commit message 자체도 SpinButtonElement를 참고 패턴으로 지목하고 있습니다. removeOwner() teardown 관례는 이미 확립되어 같은 함수 안의 다른 member에 적용되어 있었지만, 나중에 datalist indicator가 추가되었을 때는 확장 적용되지 않았을 뿐입니다. 검증된 teardown idiom이 부분적으로만 적용된 지점은 버그를 찾기에 효율적인 사냥터입니다. Teardown 함수가 N개의 owned member를 null 처리하면서 unregister helper는 그중 M < N개에만 호출한다면, 남은 N-M개가 후보가 됩니다.