Released March 31, 2025

CVE-2025-24216

CVSS 4.3 severity medium CWE-119CWE-508 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: Paul Bakker of ParagonERP

Key insight. CSS nesting could be resolved more than once against the same sheet, re-processing selectors whose owners had been destroyed; making resolution once-per-sheet (with a mutation reset) and asserting selector liveness fixes the use-after-destroy.

Patch Details

diff --git a/LayoutTests/fast/selectors/has-nesting-crash-expected.txt b/LayoutTests/fast/selectors/has-nesting-crash-expected.txt
new file mode 100644
index 000000000000..d380fbf76e1a
--- /dev/null
+++ b/LayoutTests/fast/selectors/has-nesting-crash-expected.txt
@@ -0,0 +1,2 @@
+This test passes if it doesn't crash.
+
diff --git a/LayoutTests/fast/selectors/has-nesting-crash.html b/LayoutTests/fast/selectors/has-nesting-crash.html
new file mode 100644
index 000000000000..63dba7c79e31
--- /dev/null
+++ b/LayoutTests/fast/selectors/has-nesting-crash.html
@@ -0,0 +1,18 @@
+<body>
+This test passes if it doesn't crash.
+<host-element id=host>
+<template shadowrootmode=open></template>
+</host-element>
+<script>
+window.testRunner?.dumpAsText();
+
+const css = new CSSStyleSheet();
+css.replaceSync("* { :has(.foo) { color: green; } }");
+document.adoptedStyleSheets.push(css);
+host.shadowRoot.adoptedStyleSheets.push(css);
+document.body.offsetWidth;
+
+const div = document.createElement("div");
+div.classList.add("foo");
+document.body.appendChild(div);
+</script>
diff --git a/Source/WebCore/css/CSSSelector.h b/Source/WebCore/css/CSSSelector.h
index 2fd45f301465..ff02a2733aef 100644
--- a/Source/WebCore/css/CSSSelector.h
+++ b/Source/WebCore/css/CSSSelector.h
@@ -183,6 +183,10 @@ WTF_ALLOW_UNSAFE_BUFFER_USAGE_END
     // Implicit means that this selector is not author/UA written.
     bool isImplicit() const { return m_isImplicit; }
 
+#if !ASSERT_WITH_SECURITY_IMPLICATION_DISABLED
+    bool destructorHasBeenCalled() const { return m_destructorHasBeenCalled; }
+#endif
+
 private:
     friend class MutableCSSSelector;
 
diff --git a/Source/WebCore/css/CSSStyleSheet.cpp b/Source/WebCore/css/CSSStyleSheet.cpp
index 6d38ca370d6f..d1bb3ef9420c 100644
--- a/Source/WebCore/css/CSSStyleSheet.cpp
+++ b/Source/WebCore/css/CSSStyleSheet.cpp
@@ -230,6 +230,7 @@ void CSSStyleSheet::didMutateRules(RuleMutationType mutationType, ContentsCloned
 {
     ASSERT(m_contents->isMutable());
     ASSERT(m_contents->hasOneClient());
+    m_contents->setHasResolvedNesting(false);
 
     forEachStyleScope([&](Style::Scope& scope) {
         if ((mutationType == RuleInsertion || mutationType == RuleReplace) && contentsClonedForMutation == ContentsClonedForMutation::No && !scope.activeStyleSheetsContains(*this)) {
diff --git a/Source/WebCore/css/SelectorChecker.cpp b/Source/WebCore/css/SelectorChecker.cpp
index 5ad7845ef9d4..5ce5b8b1cdf3 100644
--- a/Source/WebCore/css/SelectorChecker.cpp
+++ b/Source/WebCore/css/SelectorChecker.cpp
@@ -204,6 +204,8 @@ SelectorChecker::SelectorChecker(Document& document)
 
 bool SelectorChecker::match(const CSSSelector& selector, const Element& element, CheckingContext& checkingContext) const
 {
+    ASSERT_WITH_SECURITY_IMPLICATION(!selector.destructorHasBeenCalled());
+
     auto pseudoElementIdentifier = checkingContext.pseudoId == PseudoId::None ? std::nullopt : std::optional(Style::PseudoElementIdentifier { checkingContext.pseudoId, checkingContext.pseudoElementNameArgument });
     LocalContext context(selector, element, checkingContext.resolvingMode == SelectorChecker::Mode::QueryingRules ? VisitedMatchType::Disabled : VisitedMatchType::Enabled, pseudoElementIdentifier);
 
diff --git a/Source/WebCore/css/StyleSheetContents.h b/Source/WebCore/css/StyleSheetContents.h
index 461e4d32f608..8a6e79f99ceb 100644
--- a/Source/WebCore/css/StyleSheetContents.h
+++ b/Source/WebCore/css/StyleSheetContents.h
@@ -162,6 +162,9 @@ public:
 
     friend class CSSStyleSheet;
 
+    bool hasResolvedNesting() const { return m_hasResolvedNesting; }
+    void setHasResolvedNesting(bool value) const { m_hasResolvedNesting = value; }
+
 private:
     WEBCORE_EXPORT StyleSheetContents(StyleRuleImport* ownerRule, const String& originalURL, const CSSParserContext&);
     StyleSheetContents(const StyleSheetContents&);
@@ -187,6 +190,7 @@ private:
     bool m_didLoadErrorOccur { false };
     bool m_usesStyleBasedEditability { false };
     bool m_isMutable { false };
+    mutable bool m_hasResolvedNesting { false };
     mutable std::optional<bool> m_hasNestingRulesCache;
     unsigned m_inMemoryCacheCount { 0 };
 
diff --git a/Source/WebCore/style/RuleSetBuilder.cpp b/Source/WebCore/style/RuleSetBuilder.cpp
index e6365a5d735e..ec89484f9e60 100644
--- a/Source/WebCore/style/RuleSetBuilder.cpp
+++ b/Source/WebCore/style/RuleSetBuilder.cpp
@@ -55,7 +55,7 @@ RuleSetBuilder::RuleSetBuilder(RuleSet& ruleSet, const MQ::MediaQueryEvaluator&
     , m_mediaQueryCollector({ evaluator })
     , m_resolver(resolver)
     , m_shrinkToFit(shrinkToFit)
-    , m_shouldResolveNesting(shouldResolveNesting)
+    , m_builderShouldResolveNesting(shouldResolveNesting)
 {
 }
 
@@ -136,10 +136,10 @@ void RuleSetBuilder::addChildRule(Ref<StyleRuleBase> rule)
         // https://drafts.csswg.org/css-nesting/#nesting-at-scope
         // For the purposes of the style rules in its body and its own <scope-end> selector,
         // the @scope rule is treated as an ancestor style rule, matching the elements matched by its <scope-start> selector.
-        if (m_shouldResolveNesting == ShouldResolveNesting::Yes) {
+        if (m_shouldResolveNestingForSheet) {
             const CSSSelectorList* parentResolvedSelectorList = nullptr;
             if (m_selectorListStack.size())
-                parentResolvedSelectorList =  m_selectorListStack.last();
+                parentResolvedSelectorList = m_selectorListStack.last();
             if (!scopeRule->originalScopeStart().isEmpty())
                 scopeRule->setScopeStart(CSSSelectorParser::resolveNestingParent(scopeRule->originalScopeStart(), parentResolvedSelectorList));
             if (!scopeRule->originalScopeEnd().isEmpty())
@@ -246,6 +246,8 @@ void RuleSetBuilder::addChildRule(Ref<StyleRuleBase> rule)
 
 void RuleSetBuilder::addRulesFromSheetContents(const StyleSheetContents& sheet)
 {
+    auto nestingResolveScope = SetForScope { m_shouldResolveNestingForSheet, m_builderShouldResolveNesting == ShouldResolveNesting::Yes && !sheet.hasResolvedNesting() };
+
     for (auto& rule : sheet.layerRulesBeforeImportRules())
         registerLayers(rule->nameList());
 
@@ -272,10 +274,15 @@ void RuleSetBuilder::addRulesFromSheetContents(const StyleSheetContents& sheet)
     }
 
     addChildRules(sheet.childRules());
+
+    if (m_shouldResolveNestingForSheet)
+        sheet.setHasResolvedNesting(true);
 }
 
 void RuleSetBuilder::resolveSelectorListWithNesting(StyleRuleWithNesting& rule)
 {
+    ASSERT(m_shouldResolveNestingForSheet);
+
     const CSSSelectorList* parentResolvedSelectorList = nullptr;
     if (m_selectorListStack.size())
         parentResolvedSelectorList = m_selectorListStack.last();
@@ -303,7 +310,7 @@ void RuleSetBuilder::addStyleRuleWithSelectorList(const CSSSelectorList& selecto
 
 void RuleSetBuilder::addStyleRule(StyleRuleWithNesting& rule)
 {
-    if (m_shouldResolveNesting == ShouldResolveNesting::Yes)
+    if (m_shouldResolveNestingForSheet)
         resolveSelectorListWithNesting(rule);
 
     const auto& selectorList = rule.selectorList();
diff --git a/Source/WebCore/style/RuleSetBuilder.h b/Source/WebCore/style/RuleSetBuilder.h
index 66bba6e91485..09aa9395b8a3 100644
--- a/Source/WebCore/style/RuleSetBuilder.h
+++ b/Source/WebCore/style/RuleSetBuilder.h
@@ -89,7 +89,8 @@ private:
     RuleSet::CascadeLayerIdentifier m_currentCascadeLayerIdentifier { 0 };
     Vector<const CSSSelectorList*> m_selectorListStack;
     Vector<CSSParserEnum::NestedContextType> m_ancestorStack;
-    const ShouldResolveNesting m_shouldResolveNesting { ShouldResolveNesting::No };
+    const ShouldResolveNesting m_builderShouldResolveNesting { ShouldResolveNesting::No };
+    bool m_shouldResolveNestingForSheet { false };
 
     RuleSet::ContainerQueryIdentifier m_currentContainerQueryIdentifier { 0 };
     RuleSet::ScopeRuleIdentifier m_currentScopeIdentifier { 0 };

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2025-24216
Bugzilla 284055
Component WebCore
Bug Class UAF
Severity medium
Commit b879a659b19009b9…
Advisory Apple Advisory

Terminology

  • CSS nesting resolution — Flattening nested CSS rules into fully-qualified selectors when building a RuleSet.
  • StyleSheetContents / RuleSet — Parsed stylesheet contents and the built rule set; RuleSets can be rebuilt, re-running resolution.
  • hasResolvedNesting flag — New per-sheet state ensuring nesting is resolved once, reset when the sheet mutates.
  • destructorHasBeenCalled — A CSSSelector flag, checked under ASSERT_WITH_SECURITY_IMPLICATION, that detects use of a destroyed selector.

Root Cause Analysis

CSS nesting resolution flattens nested style rules into resolved selectors when building a RuleSet from a stylesheet. RuleSetBuilder drove this with a per-builder flag (m_shouldResolveNesting), but resolution could run more than once against the same StyleSheetContents (e.g. when a RuleSet is rebuilt) and, on a subsequent pass, operate on CSSSelector data whose owning objects had already been destroyed — a use-after-destroy of selector memory (surfacing as a Safari crash). The fix makes nesting resolution idempotent per sheet and adds a memory-safety assertion: StyleSheetContents gains a hasResolvedNesting()/setHasResolvedNesting() flag, RuleSetBuilder computes m_shouldResolveNestingForSheet as builderShouldResolveNesting && !sheet.hasResolvedNesting() (so a sheet’s nesting is resolved exactly once and marked resolved via setHasResolvedNesting(true)), CSSStyleSheet::didMutateRules resets the flag with setHasResolvedNesting(false) so a genuinely mutated sheet re-resolves, and SelectorChecker/CSSSelector add ASSERT_WITH_SECURITY_IMPLICATION(!selector.destructorHasBeenCalled()) backed by a destructorHasBeenCalled flag to catch use of a destroyed selector. The restored invariant is that a stylesheet’s nesting is resolved once per (unmutated) content, so resolution never re-runs over selectors that have since been destroyed.

Attack Path

1. Author nested CSS

Provide a stylesheet using CSS nesting whose rules get resolved into flattened selectors.

2. Trigger a rebuild

Cause the RuleSet to be rebuilt so nesting resolution runs again against the same sheet contents.

3. Re-resolve over destroyed selectors

Pre-patch, the second resolution pass operates on CSSSelector data whose owning objects were already destroyed.

4. Use-after-destroy

Reading the destroyed selector memory crashes or corrupts memory in the WebContent process.

Impact Assessment

A use-after-destroy of CSS selector data when nesting resolution re-runs over a rebuilt stylesheet, reachable from crafted nested CSS in the WebContent process. The observable is a crash; the added security assertion marks it a memory-safety bug, with corruption potential if the freed selector storage is reclaimed. Confined to WebContent; rated medium.

Audit Directions

Repeated resolution passes

Audit RuleSet rebuild paths for operations that assume single-pass resolution over selector data that may be freed between passes.

Selector lifetime

grep for CSSSelector/CSSSelectorList uses across rule-set rebuilds; extend destructorHasBeenCalled-style checks where selectors outlive expectations.

didMutateRules invalidation

Confirm every cached resolution/state keyed on sheet contents is reset in didMutateRules and similar mutation hooks.

Changed Functions

Function File Change Note
RuleSetBuilder::addChildRule / addRulesFromSheetContents / addStyleRuleWithSelectorList Source/WebCore/style/RuleSetBuilder.cpp modified Gates nesting resolution on m_shouldResolveNestingForSheet = builderShouldResolveNesting && !sheet.hasResolvedNesting(), and marks the sheet resolved, so nesting resolves exactly once per unmutated sheet.
CSSStyleSheet::didMutateRules Source/WebCore/css/CSSStyleSheet.cpp modified Calls setHasResolvedNesting(false) so a mutated sheet re-resolves nesting on the next build.
SelectorChecker / CSSSelector::destructorHasBeenCalled Source/WebCore/css/SelectorChecker.cpp modified Adds ASSERT_WITH_SECURITY_IMPLICATION(!selector.destructorHasBeenCalled()) and a destructor flag on CSSSelector to catch use of a destroyed selector.

Files Changed

Source Files

  • Source/WebCore/css/CSSSelector.h
  • Source/WebCore/css/CSSStyleSheet.cpp
  • Source/WebCore/css/SelectorChecker.cpp
  • Source/WebCore/css/StyleSheetContents.h
  • Source/WebCore/style/RuleSetBuilder.cpp
  • Source/WebCore/style/RuleSetBuilder.h

Test Files

  • LayoutTests/fast/selectors/has-nesting-crash-expected.txt
  • LayoutTests/fast/selectors/has-nesting-crash.html

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/LayoutTests/fast/selectors/has-nesting-crash-expected.txt b/LayoutTests/fast/selectors/has-nesting-crash-expected.txt
new file mode 100644
index 000000000000..d380fbf76e1a
--- /dev/null
+++ b/LayoutTests/fast/selectors/has-nesting-crash-expected.txt
@@ -0,0 +1,2 @@
+This test passes if it doesn't crash.
+
diff --git a/LayoutTests/fast/selectors/has-nesting-crash.html b/LayoutTests/fast/selectors/has-nesting-crash.html
new file mode 100644
index 000000000000..63dba7c79e31
--- /dev/null
+++ b/LayoutTests/fast/selectors/has-nesting-crash.html
@@ -0,0 +1,18 @@
+<body>
+This test passes if it doesn't crash.
+<host-element id=host>
+<template shadowrootmode=open></template>
+</host-element>
+<script>
+window.testRunner?.dumpAsText();
+
+const css = new CSSStyleSheet();
+css.replaceSync("* { :has(.foo) { color: green; } }");
+document.adoptedStyleSheets.push(css);
+host.shadowRoot.adoptedStyleSheets.push(css);
+document.body.offsetWidth;
+
+const div = document.createElement("div");
+div.classList.add("foo");
+document.body.appendChild(div);
+</script>