CVE-2026-5914
Overview
Background
- `@scope` rule / `CSSScopeRule`
- the CSSOM wrapper object exposed to script and devtools for a CSS
@scopeat-rule; it holds a pointer (group_rule_) to the backingStyleRuleScope. - `StyleRuleScope` / `StyleSheetContents`
- the internal, parsed representation of a
@scoperule and the stylesheet’s rule list that actually drives style resolution, distinct from the CSSOM wrapper. - `setScopeText` (CSS.setScopeText)
- a DevTools-protocol operation (reachable by devtools and extensions with devtools permissions) that re-parses a
@scoperule’s prelude and rebuilds its backing rule. - CSSOM/StyleRule desync
- a state where the wrapper’s
group_rule_points at a freshly builtStyleRuleScopewhile the stylesheet’s rule tree still references the old one, so the two views disagree about which object is live.
Root Cause Analysis
Handling setScopeText on a @scope rule, especially one nested inside another rule, rebuilt the backing scope by assigning a new StyleRuleScope to the wrapper’s group_rule_ but never replaced the corresponding rule inside the owning StyleSheetContents. As a result the CSSOM wrapper and the actual stylesheet rule tree pointed at different objects, violating the invariant that a wrapper and its backing StyleRule stay in sync. Subsequent script/devtools operations that modify the StyleRule further (the PoC does deleteRule/insertRule on the scope, then insertRule on a nested @media, plus selectorText changes and a style recalc) would then operate through a mismatched object, which the commit describes as type confusion (previously hardened to a CHECK failure by switching a static_cast to To<>).
The fix adds parentStyleSheet()->Contents()->ReplaceRuleIfExists(group_rule_, new_group_rule, /*position_hint=*/0) to swap the old rule for the new one in the stylesheet, then calls Reattach(new_group_rule) so the wrapper and the stylesheet reference the same StyleRuleScope, restoring the invariant.
group_rule_ on setScopeText while leaving the stale StyleRuleScope in the StyleSheetContents, letting later mutations confuse the two; the fix replaces the rule in the stylesheet (ReplaceRuleIfExists) and reattaches the wrapper so both point at the new object.Attack Path
- Obtain devtools access The operation is reachable only via devtools, including an extension holding devtools permissions (a precondition the commit states explicitly).
- Edit a nested `@scope` prelude
Call
CSS.setScopeTexton a@scoperule nested inside another rule (PoC: change(.a)to(.b)on.outer { @scope (.a) { ... } }). - Create the desync
Pre-patch, a new
StyleRuleScopeis bound to the wrapper’sgroup_rule_whileStyleSheetContentskeeps the old rule, so wrapper and stylesheet now disagree. - Mutate the rule further
Script then manipulates the scope and its children (
deleteRule/insertRule, nested@media.insertRule,selectorTextchange) and forces style recalc, driving operations through the mismatched object. - Type confusion
The mismatched object is treated as the wrong type during further modification; in the patched tree this was already hardened to a
CHECK(viaTo<>), so the live outcome is a controlled crash rather than silent confusion.
Impact Assessment
@scope rule’s CSSOM wrapper from its backing StyleRuleScope and trigger type confusion when the rule is modified further, in the renderer process. The vendor advisory rates this low severity as type confusion in CSS, with no reported in-the-wild exploitation and a notable precondition that setScopeText is only available to devtools/extensions-with-devtools-permissions. A prior CL had already hardened the confusion point from a static_cast to To<>, so it manifests as a CHECK failure; any memory-corruption or code-execution escalation beyond that is (inferred) and not demonstrated by this diff.Files Changed
third_party/blink/renderer/core/css/css_scope_rule.ccthird_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txtthird_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js
Audit Directions
- Wrapper-only mutation of backing rulesAudit other CSSOM edit operations (selector/prelude/condition setters on grouping at-rules like
@media,@supports,@scope,@container) to confirm they update both the wrapper pointer and theStyleSheetContentsrule tree. - Nested grouping rulesFocus on rules nested inside other rules, where reparenting/reattachment is easy to miss; verify
ReplaceRuleIfExists+Reattach(or equivalent) runs on every rebuild path. - Hardened casts as signalsTreat recent
static_cast→To<>hardening around a type as a marker of a suspected confusion site, and audit all producers of that object for stale/duplicated references.
Patch
From dff61fda8560b68af87c699fa2ef4345b0ed2cce Mon Sep 17 00:00:00 2001 From: Steinar H. Gunderson <[email protected]> Date: Fri, 06 Mar 2026 04:42:22 -0800 Subject: [PATCH] Fix CSSOM wrapper desync on setScopeText(). If setScopeText() was called on a @scope rule, especially a nested scope rule, we'd only update the CSSOM wrapper, not the actual StyleRule in the style sheet. This could cause type confusion when modifying the StyleRule further. (This was hardened to a CHECK failure in the previous CL related to this bug, changing to To<> instead of static_cast.) setScopeText() is only available to devtools (including extensions with devtools permissions). Fixed: 490023239 Change-Id: I3c64ede444c89144126acb75d854b9ac3b23d0ed Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7641210 Commit-Queue: Steinar H Gunderson <[email protected]> Reviewed-by: Anders Hartvoll Ruud <[email protected]> Cr-Commit-Position: refs/heads/main@{#1595323} --- 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 87e0758..2a99fc5 100644 --- a/third_party/blink/renderer/core/css/css_scope_rule.cc +++ b/third_party/blink/renderer/core/css/css_scope_rule.cc @@ -9,6 +9,7 @@ #include "third_party/blink/renderer/core/css/css_style_sheet.h" #include "third_party/blink/renderer/core/css/parser/css_parser_context.h" #include "third_party/blink/renderer/core/css/style_rule.h" +#include "third_party/blink/renderer/core/css/style_sheet_contents.h" #include "third_party/blink/renderer/platform/wtf/text/string_builder.h" namespace blink { @@ -113,8 +114,16 @@ child_rule->Clone(new_style_scope->RuleForNesting(), /*mixin_parameter_bindings=*/nullptr)); } - group_rule_ = MakeGarbageCollected<StyleRuleScope>( + 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); + } + + Reattach(new_group_rule); } StyleRuleScope& CSSScopeRule::GetStyleRuleScope() { diff --git a/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt new file mode 100644 index 0000000..829cb80 --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt @@ -0,0 +1,12 @@ +Tests CSS.setScopeText with scope nested in a rule. + +Running test: testNestedEdit +==== Style sheet text ==== + + .outer { + @scope (.b) { + .inner { color: red; } + } + } + + diff --git a/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js new file mode 100644 index 0000000..3c75080f --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js @@ -0,0 +1,55 @@ +/* https://crbug.com/490023239 */ +(async function(/** @type {import('test_runner').TestRunner} */ testRunner) { + const {session, dp} = await testRunner.startHTML(` +<!DOCTYPE html> +<style> + .outer { + @scope (.a) { + .inner { color: red; } + } + } +</style> +<div class="outer"><div class="inner" id="t">x</div></div>`, 'Tests CSS.setScopeText with scope nested in a rule.'); + + const CSSHelper = await testRunner.loadScript('../resources/css-helper.js'); + const cssHelper = new CSSHelper(testRunner, dp); + + dp.DOM.enable(); + dp.CSS.enable(); + + const event = await dp.CSS.onceStyleSheetAdded(); + const styleSheetId = event.params.header.styleSheetId; + const setScopeText = cssHelper.setScopeText.bind(cssHelper, styleSheetId, false); + + testRunner.runTestSuite([ + async function testNestedEdit() { + await setScopeText({ + range: { + startLine: 2, + startColumn: 11, + endLine: 2, + endColumn: 15, + }, + text: '(.b)', + }); + + await session.evaluateAsync(` +(() => { + const sheet = document.styleSheets[0]; + const outer = sheet.cssRules[0]; + const scope = outer.cssRules[0]; + + scope.deleteRule(0); + scope.insertRule('@media all {}', 0); + const media = scope.cssRules[0]; + + outer.selectorText = '.outer2'; + media.insertRule('.boom {}', 0); + + getComputedStyle(document.getElementById('t')).color; + document.body.offsetTop; +})() +`); + }, + ]); +})
Regression Test / PoC
diff --git a/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt
new file mode 100644
index 0000000..829cb80
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt
@@ -0,0 +1,12 @@
+Tests CSS.setScopeText with scope nested in a rule.
+
+Running test: testNestedEdit
+==== Style sheet text ====
+
+ .outer {
+ @scope (.b) {
+ .inner { color: red; }
+ }
+ }
+
+
diff --git a/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js
new file mode 100644
index 0000000..3c75080f
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js
@@ -0,0 +1,55 @@
+/* https://crbug.com/490023239 */
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+ const {session, dp} = await testRunner.startHTML(`
+<!DOCTYPE html>
+<style>
+ .outer {
+ @scope (.a) {
+ .inner { color: red; }
+ }
+ }
+</style>
+<div class="outer"><div class="inner" id="t">x</div></div>`, 'Tests CSS.setScopeText with scope nested in a rule.');
+
+ const CSSHelper = await testRunner.loadScript('../resources/css-helper.js');
+ const cssHelper = new CSSHelper(testRunner, dp);
+
+ dp.DOM.enable();
+ dp.CSS.enable();
+
+ const event = await dp.CSS.onceStyleSheetAdded();
+ const styleSheetId = event.params.header.styleSheetId;
+ const setScopeText = cssHelper.setScopeText.bind(cssHelper, styleSheetId, false);
+
+ testRunner.runTestSuite([
+ async function testNestedEdit() {
+ await setScopeText({
+ range: {
+ startLine: 2,
+ startColumn: 11,
+ endLine: 2,
+ endColumn: 15,
+ },
+ text: '(.b)',
+ });
+
+ await session.evaluateAsync(`
+(() => {
+ const sheet = document.styleSheets[0];
+ const outer = sheet.cssRules[0];
+ const scope = outer.cssRules[0];
+
+ scope.deleteRule(0);
+ scope.insertRule('@media all {}', 0);
+ const media = scope.cssRules[0];
+
+ outer.selectorText = '.outer2';
+ media.insertRule('.boom {}', 0);
+
+ getComputedStyle(document.getElementById('t')).color;
+ document.body.offsetTop;
+})()
+`);
+ },
+ ]);
+})
Original Bug Report
Type confusion in InspectorCSSAgent::setScopeText
Summary
CSS.setScopeText (via InspectorCSSAgent::setScopeText) desynchronizes nested @scope wrappers: CSSScopeRule::SetPreludeText mutates only wrapper-local group_rule_, and later index-based reattach calls CSSGroupingRule::Reattach with a non-group StyleRule*, leading to type confusion and the memory corruption.
> NOTE: this is a resubmission of the report 488270255, since the previous issue is repurposed.
Details
CSS.setScopeText edits the prelude of an existing @scope rule by calling InspectorStyleSheet::SetScopeRuleText, which locates the existing CSSScopeRule wrapper and invokes CSSScopeRule::SetPreludeText.
The key problem is that CSSScopeRule::SetPreludeText replaces the wrapper’s internal group_rule_ pointer with a newly allocated StyleRuleScope, but does not replace the corresponding StyleRuleScope inside the stylesheet’s rule graph. This means the wrapper becomes detached from the real sheet contents, yet still participates in subsequent CSSOM calls and later Reattach() cascades (which assume wrapper/rule index+type stability).
In CSSScopeRule::SetPreludeText, the mutation ends by assigning a new rule object only to the wrapper field:
HeapVector<Member<StyleRuleBase>> new_child_rules;
new_child_rules.ReserveInitialCapacity(
GetStyleRuleScope().ChildRules().size());
for (StyleRuleBase* child_rule : GetStyleRuleScope().ChildRules()) {
new_child_rules.push_back(
child_rule->Clone(new_style_scope->RuleForNesting(),
/*mixin_parameter_bindings=*/nullptr));
}
group_rule_ = MakeGarbageCollected<StyleRuleScope>(
*new_style_scope, std::move(new_child_rules));
After this, page JS can mutate the stale CSSScopeRule wrapper (e.g. deleteRule() then insertRule('@media all {}', 0)) such that the wrapper’s child_rule_cssom_wrappers_ at index 0 becomes a CSSMediaRule, while the stylesheet-backed @scope still has a style rule at index 0.
Later, a parent rule mutation that performs a replacement+reattach (e.g. CSSStyleRule::setSelectorText) reattaches nested wrappers by index. During that reattach, the stale scope wrapper forwards ChildRules()[0] (a StyleRule*) into the CSSMediaRule wrapper’s Reattach(). CSSMediaRule inherits CSSGroupingRule::Reattach, which performs an unchecked cast:
In CSSGroupingRule::Reattach, the downcast is a static_cast without runtime validation:
void CSSGroupingRule::Reattach(StyleRuleBase* rule) {
DCHECK(rule);
group_rule_ = static_cast<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());
}
}
}
Once the wrapper’s group_rule_ is miscast to a StyleRuleGroup*, subsequent CSSOM operations on that wrapper (notably insertRule) can write through the misinterpreted layout, corrupting the real StyleRule object fields. A reliable symptom is that later style resolution (RuleSet construction) crashes in RuleSet::AddStyleRule/AddChildRules while iterating nested rules, consistent with a corrupted StyleRule::ChildRules() pointer.
Bisection
This issue is introduced by the commit: ed46557e198a8fce6c1ef52b38e5c17734c99ba9 [css-nesting] Implement CSSScopeRule::SetPreludeText by rule replacement.
Reproduction
Download the chrome from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1591355.zip
Run the chrome with the attached extension:
./chrome --load-extension=/path/to/ext --no-sandbox
You would observe the ASAN stack trace shown in the asan.txt
Suggested Fix
Harden CSSGroupingRule::Reattach against type mismatches by validating rule is a StyleRuleGroup before assigning it to group_rule_ (e.g. DynamicTo<StyleRuleGroup>).
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/css_grouping_rule.cc;l=363
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/css_scope_rule.cc;l=69
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/css_style_rule.cc;l=87
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_css_agent.cc;l=3092
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_style_sheet.cc;l=1532
- https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1591355.zip