← 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. 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

- 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);

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.

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.

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.

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.