CVE-2025-24216
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.hSource/WebCore/css/CSSStyleSheet.cppSource/WebCore/css/SelectorChecker.cppSource/WebCore/css/StyleSheetContents.hSource/WebCore/style/RuleSetBuilder.cppSource/WebCore/style/RuleSetBuilder.h
Test Files
LayoutTests/fast/selectors/has-nesting-crash-expected.txtLayoutTests/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>