CVE-2026-11076
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/css/css_scope_rule.cc |
modified | |
ifthird_party/blink/renderer/core/css/css_style_rule.cc |
modified |
Files Changed
third_party/blink/renderer/core/css/css_rule.ccthird_party/blink/renderer/core/css/css_rule.hthird_party/blink/renderer/core/css/css_scope_rule.ccthird_party/blink/renderer/core/css/css_style_rule.ccthird_party/blink/renderer/core/css/css_style_rule.hthird_party/blink/renderer/core/css/style_rule.cc
Patch
From 86ce2036970f858441b3fa59556ffa44f608ce88 Mon Sep 17 00:00:00 2001 From: Anders Hartvoll Ruud <[email protected]> Date: Fri, 10 Apr 2026 06:29:23 -0700 Subject: [PATCH] Perform rule replacement on immediate parent only After landing CL:7743265, I realized that our current behavior of starting the "rule replacement search" from the top of the stylesheet (or root of the detached rule tree) doesn't make sense: The rule we're looking for must exist in the immediate parent object of the rule being replaced. Therefore, this CL avoids the recursive search and instead just looks in the parent rule/sheet. The lack of unification between style rules (which can have children) and grouping rules (which can also have children) continues to make this more annoying than it should be, but addressing that is out of scope. This CL also fixes a problem similar to the original (Issue 499784386) in CSSScopeRule. For a detached rule tree, we would fail to update the child vector of the parent rule. As a minor bonus, the "position hint" optimization now works on any nesting level. Bug: 499784386 Change-Id: I0164380dc7caf80bc6f4b06bc22661dcc19faa6b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7748546 Commit-Queue: Anders Hartvoll Ruud <[email protected]> Reviewed-by: Steinar H Gunderson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1612809} --- diff --git a/third_party/blink/renderer/core/css/css_rule.cc b/third_party/blink/renderer/core/css/css_rule.cc index a920311..0d0f723 100644 --- a/third_party/blink/renderer/core/css/css_rule.cc +++ b/third_party/blink/renderer/core/css/css_rule.cc @@ -21,6 +21,8 @@ #include "third_party/blink/renderer/core/css/css_rule.h" +#include "third_party/blink/renderer/core/css/css_grouping_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/style_rule.h" #include "third_party/blink/renderer/core/css/style_sheet_contents.h" @@ -58,6 +60,27 @@ } } +wtf_size_t CSSRule::ReplaceChildRuleInParentIfExists(StyleRuleBase* old_rule, + StyleRuleBase* new_rule, + wtf_size_t position_hint) { + CSSRule* parent_rule = parentRule(); + if (auto* style_rule = DynamicTo<CSSStyleRule>(parent_rule)) { + return style_rule->GetStyleRule()->ReplaceChildRuleIfExists( + old_rule, new_rule, position_hint); + } + if (auto* grouping_rule = DynamicTo<CSSGroupingRule>(parent_rule)) { + return grouping_rule->GroupRule()->ReplaceChildRuleIfExists( + old_rule, new_rule, position_hint); + } + if (CSSStyleSheet* parent_stylesheet = parentStyleSheet()) { + StyleSheetContents* contents = parent_stylesheet->Contents(); + CHECK(contents); + return contents->ReplaceChildRuleIfExists(old_rule, new_rule, + position_hint); + } + return std::numeric_limits<wtf_size_t>::max(); // Not found. +} + void CSSRule::SetParentStyleSheet(CSSStyleSheet* style_sheet) { parent_is_rule_ = false; parent_ = style_sheet; diff --git a/third_party/blink/renderer/core/css/css_rule.h b/third_party/blink/renderer/core/css/css_rule.h index 8ba3f7ab..2de17e8 100644 --- a/third_party/blink/renderer/core/css/css_rule.h +++ b/third_party/blink/renderer/core/css/css_rule.h @@ -31,6 +31,7 @@ #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/heap/member.h" #include "third_party/blink/renderer/platform/wtf/text/wtf_string.h" +#include "third_party/blink/renderer/platform/wtf/wtf_size_t.h" namespace blink { @@ -145,6 +146,16 @@ void CountUse(WebFeature) const; + // Replaces a StyleRuleBase in the child vector of the parent. + // The "parent" may be another rule, a stylesheet, or null (if the + // rule tree has been detached from the stylesheet). + // + // See StyleSheetContents::ReplaceRuleIfExists() for an explanation + // of `position_hint` and the return value. + wtf_size_t ReplaceChildRuleInParentIfExists(StyleRuleBase* old_rule, + StyleRuleBase* new_rule, + wtf_size_t position_hint); + private: bool VerifyParentIsCSSRule() const; bool VerifyParentIsCSSStyleSheet() const; diff --git a/third_party/blink/renderer/core/css/css_scope_rule.cc b/third_party/blink/renderer/core/css/css_scope_rule.cc index 182f4c41..920ddf6 100644 --- a/third_party/blink/renderer/core/css/css_scope_rule.cc +++ b/third_party/blink/renderer/core/css/css_scope_rule.cc @@ -115,10 +115,11 @@ StyleRuleScope* new_group_rule = MakeGarbageCollected<StyleRuleScope>( *new_style_scope, std::move(new_child_rules)); - if (parentStyleSheet()) { - parentStyleSheet()->Contents()->ReplaceRuleIfExists(group_rule_, - new_group_rule, - /*position_hint=*/0); + ReplaceChildRuleInParentIfExists( + /*old_rule=*/group_rule_, new_group_rule, /*position_hint=*/0); + + if (contents) { + contents->NotifyDiffUnrepresentable(); } Reattach(new_group_rule); diff --git a/third_party/blink/renderer/core/css/css_style_rule.cc b/third_party/blink/renderer/core/css/css_style_rule.cc index 372532f..d02f814 100644 --- a/third_party/blink/renderer/core/css/css_style_rule.cc +++ b/third_party/blink/renderer/core/css/css_style_rule.cc @@ -90,8 +90,7 @@ const auto* context = MakeGarbageCollected<CSSParserContext>( ParserContext(execution_context->GetSecureContextMode())); - CSSRule* root_rule = RootCSSRule(); - CSSStyleSheet* parent_stylesheet = root_rule->parentStyleSheet(); + CSSStyleSheet* parent_stylesheet = parentStyleSheet(); StyleSheetContents* parent_contents = parent_stylesheet ? parent_stylesheet->Contents() : nullptr; HeapVector<CSSSelector> arena; @@ -114,22 +113,13 @@ new_style_rule, /*mixin_parameter_bindings=*/nullptr)); } } + + position_hint_ = ReplaceChildRuleInParentIfExists( + /*old_rule=*/style_rule_, new_style_rule, position_hint_); + if (parent_contents) { - position_hint_ = parent_contents->ReplaceRuleIfExists( - style_rule_, new_style_rule, position_hint_); - } else if (root_rule) { - // This wrapper is detached from its CSSStyleSheet. We need to look - // for the rule to replace starting at the root of the (detached) - // rule tree. - if (auto* style_rule_root = DynamicTo<CSSStyleRule>(root_rule)) { - style_rule_root->GetStyleRule()->ReplaceRuleIfExists(style_rule_, - new_style_rule); - } else { - To<CSSGroupingRule>(root_rule)->GroupRule()->ReplaceRuleIfExists( - style_rule_, new_style_rule); - } - // Note that "position hint" is an optimization for the top level - // only, so we don't update that here. + parent_contents->NotifyRuleChanged(style_rule_); + parent_contents->NotifyRuleChanged(new_style_rule); } // Updates style_rule_, as well as any inner CSSOM wrappers. diff --git a/third_party/blink/renderer/core/css/css_style_rule.h b/third_party/blink/renderer/core/css/css_style_rule.h index 6d489e5e7f..0ff69d2 100644 --- a/third_party/blink/renderer/core/css/css_style_rule.h +++ b/third_party/blink/renderer/core/css/css_style_rule.h @@ -96,9 +96,9 @@ mutable Member<StyleRuleCSSStyleDeclaration> properties_cssom_wrapper_; Member<StylePropertyMap> style_map_; - // Used to faster localize the rule in the parent style sheet. - // May be wrong if indexes have moved around or the rule has been - // deleted from the style sheet. + // Used to faster localize the rule in the parent rule or parent + // style sheet. May be wrong if indexes have moved around or the rule + // has been deleted from its parent. wtf_size_t position_hint_; mutable HeapVector<Member<CSSRule>> child_rule_cssom_wrappers_; diff --git a/third_party/blink/renderer/core/css/style_rule.cc b/third_party/blink/renderer/core/css/style_rule.cc index b4bfbfd..cec08b7 100644 --- a/third_party/blink/renderer/core/css/style_rule.cc +++ b/third_party/blink/renderer/core/css/style_rule.cc @@ -21,6 +21,8 @@ #include "third_party/blink/renderer/core/css/style_rule.h" +#include <limits> + #include "base/compiler_specific.h" #include "third_party/blink/renderer/core/css/css_apply_mixin_rule.h" #include "third_party/blink/renderer/core/css/css_container_rule.h" @@ -84,6 +86,7 @@ #include "third_party/blink/renderer/platform/heap/visitor.h" #include "third_party/blink/renderer/platform/instrumentation/use_counter.h" #include "third_party/blink/renderer/platform/wtf/size_assertions.h" +#include "third_party/blink/renderer/platform/wtf/wtf_size_t.h"
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 29df2ef..bdbd934 100644
--- a/third_party/blink/renderer/core/css/style_rule_test.cc
+++ b/third_party/blink/renderer/core/css/style_rule_test.cc
@@ -370,6 +370,41 @@
EXPECT_EQ(after_rule, before_rule);
}
+TEST_F(StyleRuleTest, SetPreludeTextOnDetachedNested) {
+ CSSStyleSheet* sheet = css_test_helpers::CreateStyleSheet(GetDocument());
+ sheet->SetText(R"CSS(
+ .a {
+ @scope (.b) { }
+ }
+ )CSS",
+ CSSImportRules::kIgnoreWithWarning);
+
+ DummyExceptionStateForTesting exception_state;
+ CSSRuleList* rules = sheet->rules(exception_state);
+ ASSERT_TRUE(rules && rules->length() == 1u);
+ auto* style_rule = DynamicTo<CSSStyleRule>(rules->item(0));
+ ASSERT_TRUE(style_rule);
+ ASSERT_EQ(1u, style_rule->length());
+ auto* scope_rule = DynamicTo<CSSScopeRule>(style_rule->ItemInternal(0));
+ ASSERT_TRUE(scope_rule);
+
+ StyleRuleScope* before = &scope_rule->GetStyleRuleScope();
+
+ // Detach wrappers from the stylesheet.
+ sheet->SetText("", CSSImportRules::kIgnoreWithWarning);
+
+ scope_rule->SetPreludeText(GetDocument().GetExecutionContext(), "(.c)");
+
+ // Setting the prelude text should have created a new StyleRuleScope.
+ StyleRuleScope* after = &scope_rule->GetStyleRuleScope();
+ EXPECT_NE(before, after);
+
+ // The child rule vector of the parent rule should also have been updated.
+ ASSERT_TRUE(style_rule->GetStyleRule());
+ ASSERT_TRUE(style_rule->GetStyleRule()->ChildRules());
+ EXPECT_EQ(after, (*style_rule->GetStyleRule()->ChildRules())[0]);
+}
+
TEST_F(StyleRuleTest, CloneStyleRule) {
auto* a = To<StyleRule>(css_test_helpers::ParseRule(GetDocument(), ".a {}"));
auto* b = To<StyleRule>(css_test_helpers::ParseRule(GetDocument(), ".b {}"));
Original Bug Report
Type confusion in CSSGroupingRule::Reattach leading to OOB read and memory corruption
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 security team.
Overview: A potential type confusion exists in Blink’s CSSOM implementation when reattaching CSS wrappers to cloned style rules by index. An attacker can desynchronize the wrapper list from the rule list, binding a CSSConditionRule wrapper to a StyleRuleLayerBlock object. Accessing the condition text reads an internal Vector as a StringImpl, leading to a heap information leak and a misaligned free that can potentially be exploited for remote code execution.
Affected files:
third_party/blink/renderer/core/css/css_grouping_rule.ccthird_party/blink/renderer/core/css/css_condition_rule.ccthird_party/blink/renderer/core/css/css_style_rule.ccthird_party/blink/renderer/core/css/style_rule.h
Estimated timestamp from git blame: 2026-03-05
Disclaimer
These are suggested/potential steps and analysis, as our tooling agent doesn’t yet have the ability to run code to verify a live exploit.
Summary
A potential type confusion vulnerability exists in Blink’s CSSOM implementation. When a parent rule’s selector is modified, its child rules are cloned, and CSSGroupingRule::Reattach is called to bind existing JavaScript wrappers to the newly cloned rule objects. This reattachment occurs by index. If the wrapper list and the rule list are intentionally desynchronized while the rule tree is detached from the document, an attacker can cause a CSSSupportsRule wrapper to be backed by a StyleRuleLayerBlock object.
Root Cause Analysis
In third_party/blink/renderer/core/css/css_grouping_rule.cc, the Reattach method validates that the new backing rule is a StyleRuleGroup using To<StyleRuleGroup>(rule). However, it lacks strict type validation to ensure the rule is of the exact subclass expected by the wrapper (e.g., StyleRuleCondition for a CSSSupportsRule).
void CSSGroupingRule::Reattach(StyleRuleBase* rule) {
DCHECK(rule);
group_rule_ = To<StyleRuleGroup>(rule);
for (unsigned i = 0; i < child_rule_cssom_wrappers_.size(); ++i) {
if (child_rule_cssom_wrappers_[i]) {
child_rule_cssom_wrappers_[i]->Reattach(
group_rule_->ChildRules()[i].Get());
}
}
}
When a CSS tree is detached (e.g., via replaceSync), mutating a rule (like changing .selectorText) attempts to replace it in the parent stylesheet using StyleSheetContents::ReplaceRuleIfExists. This fails for detached rules, leaving the parent with a stale pointer to the old rule. Meanwhile, mutating the child (e.g., insertRule) updates the wrapper list. Re-mutating the parent later forces a clone of the stale rule tree and a reattachment using the shifted wrapper list, causing a type confusion.
Exploitation Path (Suggested Steps)
An attacker could potentially trigger this via the following sequence:
- Setup: Create a nested CSS structure:
.a { .w { @supports (x:y) {} @layer foo.bar.baz { .z { color: red } } } }. Acquire CSSOM wrappers for.a,.w, and@supports. Critically, do not access the@layerrule so its wrapper initializes asnullptr. - Detach: Detach the rules by executing
replaceSync('div { color: blue }')on the stylesheet. - Desynchronize: Mutate
.wby executingw.selectorText = '.ww'. Because the tree is detached,.afails to update its reference and keeps the old.wbackend rule. Then, insert a rule into.w(w.insertRule('.r {}', 0)). This shifts the@supportswrapper to index 1 and expands the wrapper vector to size 3 ([nullptr, sup, nullptr]). - Trigger Type Confusion: Mutate
.a(a.selectorText = '.aa')..aclones the old.wbackend rule (which only has 2 children) and callsReattach().Reattach()iterates over the size-3 wrapper list. The@supportswrapper at index 1 is erroneously bound to the@layerbackend rule. Thenullptrat index 2 safely skips the loop, bypassing the fatalCHECK_LT(2, 2)vector bounds check. - Heap OOB Read (ASLR Defeat): The attacker evaluates
sup.conditionText. This invokesCSSConditionRule::ConditionTextInternal(), which casts theStyleRuleLayerBlockto aStyleRuleConditionand readscondition_text_(aString). This memory overlaps exactly withLayerName name_(aVector<AtomicString, 1>). TheStringImpl*pointer reads the Vector’s buffer pointer. The resulting fakelength_field overlaps with upper 32 bits of the first heap pointer in the layer name buffer. This massive fake length allows JavaScript to read thousands of bytes out-of-bounds from the PartitionAlloc heap. - Misaligned Free (Memory Corruption): During the
Stringconstruction,impl_->AddRef()is called, which increments the lower 32 bits of the firstAtomicStringpointer in the Vector buffer, permanently misaligning it. When Garbage Collection occurs, the Vector destructor callsRelease()on this corrupted pointer, leading to a misaligned free in PartitionAlloc, providing a powerful primitive for Remote Code Execution.
Proposed Fix
Add strict runtime type checking in CSSGroupingRule::Reattach to ensure the existing wrapper is compatible with the new underlying rule type before reattaching. If the types do not match (or perhaps simply if the rule type enums do not match), the wrapper should not be reattached or should be safely invalidated.
void CSSGroupingRule::Reattach(StyleRuleBase* rule) {
DCHECK(rule);
group_rule_ = To<StyleRuleGroup>(rule);
for (unsigned i = 0; i < child_rule_cssom_wrappers_.size(); ++i) {
if (child_rule_cssom_wrappers_[i]) {
// Check bounds to prevent OOB access if lists are desynced
if (i >= group_rule_->ChildRules().size()) {
child_rule_cssom_wrappers_[i]->SetParentRule(nullptr);
continue;
}
StyleRuleBase* child_rule = group_rule_->ChildRules()[i].Get();
// Validate type compatibility before reattaching
if (child_rule_cssom_wrappers_[i]->GetType() == child_rule->GetType()) {
child_rule_cssom_wrappers_[i]->Reattach(child_rule);
} else {
child_rule_cssom_wrappers_[i]->SetParentRule(nullptr);
// Optionally clear the wrapper or handle the mismatch safely
}
}
}
}
Additionally, investigate fixing the lifecycle of detached rules to ensure ReplaceRuleIfExists either succeeds on detached subtrees or prevents mutations when rules become permanently orphaned.
Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f
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.