[WebCore] Use-after-free in DataListButtonElement::defaultEventHandler
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
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
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 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()로 실제 활성화시키는 과정으로 이루어져 있습니다.
Background
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. TextFieldInputType은 WTF_MAKE_TZONE_ALLOCATED_IMPL을 사용하여, instance들이 type별로 분리된 heap zone에 배치됩니다. 이런 zone에서 해제된 메모리는 공격자가 임의로 선택한 객체가 아니라 동일한 type의 allocation에 의해 우선적으로 재사용됩니다.
Analysis
이 버그는 비대칭적인 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로 볼 수 있는 이유입니다.
removeShadowSubtree()의removeOwner()는 detach 시점에 즉시 edge를 해제합니다. Test가 검증하는 경로입니다.WeakPtr는 owner가 파괴되는 시점에 member를 자동으로 null 처리하므로,removeShadowSubtree()를 거치지 않고TextFieldInputType이 파괴되는 어떤 경로든 커버합니다.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로 바뀌게 됩니다.
Insight
안전한 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개가 후보가 됩니다.
Audit directions
-
Owning reference는 해제하지만 상호 back-pointer는 부분적으로만 끊어내는 teardown 함수. 이런 함수의 특징은 N개의 member를 null 처리하면서 unregister/detach helper는 그보다 적은 개수에만 호출한다는 점입니다. Narrow:
TextFieldInputType::removeShadowSubtree()와 다른InputTypesubclass들의 유사한 teardown 코드를 다시 살펴봐야 합니다.TextControlInnerElements.cpp,SearchFieldResultsButtonElement,SpinButtonElement,AutoFillButtonElement도 대상입니다. Owner argument로 생성되는 모든 UA shadow element에 대해, owner를 파괴하는 모든 경로에서removeOwner()에 준하는 처리가 실행되는지 확인해야 합니다. Wider: 동일한 비대칭 구조는create(Document&, SomeOwner&)형태의 factory가 owner를 저장하는 모든 WebCore 조합에서 나타날 수 있습니다. Owner reference를 받는 생성자를 검색하고, owner의 destructor나 detach hook이 이를 해제하는지 확인해야 합니다.RenderThemedelegate, media control shadow element, form validation bubble owner 등이 같은 형태를 공유합니다. Widest: 이는 "생성 시점에 맺어진 상호 reference가 한쪽에서만 해제되는" 일반적인 invariant이며, parent/child back-edge를 가진 모든 object graph에서 성립합니다. Blink의 shadow elementowner_field, Qt의 parent/child observer, 양방향 링크를 가진 ORM이나 UI framework 모두 해당합니다. 각 단계에서 확인할 tell은 다음과 같습니다. Narrow 단계에서는 생성자에서 초기화된 뒤 클래스 내 다른 곳에서 전혀 재할당되지 않는 rawT&또는T*member를 찾아야 합니다. Wider 단계에서는 owner reference를 받는 factory signature를 가졌지만 해제용 setter가 아예 없는 클래스를 찾아야 합니다. Widest 단계에서는, 링크가 정확히 한 곳에서만 생성되고 이후 다시 쓰이지 않는다면 어느 쪽이 더 오래 살아남는지를 물어야 합니다. -
네이티브 코드가 re-entrancy 경계를 지난 뒤 멤버 포인터를 재검증 없이 참조하는 패턴. Narrow:
Source/WebCore/html/shadow/와Source/WebCore/html/에서, 로컬Ref/RefPtr가 아니라 멤버(m_owner,m_element,m_input)를 직접 역참조하는defaultEventHandler본문을 검색하고, 같은 dispatch 안에서 더 앞선 listener가 그 멤버가 가리키는 대상을 파괴할 수 있는지 확인합니다.input.type재할당,element.remove(), form 재결합이 비용이 낮은 트리거입니다. Wider: 이 클래스는 script 실행이 끝난 뒤 호출될 수 있는 모든 WebCore entry point를 포함합니다. style resolution callback,didFinishInsertingNode, form-control state 복원,ResizeObserver/IntersectionObserver의 delivery loop 등이 해당됩니다. Widest: 재사용 가능한 invariant는 "네이티브 코드가 사용자 스크립트로 제어를 넘긴 이후에는, yield 이전에 얻어둔 사용자-mutable state에 대한 포인터를 신뢰할 수 없다"는 것입니다. 이는 V8/Blink binding, Lua/C host, interpreter로 콜백하는 Python C extension 등 임베디드 스크립팅 host 전반에 적용됩니다. Match tell: narrow 단계에서는 이벤트를 dispatch하거나 style를 resolve할 수 있는 호출 이후에 텍스트상으로 멤버 역참조가 등장하는지를 봅니다. widest 단계에서는 "이 스택 프레임에서 가장 오래 살아남는 포인터가 무엇이고, callback이 그 포인터가 가리키는 객체에 도달할 수 있는가"를 묻습니다. -
HTMLInputElement::updateType()를, 하나의 버그를 유발하는 트리거가 아니라 일반적인 destruction primitive로 보는 관점. 이 패턴 클래스는 script에서 도달 가능한 단일 attribute 할당이 다른 코드가 포인터를 들고 있는 크고 polymorphic한 객체를 동기적으로 파괴하고 교체하는 형태를 가리킵니다. Narrow: 각InputTypesubclass가 생성하는 멤버와 shadow node를 모두 나열하고,InputType외부의 무언가가 swap 이후에도 살아남는 포인터를 들고 있는지 하나씩 확인합니다.needsShadowSubtree()/createShadowSubtree()/removeShadowSubtree()triple부터 시작하는 것이 좋습니다. Wider: "live observer 아래에서 구현 객체를 교체한다"는 동일한 형태를 가진 다른 WebCore 연산으로는, element를 새 form owner에 재결합하는 동작,document.open()이 document의 state를 교체하는 동작, custom element upgrade가 element의 behavior를 교체하는 동작이 있습니다. 각각에 대해 "여전히 이 객체를 가리키는 것은 무엇인가"를 동일하게 조사할 필요가 있습니다. Widest: 원칙은 "구현 전략 객체를 교체하는 모든 state transition은 그 객체로 들어오는 edge를 전부 나열해야 한다"는 것이며, 이는 live reconfiguration을 지원하는 모든 strategy/PIMPL/plugin architecture에 적용됩니다. Match tell: polymorphic한 구현체를 담고 있는std::unique_ptr/Ref를 재할당하는 setter가 있고, 이전 구현체가this를 다른 곳에 넘겨준 적이 있는 경우입니다. -
이 버그 클래스에 대한 severity triage가 전제하는 mitigation 효과를, TZone type-segregated allocation이 실제로 만들어내고 있는지 여부. 이 패턴 클래스는
WTF_MAKE_TZONE_ALLOCATED_IMPL을 사용하는 클래스에서 발생하는 UAF이며, 해제된 타입 자체가 web content에서 저렴하게 대량 할당 가능한 경우를 가리킵니다. Narrow:TextFieldInputType에 한정해, 텍스트류<input>element를 대량 생성하는 방식으로 단일 event dispatch 안에서 방금 해제된 instance를 안정적으로 회수할 수 있는지 측정합니다. Wider: page script가 대량으로 instantiate할 수 있는 다른 TZone-allocated WebCore 객체들에 대해서도 동일한 질문을 던집니다.InputTypesubclass,RenderObjectfamily, shadow element class 등이 해당되며, per-type zone은 그 타입을 할당하는 비용이 높거나 rate-limit이 걸려 있는 만큼만 공격자를 제약하기 때문입니다. Widest: invariant는 "type-segregated heap은 공격자가 정확히 그 타입을 할당하기 어려운 정도에 비례해서만 exploitability를 낮춘다"는 것이며, 이는 PartitionAlloc의 type bucket, 다른 엔진의 isolated heap, slab allocator 일반에도 동일하게 적용됩니다. Match tell: heap partitioning을 mitigation 근거로 인용하면서도 공격자가 초당 몇 개의 해당 타입 instance를 생성할 수 있는지는 명시하지 않는 UAF 분석 — 그 공백이 측정해야 할 지점입니다. 이 rung은 코드 읽기만으로는 부족하고, 실제 runtime 실험이 필요합니다.