← All issues

[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

void Highlight::clearFromSetLike()
{
for (auto& highlightRange : std::exchange(m_highlightRanges, { }))
repaintRange(highlightRange->range());
- m_highlightRanges.clear();
+
+ // Exchange with an empty vector, as this function might reenter when m_highlightRanges' contents are released.
+ std::exchange(m_highlightRanges, { });
}

LayoutTests/highlight/highlight-crash-2.html

+ function main() {
+ let d = f.contentDocument;
+ let hr = f.contentWindow.CSS.highlights;
+ let h = new Highlight(d.createRange());
+ g.contentWindow.CSS.highlights.set("g", h);
+ f.remove();
+ hr.set("f", h);
+ hr = 0;
+ d = 0;
+ gc();
+ g.remove();
+ }

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.

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.

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.

🔒

Detailed vulnerability analysis & security impact assessment

Subscribe to read more

🔒

Pattern-based audit directions for variant discovery

Subscribe to read more