[5] Use-after-free in DataListButtonElement::defaultEventHandler
One click, a two-line listener, and a virtual call into freed heap.
High. A single click plus a two-line listener produces a virtual call through freed heap in the renderer. What holds it back from a clean control-flow primitive is TZone segregation — reuse is biased toward same-type allocations — so the reliable outcome is a stale-object confusion rather than an arbitrary vtable.
Form controls in WebKit build an internal user-agent shadow DOM, and the type-specific behaviour of an <input> lives in a refcounted implementation object that the element delegates to. For <input type=text list=...> that object also owns the little dropdown-indicator button rendered next to the field, and the button holds a back-pointer to it so a click can open the suggestion picker. The lifetimes are separate: the button is a Node kept alive by references taken during event dispatch, while the implementation object is released the moment script changes input.type.
The angle: a page that lures a single click on a datalist dropdown button can free the owning input-type object from the click listener and have the browser then make a virtual call through the freed memory.
DataListButtonElement stores its owner as a raw DataListButtonOwner& m_owner. The only DataListButtonOwner is TextFieldInputType. When the type of the owning input element is changed, HTMLInputElement::updateType() calls removeShadowSubtree(). This nulls out m_dataListDropdownIndicator but does not clear the owner member in DataListButtonElement. Changing the type inside a click listener results in the TextFieldInputType being freed while event dispatch is in progress. Eventually DataListButtonElement::defaultEventHandler() is called, calling m_owner.dataListButtonElementWasClicked() after m_owner was already freed. The fix stores the owner as a WeakPtr and clears it in removeShadowSubtree(), matching the implementation of 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
Three production changes plus a regression test. In DataListButtonElement.h, the back-pointer member changes from DataListButtonOwner& m_owner to WeakPtr<DataListButtonOwner> m_owner, the nested DataListButtonOwner interface now derives from AbstractRefCountedAndCanMakeWeakPtr<DataListButtonOwner> so the weak reference can be upgraded to a strong one, and a new void removeOwner() { m_owner = nullptr; } accessor is added.
In DataListButtonElement.cpp, defaultEventHandler's unconditional m_owner.dataListButtonElementWasClicked() becomes if (RefPtr owner = m_owner) owner->dataListButtonElementWasClicked(); — a null-check plus a strong ref held across the virtual call.
In TextFieldInputType.cpp, removeShadowSubtree() now calls dataListDropdownIndicator->removeOwner() before clearing m_dataListDropdownIndicator, mirroring the existing autoFillButton->removeOwner() line directly above it. The new layout test activates the -webkit-list-button shadow element while a click listener on the input sets input.type = 'button' and calls gc().
Raw back-pointer to an owner, never invalidated at teardown, dereferenced after a script re-entrancy window that can destroy the owner.
Background
User-agent shadow subtree.
Form controls build an internal shadow DOM owned by the control's InputType — for <input type=text list=...> this includes a DataListButtonElement div with useragentpart='-webkit-list-button' that opens the suggestion list.
InputType / TextFieldInputType.
HTMLInputElement delegates type-specific behavior to a refcounted InputType object; TextFieldInputType is the subclass backing text-like inputs and is the sole implementer of the DataListButtonOwner interface. The delegation split exists so that changing input.type swaps one implementation object for another rather than mutating a monolithic element class.
HTMLInputElement::updateType().
Assigning to input.type from script swaps the InputType object, calling removeShadowSubtree() on the outgoing one and releasing it.
Event dispatch ordering.
DOM dispatch runs author listeners for capture/target/bubble phases first; a node's defaultEventHandler() runs afterwards as part of default (user-agent) handling of the same event.
Re-entrancy. A point where native code hands control to JavaScript, which may synchronously mutate C++ object state before returning.
WeakPtr and AbstractRefCountedAndCanMakeWeakPtr.
WeakPtr is a non-owning WebKit smart pointer that reads back as null once the referent is destroyed; AbstractRefCountedAndCanMakeWeakPtr is the base that lets a weak reference to an abstract interface be upgraded to a strong RefPtr.
TZone allocation.
WTF_MAKE_TZONE_ALLOCATED places instances of a class in a type-segregated heap zone, so freed storage is preferentially reused by objects of the same type. The point of the segregation is to deny the attacker the free choice of what refills a freed slot.
Analysis
The button held its owner as a raw C++ reference bound in the constructor initializer list. The lifetimes of the two objects are independent: the shadow div is a Node kept alive by references taken during event dispatch, while the TextFieldInputType is released when HTMLInputElement::updateType() swaps in a different InputType. Nothing in the teardown path severed the button's back-pointer — removeShadowSubtree() only nulled the owner's forward pointer m_dataListDropdownIndicator, so the raw reference in the surviving shadow element was left 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
The failure window is exactly the span between the author listener returning and default handling reaching the button: author JS always runs first on the same event, so script has a guaranteed opportunity to destroy the owner before the native handler uses it. The fix closes this on both ends — the owner pointer is now weak so it reads as null after destruction, it is proactively cleared during shadow-subtree teardown even before destruction, and the call site upgrades the weak reference to a RefPtr so the owner is pinned for the duration of dataListButtonElementWasClicked(), which itself can re-enter script via suggestion display.
Reachability is from web content but requires a genuine click on the datalist dropdown affordance: the vulnerable branch is gated on isAnyClick(*mouseEvent), and the button lives in a closed user-agent shadow tree, so the test needs internals.shadowRoot() plus UIHelper.activateElement to target it directly. Ordinary web content would lure the user into clicking the list button of an <input type=text list=...> — a single click on a visually plausible control. Following the regression test: (1) the page has <input id=input type=text list=list>, so TextFieldInputType builds the shadow subtree containing the DataListButtonElement, whose constructor stores m_owner(owner) as a raw reference; (2) a click listener is registered on the input; (3) the user clicks the list button, dispatch begins with the button as target and bubbles to the input; (4) the listener sets input.type = 'button', so updateType() runs synchronously — removeShadowSubtree() nulls m_dataListDropdownIndicator without touching the button's back-pointer and the outgoing TextFieldInputType reference is dropped; (5) gc() in the listener increases the chance the storage is actually reclaimed rather than merely logically dead; (6) dispatch unwinds and reaches defaultEventHandler(), which takes the isAnyClick branch and performs a virtual call through the now-dangling m_owner.
Escalation past the crash is conditional. If the freed TextFieldInputType slot is reclaimed before step 6 by a same-type allocation — the class carries WTF_MAKE_TZONE_ALLOCATED_IMPL(TextFieldInputType), so reuse is biased toward other TextFieldInputType instances the page can mass-create by inserting text inputs from the same listener — the virtual dispatch would land on a valid vtable but a stale or attacker-chosen object, and the subsequent dataListButtonElementWasClicked() work (suggestion display against m_suggestionPicker and the associated element()) could operate on a mismatched control. If instead the TZone page were recycled to unrelated content, the vtable load could yield attacker-influenced control flow, but TZone segregation makes that materially harder than a general-purpose-heap UAF. Absent verified heap reuse, the reliable observed effect is a virtual call on freed memory — a controlled renderer crash.
This vulnerability weakens memory safety inside the WebContent process. The security model assumption at stake is that a user-agent shadow element never outlives the validity of its back-pointer to the owning InputType — an invariant that script can break simply by mutating input.type from an event listener, since author JS runs before default event handling on the same event. Before the fix, an attacker-controlled page could cause a virtual call through freed heap memory, which under favorable heap-reuse conditions could give a control-flow or state-confusion primitive in the renderer; at minimum it is an attacker-triggerable renderer crash. Any resulting compromise stays inside the WebContent sandbox and would still require a separate escape.
Insight
The fix explicitly matches SpinButtonElement, and the diff shows the sibling m_autoFillButton->removeOwner() call sitting one line above the newly added dataListDropdownIndicator->removeOwner() in the same function. That is the tell: the safe pattern already existed for two of the three UA shadow children that hold back-pointers to TextFieldInputType, and the datalist button was simply the one that never got converted. Whenever a teardown routine clears a list of owned children and only some of them get a reciprocal back-pointer clear, the odd ones out are prime UAF candidates. The second half of the fix is worth noting independently: upgrading WeakPtr to RefPtr at the call site is not just a null-check — it pins the owner across dataListButtonElementWasClicked(), which itself opens the suggestion picker and can re-enter script.
Audit directions
-
Asymmetric teardown — an owner nulls its forward pointers to child objects but never invalidates the children's back-pointers, so a child that outlives the owner keeps a stale reference. Narrow: audit the rest of
TextFieldInputType::removeShadowSubtree()and the equivalent teardown in otherInputTypesubclasses (SearchInputType,NumberInputType,ColorInputType,FileInputType,RangeInputType) — the match tell is a line of the formm_someChild = nullptr;with no precedingsomeChild->removeOwner()/someChild->clearOwner()call, when the child's header declares an owner member. Wider: the same shape appears anywhere a WebCore object hands*thisto a child it constructs — grepSource/WebCorefor constructors takingOwner&orClient&and storing them asT& m_owner/T* m_ownerrather thanWeakPtr/CheckedPtr; the tell in search results is a reference-typed member initialized in the ctor init list with no assignment operator anywhere else, which means it can never be cleared. Widest: the general parent-child back-pointer with unidirectional teardown class — every back-pointer must have exactly one code path that invalidates it, and that path must run on every owner-destruction route. It applies to Chromium'sbase::WeakPtrobserver wiring, Qt parent/child widget graphs, and any Rust code mixingRc/Weakwhere theWeakside is emulated with a raw pointer for performance. Match tell in any codebase: a destructor or teardown method that clears N owned handles but calls back into fewer than N of them. In code review, a member declared asT&inside aNodesubclass is worth a comment explaining what guarantees the referent outlives the node. -
Audit user-agent shadow elements whose
defaultEventHandlercalls back into the form-control machinery, since author listeners always run before default handling on the same event. Narrow: grepSource/WebCore/html/shadow/fordefaultEventHandlerimplementations and check each for a member dereference that is not re-validated — start withTextControlInnerElements.cpp(SearchFieldResultsButtonElement,SearchFieldCancelButtonElement,SpinButtonElement) andAutoFillButtonElement; the tell is a call on a member pointer/reference to a non-Nodecollaborator with no intervening null check. Wider: the same class covers any WebCore code that caches native state, dispatches an event or otherwise re-enters script, then uses the cached state — look atMouseEventTypes/EventHandlerdefault-handling paths,HTMLFormElementsubmission, andHTMLMediaElementcontrol-panel shadow handlers. Widest: no pointer captured before a re-entrancy boundary may be dereferenced after it without re-validation or a strong ref taken across the window — the audit question to carry into any engine (Blink'sEventTarget::FireEventListeners, Gecko's event handling, or any embedder plugin API that calls user code mid-operation) is "what native object could the user callback have destroyed?" -
Investigate whether the
input.typemutation path frees other objects that are live on the stack or reachable from a mid-flight operation, not just shadow children. Narrow: traceHTMLInputElement::updateType()and enumerate everything the outgoingInputTypeowns or is pointed to by — suggestion pickers,m_suggestionPicker, renderer/RenderThemeassociations, and anyDataListSuggestionsClientregistrations — and check each for a clear-on-teardown counterpart; the tell is any member of the outgoingInputTypethat was handed to a longer-lived collection (a client list, an event-loop task, a chrome-client callback) without a matching removal. Wider: the same class covers every WebCore API where a script-visible attribute swap replaces a whole implementation object rather than mutating it —HTMLMediaElementsource/type changes,HTMLCanvasElementcontext replacement, and custom-element upgrade/attributeChangedCallbackpaths; the shape to look for in code search is an assignment of the formm_impl = createSomething(...)inside a method reachable from an attribute setter. Widest: replacing a polymorphic implementation object from a script-reachable setter must be treated as a destruction event for everything that pointed at the old implementation — this transfers to any plugin/strategy-swap architecture, state machines that hot-swap handler objects, and DI containers rebinding a singleton at runtime. -
Verify that the strong-ref-across-the-call idiom used here is applied consistently wherever a weak back-pointer is dereferenced into a method that can re-enter script. Narrow: grep WebCore for
if (auto owner = m_owner)/if (RefPtr x = m_something)patterns in shadow-element event handlers and compare against sites that instead dom_owner->foo()on a raw-dereferenced weak pointer — the tell ism_weakMember->method()orm_weakMember.get()->method()with no local strong ref, wheremethod()transitively dispatches events or resolves style. Wider: the same class applies toCheckedPtr/WeakRefdereferences inSource/WebCore/pageandSource/WebCore/domobserver-delivery loops, where a null check alone is insufficient because the referent can die during the call. Widest: a null-check on a weak reference proves liveness only at the instant of the check; any call that can re-enter user code needs ownership held for the call's duration — the same distinction between upgrade-and-hold and check-and-use shows up in Rust'sWeak::upgrade(which returns an owningArcprecisely for this reason), C++std::weak_ptr::lock, and Objective-C__weakreads assigned into__stronglocals.