CVE-2024-54534
Overview
Background
- HashCountedSet
- A WTF set that stores each element once along with an integer count of how many times it was added, here used to track how many handlers a Node has registered.
- WeakHashCountedSet / WeakPtr
- A counted set whose keys are weak references that automatically become null when the referenced object is destroyed, preventing stale-pointer dereference.
- EventTargetSet
- The Document-level structure (m_touchEventTargets, m_wheelEventTargets) recording every Node that currently has a touch or wheel event handler.
- isEmptyIgnoringNullReferences / computeSize
- Weak-container accessors that account for entries whose weak reference has already been nulled, so callers see only live members rather than a raw slot count.
- Use-after-free
- A memory-safety bug where a pointer is dereferenced after the object it referenced has been freed, allowing reads/writes of reallocated attacker-controlled memory.
Root Cause Analysis
The patch replaces the type of EventTargetSet in Document.h from HashCountedSet<Node*> (a counted set of raw, non-owning Node pointers) with WeakHashCountedSet<Node, WeakPtrImplWithEventTargetData> (a counted set of weak references). Document keeps two of these sets, m_touchEventTargets and m_wheelEventTargets, which record every Node in the document that has registered a touch or wheel event handler. The invariant these sets must uphold is that every entry corresponds to a live Node: the set holds only non-owning references, so an entry must be removed before or exactly when the referenced Node is destroyed. With raw Node* keys that invariant was enforced only by explicit bookkeeping calls (didRemoveEventTargetNode / removeHandlerFromSet), and any path where a handler-bearing Node was torn down without correctly pruning these sets left a dangling raw pointer. Subsequent operations that dereference the keys — most clearly Document::absoluteRegionForEventTargets, which before the patch did RefPtr node = keyValuePair.key and called absoluteEventRegionForNode(*node), and the iteration loops in wheelEventHandlerCount/touchEventHandlerCount — would then read or ref-count freed memory, a use-after-free.
The fix makes the container itself hold weak references: a destroyed Node’s slot is automatically nulled, so stale entries can never be dereferenced as live objects. Consequently the API surface changes throughout Document.cpp: add/remove/removeAll/contains now take Node& instead of Node*, size() (which counted possibly-dead slots) is replaced by computeSize() and isEmpty() by isEmptyIgnoringNullReferences() so callers ask about live entries only, and absoluteRegionForEventTargets iterates by value taking a Ref node = keyValuePair.key directly (the weak set never yields a dead key). A new removeAll(const ValueType&)/removeAll(iterator) pair was added to WeakHashCountedSet because that method did not previously exist on the weak container.
The fix restores the live-reference invariant structurally rather than relying on every teardown path to remember to prune, which is why it is fundamentally a memory-safety (UAF) fix despite the LogicError label.
Attack Path
- Register handlers From attacker-controlled web content, add touch and/or wheel event listeners to one or more DOM nodes so those nodes are inserted into Document::m_touchEventTargets / m_wheelEventTargets.
- Trigger a teardown path that skips pruning Manipulate the DOM (node removal, frame/document detachment, or a reentrant teardown during willBeRemovedFromFrame) so a handler-bearing Node is destroyed without its raw-pointer entry being correctly removed from the counted set, leaving a dangling Node* key.
- Reoccupy the freed slot Use standard heap grooming (allocate objects of the same size class as the freed Node) so the dangling pointer now points at attacker-influenced memory.
- Force a dereference of the stale key Cause the document to walk the target set — e.g. a hit-test / scrolling-region computation calling absoluteRegionForEventTargets, or wheelEventHandlerCount()/touchEventHandlerCount(), or hasTouchEventHandlers() — which reads and, in the pre-patch absoluteRegionForEventTargets, ref-counts the freed Node as if live.
- Escalate Convert the resulting use-after-free read/write of a Node object into a stronger primitive (type-confused vtable/refcount manipulation) toward code execution in the WebContent process; this escalation is standard exploitation background, not shown by the diff.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
EventTargetSet (type alias)Source/WebCore/dom/Document.h |
modified | Changed from HashCountedSet<Node*> to WeakHashCountedSet<Node, WeakPtrImplWithEventTargetData>, the core fix converting non-owning raw pointers to auto-nulling weak references. |
Document::hasTouchEventHandlers / touchEventTargetsContain / hasWheelEventHandlersSource/WebCore/dom/Document.h |
modified | Use computeSize() and contains(node) on the weak set instead of size()/contains(&node), so liveness queries count only live entries. |
Document::willBeRemovedFromFrameSource/WebCore/dom/Document.cpp |
modified | size() checks replaced with computeSize() for the weak container when deciding whether to notify the parent document. |
Document::wheelEventHandlersChangedSource/WebCore/dom/Document.cpp |
modified | Uses isEmptyIgnoringNullReferences() instead of isEmpty() to ignore already-dead weak entries. |
Document::didAddWheelEventHandler / didAddTouchEventHandlerSource/WebCore/dom/Document.cpp |
modified | add(node) by reference instead of add(&node) by pointer, matching the weak-set API. |
removeHandlerFromSetSource/WebCore/dom/Document.cpp |
modified | remove(node)/removeAll(node) by reference instead of by pointer. |
Document::wheelEventHandlerCount / touchEventHandlerCountSource/WebCore/dom/Document.cpp |
modified | Iterate by value (auto handler) over the weak set; the weak container yields only live key/value pairs. |
Document::didRemoveEventTargetNodeSource/WebCore/dom/Document.cpp |
modified | removeAll(handler) by reference and isEmptyIgnoringNullReferences() checks before propagating to the parent document. |
Document::absoluteRegionForEventTargetsSource/WebCore/dom/Document.cpp |
modified | Iterates by value and takes Ref node = keyValuePair.key directly instead of RefPtr node = keyValuePair.key with a null check; the weak set guarantees a live key so the freed-pointer dereference path is eliminated. |
WeakHashCountedSet::removeAll(const ValueType&) / removeAll(iterator)Source/WTF/wtf/WeakHashCountedSet.h |
added | New method (with declaration and inline definition) removing a value regardless of count, needed so the weak container can serve the same removeAll usage the raw HashCountedSet provided. |
Audit Directions
- Other raw-pointer counted/hash sets of DOM nodes in DocumentGrep Document.h/.cpp and related DOM classes for HashCountedSet<Node*>, HashSet<Node*>, Vector<Node*> and similar non-owning containers keyed on Node*/Element*/EventTarget*; each is a candidate for the same dangling-key UAF and should either be weak or provably pruned on teardown.
- size()/isEmpty() used as liveness on weak or soon-to-be-weak containersSearch for callers using ->size() or ->isEmpty() on event-target and observer sets where computeSize()/isEmptyIgnoringNullReferences() would be semantically required; a raw size() over a container with dead slots signals stale-entry logic bugs.
- Iterators taking a reference to a key then dereferencingLook for loops of the form RefPtr node = pair.key; … node or auto& over node containers without a liveness guarantee (grep absoluteEventRegionForNode-style callers and other absoluteForEventTargets helpers), which is the exact pattern that dereferenced the freed key here.
- removeAll / prune-on-destroy obligations across WebCore observer registriesAudit registries where node destruction must call a manual removeAll/unregister (touch, wheel, IntersectionObserver, mutation, resize observers); enumerate teardown paths (detach, willBeRemovedFromFrame, reentrant document destruction) and confirm every one prunes, or migrate the container to a weak type.