[9] Highlight Re-entrancy UAF via Cross-Document Ownership Cycle
Severity: High | Component: WebCore CSS Custom Highlight API | e01d254
Rated High because the observable effect is an ASAN-detected use-after-free in Highlight::clearFromSetLike triggered by a cross-document ownership cycle, and escalation to a controlled UAF primitive via heap grooming — while non-trivial — is projected with confidence 0.92 given the reproducible trigger and the well-understood WebKit destruction-time exploitation pattern.
With the right disposing order, it is possible to cause reentrancy to Highlight::clearFromSetLike(). This is due to the ownership relationship between Document→HighlightRegistry→Highlight→HighlightRange→Range→Document.
Source/WebCore/Modules/highlight/Highlight.cpp
LayoutTests/highlight/highlight-crash-2.html
Re-entrant use-after-free caused by a cyclic ownership chain where member destruction triggers re-entry into the same method on the same object.
Patch Details
The fix replaces m_highlightRanges.clear() with std::exchange(m_highlightRanges, { }) at the end of Highlight::clearFromSetLike(). The for-loop already used std::exchange to move m_highlightRanges into a loop-scoped temporary before iterating. The fix ensures that after the loop (when the temporary's Ref<HighlightRange> elements are released and may trigger re-entrant document teardown), m_highlightRanges is atomically swapped to an empty vector rather than cleared in-place via .clear(). This makes any re-entrant call to clearFromSetLike() a safe no-op.
Background
The CSS Custom Highlight API (CSS.highlights) allows web content to register named Highlight objects, each containing a set of AbstractRanges. HighlightRegistry is per-Document and owns Ref<Highlight> references. A single Highlight object can be registered in multiple HighlightRegistry instances across different Documents/iframes — this is the cross-document registration that creates the ownership cycle.
Range objects hold a Ref<Document> to their owning Document. When a Document is torn down via Document::commonTeardown(), it clears its HighlightRegistry, which calls clearFromSetLike() on each registered Highlight. std::exchange(member, {}) atomically moves the member's contents to a temporary and replaces the member with a default-constructed value — this is a common WebKit pattern for safe re-entrancy, ensuring that recursive calls see empty state.
The ownership chain forms a cycle: Document → HighlightRegistry → Highlight → HighlightRange → Range → Document. When destruction of one link releases the last reference to a Document at the other end of the chain, the entire cycle can unwind re-entrantly.
Analysis
The root cause is a re-entrancy hazard in a cyclic ownership chain. Before the fix, clearFromSetLike() used std::exchange to move m_highlightRanges into a loop-scoped temporary, iterated over it calling repaintRange(), and then called m_highlightRanges.clear(). The problem arises when the loop-scoped temporary is destroyed (releasing Ref<HighlightRange> elements): the destruction chain can release the last reference to a Range's Document, triggering Document::commonTeardown() → HighlightRegistry::clear() → Highlight::clearFromSetLike() on the same Highlight object (if that Highlight was registered in multiple Documents' registries).
The test case demonstrates this precisely: a Highlight holding a Range from iframe f's Document is registered in both f's and g's CSS.highlights. Removing f detaches the Range's Document. Removing g triggers g's Document::commonTeardown(), which clears g's HighlightRegistry, calling clearFromSetLike() on the shared Highlight. During element destruction in the for-loop, the Range's Document (from f) loses its last reference and is torn down, which clears f's HighlightRegistry, re-entering clearFromSetLike() on the same Highlight.
The .clear() call in the original code destroys vector elements in-place while the vector is a member of this. If re-entrancy during element destruction causes the Highlight itself to lose its last reference (when a HighlightRegistry drops its Ref during clear()), subsequent element destruction would access freed memory.