b32bca6755 [CSS JIT] :has() argument cache should iterate cached selector list to avoid stale pointers
Triage note: Stale-pointer/UAF fix in CSS :has() JIT cache (regression test is a JIT crash).
Contents
The bug at a glance
This is a genuine use-after-free reachable from untrusted CSS parsed at runtime, exercising the CSS :has() JIT compilation path, which historically has been a rich source of WebKit renderer exploits. The regression test crashes deterministically after repeated document reloads, showing the dangling pointers are baked into cached machine code that outlives the originating document’s selector data. Because the stale pointers live inside JIT-compiled selector-matching code driven by attacker-controlled selectors, the primitive has strong exploitation potential, warranting the high rating even though the committed artifact only demonstrates a crash.
The content-keyed cache compiledHasArgumentSelectorsMap stores a deep copy of each CSSSelectorList as its key, but the matching loop kept iterating the caller’s original selector list while feeding CompiledSelector slots that the JIT associates with the cached copy. The compiled code bakes raw pointers to selector data, and those pointers must point into the long-lived cached copy, not into the transient originating list that a reload can destroy. The fix simply switches the iteration to the cached key’s selector list.
Root cause
SelectorChecker::matchHasPseudoClass compiles the argument selectors of a :has() pseudo-class into machine code via the CSS selector JIT and memoizes the result in a global, content-keyed cache: compiledHasArgumentSelectorsMap(). The cache is keyed by the CSSSelectorList itself, and the map’s key is a deep copy of the list — an independent, cache-owned CSSSelectorList that persists for the lifetime of the cache entry (subject only to the random eviction when map.size() reaches maximumCompiledHasArgumentSelectorsSize). The stored value is a FixedVector<CompiledSelector> sized to selectorList.size(), each element eventually holding JIT-compiled code for one argument selector.
The bug is a pointer-provenance mismatch. Before the patch the code obtained the value with map.ensure(selectorList, …).iterator->value and then iterated for (auto& hasSelector : selectorList) — i.e., over the caller’s original list — while compiling each hasSelector into compiledSelectors[argIndex]. The CSS selector JIT, when it compiles a selector, bakes raw pointers to the CSSSelector nodes (and associated data such as :lang() language ranges, attribute names, etc.) directly into the emitted code. Those pointers therefore referenced the caller’s original selector list, not the cache-owned deep copy. The cached CompiledSelector, however, outlives the call: on a subsequent match with an equal selector list, the cache hit returns compiled code whose baked-in pointers still point at the original list from the first compilation.
When the originating document (or the stylesheet / CSSSelectorList that provided the original selectors) is torn down — for example across the repeated document.navigation.reload() the regression test performs — the memory backing those original CSSSelector nodes is freed, but the cached compiled code (and its baked pointers) survives in the process-global map. The next time the equal selector list is matched, the JIT code dereferences freed selector data: a classic dangling-pointer use-after-free. The test’s selector deliberately embeds :lang(en) and :has(…) so that the :has() argument JIT path with language-range data is exercised, maximizing the chance the freed data is meaningful.
The fix rebinds the compiled code’s provenance to the durable copy. After map.ensure(…) the patch destructures the map entry as auto& [hashKey, compiledSelectors] = *result.iterator;, obtains auto& cachedSelectorList = hashKey.key(); — the deep copy the cache owns — and iterates that (for (auto& hasSelector : cachedSelectorList)). Now every pointer the JIT bakes points into the cache-owned CSSSelectorList, whose lifetime matches the compiled code exactly, so eviction of the entry frees both together and no dangling pointer can be dereferenced.
Key code
SelectorChecker::matchHasPseudoClass — iterate the cache-owned copy so JIT pointers stay valid
auto result = map.ensure(selectorList, [&] {
return FixedVector<CompiledSelector>(selectorList.size());
});
auto& [hashKey, compiledSelectors] = *result.iterator;
// Iterate the cached copy: the JIT bakes pointers to selector data into compiled
// code, and the cache outlives the originating CSSSelectorList.
auto& cachedSelectorList = hashKey.key();
unsigned argIndex = 0;
for (auto& hasSelector : cachedSelectorList) {
if (matchHasArgumentSelector(checkingContext, element, hasSelector, &compiledSelectors[argIndex++], matchingHost))
return true;
}
Patch walkthrough
Source/WebCore/css/SelectorChecker.cpp— In SelectorChecker::matchHasPseudoClass, the map.ensure() result is now captured whole and destructured into [hashKey, compiledSelectors]. A new reference cachedSelectorList = hashKey.key() names the cache-owned deep copy of the CSSSelectorList. The compilation/matching loop iterates cachedSelectorList instead of the caller-supplied selectorList, so the JIT bakes pointers into the long-lived cached data rather than the transient originating list. The compiledSelectors[argIndex++] slots are unchanged.LayoutTests/fast/css/has-lang-jit-crash.html— Regression test that inserts a deeply nested rule containing :has(#n1:only-child, :lang(en)) into a stylesheet and reloads the document up to 50 times via frames.navigation.reload(), using sessionStorage to count reloads. Each reload re-runs :has() matching so the cached JIT code from a prior (now-destroyed) document is re-invoked, dereferencing freed selector data unless the pointers were rebound to the cache copy.LayoutTests/fast/css/has-lang-jit-crash-expected.txt— Expected output is simply ‘PASS’ — the test passes if the browser survives 50 reloads without crashing in the :has() JIT path.
Background
:has() selector matching and its argument JIT — CSS :has() is a relational pseudo-class whose argument is itself a selector list evaluated against descendants/siblings of the anchor element. Because :has() can be extremely hot during style resolution, WebKit compiles the argument selectors with its selector JIT into native code. Each compiled argument is represented by a CompiledSelector, produced lazily on first match and reused thereafter.
compiledHasArgumentSelectorsMap content-keyed cache — To avoid recompiling identical :has() arguments seen across different rules, WebCore keeps a process-global HashMap keyed by CSSSelectorList content. The key is a deep copy of the list so equal-but-distinct lists share one entry, and the value is a FixedVector<CompiledSelector>. The cache is bounded by maximumCompiledHasArgumentSelectorsSize and evicts a random entry (map.remove(map.random())) when full.
Pointer baking in the CSS selector JIT — When the selector JIT compiles a selector, it emits machine code that dereferences the concrete CSSSelector objects and their auxiliary data (attribute QualifiedNames, :lang() language ranges, nth coefficients). These are captured as raw pointers embedded in the emitted code. The compiled code is therefore only valid so long as the exact CSSSelector objects it was compiled against remain alive at the same addresses.
CSSSelectorList lifetime versus cache lifetime — A CSSSelectorList supplied by a stylesheet is owned by that stylesheet/document and is destroyed when the document is torn down or the rule is mutated. The global compiled cache, by contrast, lives for the process. Any compiled artifact that retains pointers into a document-owned selector list becomes a dangling reference the moment that document dies — the mismatch this bug exploited.
Use-after-free from cross-navigation cache reuse — Because the cache is process-global and keyed by content, a selector matched in a destroyed document produces a cache hit when an equal selector appears in a later document. If the compiled code’s baked pointers referenced the first (now-freed) document’s selector data, the second match dereferences freed memory. The reload-loop test is precisely engineered to trigger this cross-navigation reuse.
Vulnerability window
- Style resolution — A rule containing :has(…) is matched; SelectorChecker::matchHasPseudoClass is reached and consults compiledHasArgumentSelectorsMap.
- First compile — map.ensure() inserts a new entry keyed by a deep copy of the CSSSelectorList; the JIT compiles each argument while the pre-patch code iterates the caller’s original list, baking pointers into that original (document-owned) list.
- Document teardown — The document/stylesheet is destroyed (e.g. via reload), freeing the original CSSSelector nodes; the global cache entry with its compiled code and stale pointers survives.
- Cache hit in new document — An equal :has() selector is matched again; map.ensure() returns the surviving entry and its compiled code.
- Dangling dereference — The compiled code (or the matching loop feeding it) dereferences the freed original selector data — a use-after-free, observable as a crash in the JIT/:has() path.
- Fix — matchHasPseudoClass now iterates hashKey.key() (the cache-owned deep copy) so pointers baked by the JIT point into memory whose lifetime equals the cached code’s.
Proof of concept
Verbatim core of the added LayoutTest has-lang-jit-crash.html (trimmed of the reload-option boilerplate). It inserts a rule whose :has() argument contains :lang(en) so the argument JIT compiles language-range data, then reloads the document up to 50 times. Each reload destroys the prior document’s selector data while the process-global compiled cache persists; re-matching the equal selector dereferences the freed data. Pre-patch this crashes; post-patch it prints PASS.
const limit = 50;
const count = parseInt(sessionStorage.getItem('reloadCount') || '0');
if (count === 0 && window.testRunner) {
testRunner.dumpAsText();
testRunner.waitUntilDone();
}
(async () => {
const styleSheet = document.styleSheets[0];
const rules = styleSheet.rules;
try {
styleSheet?.insertRule(`*|mpath & { @supports selector(:nth-last-of-type(odd)::-webkit-scrollbar-thumb:active:increment:hover) { & slot, :is(:dir(rtl) :not(& ~ :where(svg|stop[a1], .c1))) { & :not(dialog, :has(#n1:only-child, :lang(en)), slot, [a2*=b]) { column-count: auto; } } } }`, rules?.length);
} catch { }
if (count >= limit) {
sessionStorage.removeItem('reloadCount');
document.body.textContent = 'PASS';
if (window.testRunner)
testRunner.notifyDone();
return;
}
sessionStorage.setItem('reloadCount', count + 1);
await frames.navigation.reload();
})();
Exploitation
- Trigger — Author a stylesheet whose :has() argument list is JIT-compiled and cached, then destroy the originating document (reload/navigation) while keeping the process-global cache warm, and re-match an equal selector in a fresh document.
- Groom — Between teardown and re-match, spray the renderer heap so the freed CSSSelector/:lang data slot is reclaimed by attacker-controlled bytes. The :lang() language-range and attribute-name pointers baked by the JIT are attractive targets since their contents feed comparisons.
- Primitive — Controlled dereference of freed selector data inside JIT-compiled matching code — potentially a read/branch driven by attacker-controlled memory. Escalation to full control depends on what the compiled code does with the baked pointer (info-leak of heap layout, then a type-confused write).
- Reality check — The committed artifact demonstrates crash-only. No memory-corruption-to-RCE chain is present in the patch; escalation is inferred from the nature of a UAF in JIT code whose corrupted operands are attacker-selected selector data.
Detection & hunting
For defenders and SOC / detection engineers:
- Renderer crash signature —
- Content heuristics —
- Version gating —
Audit directions
- Other content-keyed JIT caches —
- Selector JIT pointer provenance —
- map.ensure() iterator/value patterns —
- Cross-document cache survival —