Medium CVSS 4.3 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentWebCore
Sub-componentWebCore CSS
Bug ClassUAF
Tracker284055
Fix commitb879a659b190 (WebKit/WebKit) +44/-5
CWECWE-119, CWE-508 (Buffer bounds error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedPaul Bakker of ParagonERP
Disclosed2025-03-31

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
forEachStyleScope
Source/WebCore/css/CSSStyleSheet.cpp
modified
if
Source/WebCore/style/RuleSetBuilder.cpp
modified

Files Changed

  • LayoutTests/fast/selectors/has-nesting-crash-expected.txt
  • LayoutTests/fast/selectors/has-nesting-crash.html
  • 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

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.
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 };
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.