Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CSS
DescriptionInappropriate implementation in CSS
ComponentCSS
Bug ClassLogic Error
Tracker501810226
Fix commite87414ff9a63 (chromium/src) +132/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/css/style_rule.cc
modified
StyleRuleCloneTest
third_party/blink/renderer/core/css/style_rule_test.cc
modified
if
third_party/blink/renderer/core/css/style_rule_test.cc
modified
TEST_P
third_party/blink/renderer/core/css/style_rule_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/css/style_rule.cc
  • third_party/blink/renderer/core/css/style_rule.h
  • third_party/blink/renderer/core/css/style_rule_test.cc
From e87414ff9a639d5071278051457a1ad708e37e44 Mon Sep 17 00:00:00 2001
From: Anders Hartvoll Ruud <[email protected]>
Date: Thu, 16 Apr 2026 05:22:13 -0700
Subject: [PATCH] Make StyleRuleBase::Clone() always clone

Before CL:6943852, we had separate paths for deep-copying the rule
tree (for e.g. StyleSheetContents copies) and for "re-nesting",
which copied parts of the rule tree affected by a parent selector
change. The unification accidentally left the kNestedDeclarations
branch returning "this" in some cases, which is not correct
as it could cause multiple StyleSheetContents objects to hold
the same rule. The kApplyMixin and kContents branches have
the same problem.

Changed `DowncastTraits<StyleRuleGroup>` to include mixin/result
rules. (Additional rules should ultimately be listed, see
Issue 502048295). Otherwise test failures would be masked.

Fixed: 501810226
Bug: 502048295
Change-Id: I17f1e5cda0981f5cc29e38bd7d65ec060f0b3e7c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7755161
Reviewed-by: Steinar H Gunderson <[email protected]>
Commit-Queue: Anders Hartvoll Ruud <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1615762}
---

diff --git a/third_party/blink/renderer/core/css/style_rule.cc b/third_party/blink/renderer/core/css/style_rule.cc
index cec08b7..aa1e8fc3 100644
--- a/third_party/blink/renderer/core/css/style_rule.cc
+++ b/third_party/blink/renderer/core/css/style_rule.cc
@@ -605,6 +605,19 @@
                               mixin_parameter_bindings));
 }
 
+HeapVector<CSSSelector> CloneSelectorListWithDummyFallback(
+    StyleRule* new_parent) {
+  if (new_parent) {
+    return CSSSelectorList::Copy(new_parent->FirstSelector());
+  }
+  // A StyleRule cannot have an empty selector; create a dummy.
+  HeapVector<CSSSelector> selectors;
+  selectors.emplace_back(/*parent_rule=*/nullptr, /*is_implicit=*/true);
+  selectors.back().SetLastInSelectorList(true);
+  selectors.back().SetLastInComplexSelector(true);
+  return selectors;
+}
+
 // Make sure that the FakeParentRuleForDeclarations, if any,
 // gets our parent as parent. In particular, we'd like any
 // StyleRuleNestedDeclarations in there to get our selector
@@ -615,14 +628,19 @@
     StyleRule* old_inner_rule,
     StyleRule* new_parent,
     const MixinParameterBindings* mixin_parameter_bindings) {
+  if (!old_inner_rule) {
+    return nullptr;
+  }
   HeapVector<CSSSelector> selectors =
-      CSSSelectorList::Copy(new_parent->FirstSelector());
+      CloneSelectorListWithDummyFallback(new_parent);
   auto* new_rule = StyleRule::Create(
       selectors, old_inner_rule->Properties().ImmutableCopyIfNeeded(),
       mixin_parameter_bindings);
-  for (StyleRuleBase* child_rule : *old_inner_rule->ChildRules()) {
-    new_rule->AddChildRule(
-        child_rule->Clone(new_rule, mixin_parameter_bindings));
+  if (old_inner_rule->ChildRules()) {
+    for (StyleRuleBase* child_rule : *old_inner_rule->ChildRules()) {
+      new_rule->AddChildRule(
+          child_rule->Clone(new_rule, mixin_parameter_bindings));
+    }
   }
   return new_rule;
 }
@@ -701,9 +719,6 @@
     case kApplyMixin: {
       auto* apply_rule = To<StyleRuleApplyMixin>(this);
       StyleRule* old_inner_rule = apply_rule->FakeParentRuleForDeclarations();
-      if (!old_inner_rule || !old_inner_rule->ChildRules()) {
-        return this;
-      }
       return MakeGarbageCollected<StyleRuleApplyMixin>(
           apply_rule->GetName(), apply_rule->GetArguments(),
           CloneFakeParentRule(old_inner_rule, new_parent,
@@ -712,15 +727,14 @@
     case kContents: {
       auto* contents_rule = To<StyleRuleContentsStatement>(this);
       StyleRule* old_inner_rule = contents_rule->FakeParentRuleForFallback();
-      if (!old_inner_rule || !old_inner_rule->ChildRules()) {
-        return this;
-      }
       return MakeGarbageCollected<StyleRuleContentsStatement>(
           CloneFakeParentRule(old_inner_rule, new_parent,
                               mixin_parameter_bindings));
     }
     case kNestedDeclarations: {
       auto* nested_declarations_rule = To<StyleRuleNestedDeclarations>(this);
+      HeapVector<CSSSelector> selectors;
+      StyleRule* old_inner_rule = nested_declarations_rule->InnerStyleRule();
       // Nested declaration rules are different from regular nested style rules,
       // since they don't refer to their parent rule with any '&' selector.
       // Instead the outer selector list is *copied* parse-time. Now that we're
@@ -731,11 +745,10 @@
       // by @scope rules, however, since they always just behave like
       // :where(:scope).
       if (nested_declarations_rule->NestingType() == CSSNestingType::kScope) {
-        return this;
+        selectors = CSSSelectorList::Copy(old_inner_rule->FirstSelector());
+      } else {
+        selectors = CloneSelectorListWithDummyFallback(new_parent);
       }
-      StyleRule* old_inner_rule = nested_declarations_rule->InnerStyleRule();
-      HeapVector<CSSSelector> selectors =
-          CSSSelectorList::Copy(new_parent->FirstSelector());
       auto* new_inner_rule = StyleRule::Create(
           selectors, old_inner_rule->Properties().ImmutableCopyIfNeeded(),
           mixin_parameter_bindings);
diff --git a/third_party/blink/renderer/core/css/style_rule.h b/third_party/blink/renderer/core/css/style_rule.h
index 4beff03..5b3fe99 100644
--- a/third_party/blink/renderer/core/css/style_rule.h
+++ b/third_party/blink/renderer/core/css/style_rule.h
@@ -864,7 +864,7 @@
            rule.IsContainerRule() || rule.IsLayerBlockRule() ||
            rule.IsScopeRule() || rule.IsStartingStyleRule() ||
            rule.IsFunctionRule() || rule.IsPageRule() ||
-           rule.IsNavigationRule();
+           rule.IsNavigationRule() || rule.IsMixinRule() || rule.IsResultRule();
   }
 };
 
diff --git a/third_party/blink/renderer/core/css/style_rule_test.cc b/third_party/blink/renderer/core/css/style_rule_test.cc
index bdbd934..88f1cc0 100644
--- a/third_party/blink/renderer/core/css/style_rule_test.cc
+++ b/third_party/blink/renderer/core/css/style_rule_test.cc
@@ -5,14 +5,18 @@
 #include "third_party/blink/renderer/core/css/style_rule.h"
 
 #include "base/functional/function_ref.h"
+#include "third_party/blink/renderer/core/css/css_property_value_set.h"
 #include "third_party/blink/renderer/core/css/css_rule_list.h"
 #include "third_party/blink/renderer/core/css/css_scope_rule.h"
 #include "third_party/blink/renderer/core/css/css_style_rule.h"
 #include "third_party/blink/renderer/core/css/css_style_sheet.h"
 #include "third_party/blink/renderer/core/css/css_test_helpers.h"
 #include "third_party/blink/renderer/core/css/navigation_query.h"
+#include "third_party/blink/renderer/core/css/style_rule_nested_declarations.h"
+#include "third_party/blink/renderer/core/css/style_sheet_contents.h"
 #include "third_party/blink/renderer/core/dom/document.h"
 #include "third_party/blink/renderer/core/testing/page_test_base.h"
+#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 
@@ -581,4 +585,104 @@
   EXPECT_FALSE(navigation_test);
 }
 
+struct CloneTestParam {
+  const char* name;
+  const char* css;
+};
+
+class StyleRuleCloneTest : public StyleRuleTest,
+                           public testing::WithParamInterface<CloneTestParam> {
+ protected:
+  void VerifyDifferent(const StyleRuleBase* rule1, const StyleRuleBase* rule2) {
+    ASSERT_TRUE(rule1);
+    ASSERT_TRUE(rule2);
+    EXPECT_NE(rule1, rule2);
+    ASSERT_EQ(rule1->GetType(), rule2->GetType());
+
+    if (auto* group1 = DynamicTo<StyleRuleGroup>(rule1)) {
+      auto* group2 = To<StyleRuleGroup>(rule2);
+      const HeapVector<Member<StyleRuleBase>>& c1 = group1->ChildRules();
+      const HeapVector<Member<StyleRuleBase>>& c2 = group2->ChildRules();
+      ASSERT_EQ(c1.size(), c2.size());
+      for (wtf_size_t i = 0; i < c1.size(); ++i) {
+        VerifyDifferent(c1[i].Get(), c2[i].Get());
+      }
+    } else if (auto* style1 = DynamicTo<StyleRule>(rule1)) {
+      auto* style2 = To<StyleRule>(rule2);
+      const GCedHeapVector<Member<StyleRuleBase>>* c1 = style1->ChildRules();
+      const GCedHeapVector<Member<StyleRuleBase>>* c2 = style2->ChildRules();
+      if (c1 && c2) {
+        ASSERT_EQ(c1->size(), c2->size());
+        for (wtf_size_t i = 0; i < c1->size(); ++i) {
+          VerifyDifferent((*c1)[i].Get(), (*c2)[i].Get());
+        }
+      } else {
+        EXPECT_FALSE(c1);
+        EXPECT_FALSE(c2);
+      }
+    }
+  }
+};
+
+TEST_P(StyleRuleCloneTest, CloneRulesAreDifferent) {
+  auto param = GetParam();
+  CSSStyleSheet* sheet = css_test_helpers::CreateStyleSheet(GetDocument());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/css/style_rule_test.cc b/third_party/blink/renderer/core/css/style_rule_test.cc
index bdbd934..88f1cc0 100644
--- a/third_party/blink/renderer/core/css/style_rule_test.cc
+++ b/third_party/blink/renderer/core/css/style_rule_test.cc
@@ -5,14 +5,18 @@
 #include "third_party/blink/renderer/core/css/style_rule.h"
 
 #include "base/functional/function_ref.h"
+#include "third_party/blink/renderer/core/css/css_property_value_set.h"
 #include "third_party/blink/renderer/core/css/css_rule_list.h"
 #include "third_party/blink/renderer/core/css/css_scope_rule.h"
 #include "third_party/blink/renderer/core/css/css_style_rule.h"
 #include "third_party/blink/renderer/core/css/css_style_sheet.h"
 #include "third_party/blink/renderer/core/css/css_test_helpers.h"
 #include "third_party/blink/renderer/core/css/navigation_query.h"
+#include "third_party/blink/renderer/core/css/style_rule_nested_declarations.h"
+#include "third_party/blink/renderer/core/css/style_sheet_contents.h"
 #include "third_party/blink/renderer/core/dom/document.h"
 #include "third_party/blink/renderer/core/testing/page_test_base.h"
+#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 
@@ -581,4 +585,104 @@
   EXPECT_FALSE(navigation_test);
 }
 
+struct CloneTestParam {
+  const char* name;
+  const char* css;
+};
+
+class StyleRuleCloneTest : public StyleRuleTest,
+                           public testing::WithParamInterface<CloneTestParam> {
+ protected:
+  void VerifyDifferent(const StyleRuleBase* rule1, const StyleRuleBase* rule2) {
+    ASSERT_TRUE(rule1);
+    ASSERT_TRUE(rule2);
+    EXPECT_NE(rule1, rule2);
+    ASSERT_EQ(rule1->GetType(), rule2->GetType());
+
+    if (auto* group1 = DynamicTo<StyleRuleGroup>(rule1)) {
+      auto* group2 = To<StyleRuleGroup>(rule2);
+      const HeapVector<Member<StyleRuleBase>>& c1 = group1->ChildRules();
+      const HeapVector<Member<StyleRuleBase>>& c2 = group2->ChildRules();
+      ASSERT_EQ(c1.size(), c2.size());
+      for (wtf_size_t i = 0; i < c1.size(); ++i) {
+        VerifyDifferent(c1[i].Get(), c2[i].Get());
+      }
+    } else if (auto* style1 = DynamicTo<StyleRule>(rule1)) {
+      auto* style2 = To<StyleRule>(rule2);
+      const GCedHeapVector<Member<StyleRuleBase>>* c1 = style1->ChildRules();
+      const GCedHeapVector<Member<StyleRuleBase>>* c2 = style2->ChildRules();
+      if (c1 && c2) {
+        ASSERT_EQ(c1->size(), c2->size());
+        for (wtf_size_t i = 0; i < c1->size(); ++i) {
+          VerifyDifferent((*c1)[i].Get(), (*c2)[i].Get());
+        }
+      } else {
+        EXPECT_FALSE(c1);
+        EXPECT_FALSE(c2);
+      }
+    }
+  }
+};
+
+TEST_P(StyleRuleCloneTest, CloneRulesAreDifferent) {
+  auto param = GetParam();
+  CSSStyleSheet* sheet = css_test_helpers::CreateStyleSheet(GetDocument());
+  sheet->SetText(param.css, CSSImportRules::kIgnoreWithWarning);
+  StyleSheetContents* contents1 = sheet->Contents();
+  StyleSheetContents* contents2 = contents1->Copy();
+
+  ASSERT_EQ(contents1->ChildRules().size(), contents2->ChildRules().size());
+  EXPECT_GT(contents1->ChildRules().size(), 0);
+
+  for (wtf_size_t i = 0; i < contents1->ChildRules().size(); ++i) {
+    VerifyDifferent(contents1->ChildRules()[i].Get(),
+                    contents2->ChildRules()[i].Get());
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    StyleRuleTest,
+    StyleRuleCloneTest,
+    testing::Values(
+        CloneTestParam{"NestedScopeDeclarations",
+                       "@scope (div) { color: green; } "},
+        CloneTestParam{"NestedDeclarations",
+                       "div { @media (width > 100px) { color: green; } }"},
+        CloneTestParam{"MixinContentsStatement",
+                       "@mixin --m() { @result { @contents; } }"},
+        CloneTestParam{"MixinContentsEmptyBlock",
+                       "@mixin --m() { @result { @contents {} } }"},
+        CloneTestParam{"MixinContentsNonEmptyBlock",
+                       "@mixin --m() { @result { @contents { div {} } } }"},
+        CloneTestParam{"ApplyStatement", "div { @apply --m(); }"},
+        CloneTestParam{"ApplyEmptyBlock", "div { @apply --m() { } }"},
+        CloneTestParam{"ApplyNonEmptyBlock",
+                       "div { @apply --m() { div {} } }"}),
+    [](const testing::TestParamInfo<StyleRuleCloneTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST_F(StyleRuleTest, CloneNestedDeclarationsNoParent) {
+  HeapVector<CSSSelector> selectors;
+  selectors.emplace_back(/*parent_rule=*/nullptr, /*is_implicit=*/true);
+  selectors.back().SetLastInSelectorList(true);
+  selectors.back().SetLastInComplexSelector(true);
+
+  auto* declarations =
+      MakeGarbageCollected<MutableCSSPropertyValueSet>(kHTMLStandardMode);
+
+  auto* inner_rule = StyleRule::Create(selectors, declarations,
+                                       /*mixin_parameter_bindings=*/nullptr);
+
+  StyleRuleBase* nested_declarations1 =
+      MakeGarbageCollected<StyleRuleNestedDeclarations>(
+          CSSNestingType::kNesting, inner_rule);
+
+  StyleRuleBase* nested_declarations2 = nested_declarations1->Clone(
+      /*new_parent=*/nullptr,
+      /*mixin_parameter_bindings=*/nullptr);  // Don't crash.
+
+  EXPECT_NE(nested_declarations1, nested_declarations2);
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by [email protected]

Same-Process CSS Poisoning via @scope COW failure

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: A logic error in StyleRuleBase::Clone() breaks the Copy-On-Write (COW) mechanism for CSS rules nested inside an @scope rule. When cloned, these rules return this instead of a deep copy, allowing an attacker to modify shared StyleSheetContents in the process cache. This can potentially lead to cross-origin data exfiltration or UI spoofing for documents sharing the renderer process.

Affected files:

  • third_party/blink/renderer/core/css/style_rule.cc
  • third_party/blink/renderer/core/css/css_style_sheet.cc
  • third_party/blink/renderer/core/css/style_sheet_contents.cc
  • third_party/blink/renderer/core/css/css_nested_declarations_rule.cc
  • third_party/blink/renderer/core/loader/resource/css_style_sheet_resource.cc

Estimated timestamp from git blame: 2025-10-24

Summary

A vulnerability in Blink’s CSS engine potentially allows for cross-document mutation of cached stylesheets. Due to a broken copy-on-write (COW) invariant in StyleRuleBase::Clone(), mutating an @scope rule via CSSOM can inadvertently poison a shared stylesheet stored in the renderer’s CSSStyleSheetResource cache. This enables same-site cross-origin CSS declaration injection, which can be leveraged for UI spoofing and information exfiltration.

Technical Details

When a CSSOM mutation is performed on a shared external stylesheet, CSSStyleSheet::WillMutateRules() performs copy-on-write by calling StyleSheetContents::Copy(). This operation is intended to create a deep clone of the stylesheet’s rule tree using StyleRuleBase::Clone().

However, in third_party/blink/renderer/core/css/style_rule.cc, the Clone() implementation for kNestedDeclarations contains a logic flaw:

case kNestedDeclarations: {
  auto* nested_declarations_rule = To<StyleRuleNestedDeclarations>(this);
  // ...
  if (nested_declarations_rule->NestingType() == CSSNestingType::kScope) {
    return this; // <--- The vulnerability
  }
  // ...
}

When cloning an @scope rule, the function returns the original StyleRuleNestedDeclarations object instead of a new instance. As a result, the “cloned” StyleSheetContents tree incorrectly aliases the original rule owned by the cached, immutable StyleSheetContents in CSSStyleSheetResource.

During the mutation process, CSSNestedDeclarationsRule::Reattach() re-binds the CSSOM wrapper to the shared rule’s property set. Subsequent modifications (e.g., via style.setProperty()) write directly into the shared MutableCSSPropertyValueSet. Because the original StyleSheetContents object’s is_mutable_ flag remains false, it continues to be served from the cache to other documents in the same renderer process.

Latent issues also exist for kApplyMixin and kContents branches in StyleRuleBase::Clone(), which also return this when they should return a clone.

Potential Impact

An attacker-controlled document can potentially inject arbitrary CSS declarations into the :where(:scope) block of an @scope rule in a shared stylesheet. This affects all same-process documents (typically same-site cross-origin) that load the same stylesheet. This could lead to:

  1. Information Exfiltration: Using background-image: url(...) to leak the victim’s URL via the Referer header.
  2. UI Spoofing: Altering the appearance of elements within the @scope root in the victim document.
  3. Persistent Cache Poisoning: The shared stylesheet remains poisoned in the renderer’s memory cache for the duration of the process.

Potential Reproduction Steps

Note: Our agent does not have the ability to run code, so these are theoretical steps based on code analysis.

  1. Host a shared CSS file with @scope (body) { color: green; } on a CORS-enabled origin.
  2. Load an attacker page and a victim page (same-site, different origin) that both link to this stylesheet, ensuring they share a renderer process.
  3. In the attacker page, access the @scope rule’s nested declarations via CSSOM and call setProperty('background-image', 'url(https://attacker.test/probe)').
  4. Trigger a layout recalculation in the victim page. The victim page should apply the injected background image and issue a request to the attacker’s server with its own URL in the Referer header.

Suggested Fix

Modify StyleRuleBase::Clone() in third_party/blink/renderer/core/css/style_rule.cc to perform a proper deep clone for kNestedDeclarations when NestingType() == CSSNestingType::kScope. Ensure that kApplyMixin and kContents also correctly clone their inner rules instead of returning this.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker