← All reports

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

HighWebCore HTML forms / UA shadow DOMUAF

CVE: CVE-2026-64783 · Safari 26.6 · Released July 27, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A use-after-free issue was addressed with improved memory management. 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. A raw back-pointer that outlives its target, dereferenced as a pure-virtual call — the shape that turns a form-control teardown into an indirect branch. Escalation past the crash needs same-type heap reclaim that the shipped test case does not attempt.

Complex form controls in WebKit are not single elements; each <input> delegates its behavior to a polymorphic implementation object and grows an internal shadow tree of helper nodes that the page can see rendered but cannot script directly. Those helper nodes hold back-pointers to the implementation object that built them, and the implementation object is swapped wholesale the moment the element's type changes. DataListButtonElement — the dropdown indicator injected into <input list=...> — held that back-pointer as a raw C++ reference bound once in its constructor, with no path anywhere in the class that could clear it.

The angle: A page that gets one click on a datalist dropdown arrow can destroy the input's implementation object from a listener and then have native code make a virtual call through the freed pointer.

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();
+});

Three production changes and one regression test, and the three production changes are not redundant with each other — they close the same hole at three different points on the lifetime.

In DataListButtonElement.h, the member type changes from DataListButtonOwner& m_owner to WeakPtr<DataListButtonOwner> m_owner. To make that legal, the nested DataListButtonOwner interface class now derives from AbstractRefCountedAndCanMakeWeakPtr<DataListButtonOwner>, the WTF mixin that lets an abstract interface be both weakly referenced and upgraded back to a strong reference. A new public one-liner void removeOwner() { m_owner = nullptr; } gives the owner a way to sever the edge explicitly, and <wtf/AbstractRefCountedAndCanMakeWeakPtr.h> is pulled in.

In DataListButtonElement.cpp, the unconditional m_owner.dataListButtonElementWasClicked() becomes if (RefPtr owner = m_owner) owner->dataListButtonElementWasClicked();. Two things happen in that one line: the weak pointer is null-checked, and — if it is still live — it is upgraded to a strong reference that is held across the virtual call.

In TextFieldInputType.cpp, removeShadowSubtree() gains a dataListDropdownIndicator->removeOwner() call before nulling m_dataListDropdownIndicator. It sits directly beneath the pre-existing autoFillButton->removeOwner() line and is textually identical in shape to it.

  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

The layout test builds the minimal reproducer: a text input with a list attribute, a one-shot click listener that reassigns input.type = 'button' and calls gc(), and UIHelper.activateElement() driving a real activation of the -webkit-list-button node reached through internals.shadowRoot().

User-agent shadow DOM. WebKit implements complex form controls by attaching an internal shadow tree to the host element. These nodes render and receive events like any other node, but page script cannot reach them by ordinary DOM traversal; they are tagged with useragentpart attributes (-webkit-list-button here) and test harnesses reach them via internals.shadowRoot().

InputType and type swapping. An <input> element does not implement its own behavior. It delegates to a polymorphic InputType object selected by its type attribute. TextFieldInputType is the base for text-like types and is the sole implementer of the DataListButtonElement::DataListButtonOwner interface. When the type content attribute or IDL attribute changes, HTMLInputElement::updateType() runs: it calls removeShadowSubtree() on the outgoing type — the teardown hook that dismantles the shadow tree that type built — and then replaces the InputType object entirely.

<input list> and the dropdown indicator. When a text input references a <datalist>, TextFieldInputType adds a clickable dropdown indicator into the shadow subtree. That indicator is a DataListButtonElement, constructed with a reference to its owner: DataListButtonElement(Document&, DataListButtonOwner& owner) : m_owner(owner). Clicking it is how the control asks its owner to display the suggestion list.

defaultEventHandler. This is the per-node hook the event dispatcher invokes to implement a node's built-in behavior for an event. It runs after the author's listeners for that event have already run.

Event dispatch re-entrancy. Author JavaScript listeners execute synchronously in the middle of native event dispatch. Between the moment native code enters dispatch and the moment it resumes in a default handler, arbitrary script has had the opportunity to mutate the DOM and engine state.

Smart pointers. RefPtr is a strong reference-counted pointer that keeps its target alive. WeakPtr is non-owning and is automatically nulled when the pointee is destroyed. AbstractRefCountedAndCanMakeWeakPtr<T> is the WTF mixin that gives an abstract interface class both properties at once.

TZone allocation. TextFieldInputType carries WTF_MAKE_TZONE_ALLOCATED_IMPL, placing instances in a type-segregated heap zone. Freed memory in such a zone is preferentially reused by allocations of the same type rather than by arbitrary attacker-chosen objects.

The bug is an asymmetric back-pointer lifecycle: the child→owner edge is established at construction and never severed on the owner's teardown path, so the child outlives the object it points at.

  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

The left column survives the whole sequence for a reason worth stating plainly: event dispatch retains the target node, so the DataListButtonElement is guaranteed alive from the start of dispatch to the end of the default handler. That guarantee is exactly what makes the bug reachable — the element is safely alive while the thing it points to is not. The right column is where the invariant breaks. removeShadowSubtree() drops the owner's strong references (m_autoFillButton, m_dataListDropdownIndicator, m_container) and calls removeOwner() on the autofill button, but does nothing about the datalist indicator's back-pointer. Nothing else in the codebase severed that edge, because the raw reference member had no setter at all.

Once updateType() releases the HTMLInputElement's owning reference to the old TextFieldInputType, the object is destroyed. This is a real destructor, not a bookkeeping no-op — ~TextFieldInputType() runs closeSuggestions() and detaches m_suggestionPicker, so the memory is genuinely returned. Control then unwinds back into DataListButtonElement::defaultEventHandler, which reaches the click branch and executes the call through m_owner unconditionally.

That call is a pure-virtual dispatch. dataListButtonElementWasClicked() is declared virtual void ... = 0 on the interface, so reaching it requires loading the vtable pointer from the freed object's first word and indirecting through it. The distance from "dangling reference" to "indirect call target read from reclaimed memory" is one load.

Each of the three production changes independently defeats the shipped test case, which is what makes this defense in depth rather than one fix:

  1. removeOwner() in removeShadowSubtree() clears the edge eagerly at detach time — the path the test exercises.
  2. WeakPtr nulls the member automatically at owner destruction, covering any path that destroys a TextFieldInputType without routing through removeShadowSubtree().
  3. RefPtr owner = m_owner pins the owner alive for the duration of the call, so re-entrancy triggered inside dataListButtonElementWasClicked() — which opens a suggestion picker and can itself run script — cannot free it mid-callback.

For impact: this lives entirely inside the WebContent process and crosses no sandbox boundary on its own. The trigger needs a real activation of the dropdown indicator, so a user gesture is in the chain, but the crash is reliably reproducible from a page. An attacker who reclaimed the freed TextFieldInputType slot before the virtual dispatch could turn this into a controlled indirect call in the renderer, the standard starting point for building read/write primitives — that escalation is plausible and unproven, and the TZone zone constrains it to same-type reclaim. Apple's "unexpected Safari crash" framing reflects the minimum observed impact.

A UA shadow element's raw back-pointer to its owning InputType survived the owner's teardown, so a click listener that reassigns input.type turns the default handler's pure-virtual call into a dereference of freed memory.

The safe idiom was already in the file — two lines above the bug. removeShadowSubtree() called autoFillButton->removeOwner(), and the fix's own commit message points at SpinButtonElement as the pattern being matched. The removeOwner() teardown convention was established, applied to a sibling member in the same function, and simply not extended when the datalist indicator was added later. Partial application of a known-good teardown idiom is a productive hunting ground: whenever a teardown function nulls N owned members but calls an unregister helper on only M < N of them, the remaining N−M are candidates.