Low watch CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in CSS
DescriptionType Confusion in CSS
ComponentCSS
Bug ClassType Confusion
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker490023239
Fix commitdff61fda8560 (chromium/src) +77/-1
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSyn4pse
Reported2026-03-05 (per vendor advisory)
Fix landed2026-03-06 (public git)
Disclosed2026-04-07
Patch gap32 days — fix public in source before the advisory

Background

`@scope` rule / `CSSScopeRule`
the CSSOM wrapper object exposed to script and devtools for a CSS @scope at-rule; it holds a pointer (group_rule_) to the backing StyleRuleScope.
`StyleRuleScope` / `StyleSheetContents`
the internal, parsed representation of a @scope rule 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 @scope rule’s prelude and rebuilds its backing rule.
CSSOM/StyleRule desync
a state where the wrapper’s group_rule_ points at a freshly built StyleRuleScope while 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.

Key insight
The single mistake was updating only the CSSOM wrapper’s 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

  1. Obtain devtools access The operation is reachable only via devtools, including an extension holding devtools permissions (a precondition the commit states explicitly).
  2. Edit a nested `@scope` prelude Call CSS.setScopeText on a @scope rule nested inside another rule (PoC: change (.a) to (.b) on .outer { @scope (.a) { ... } }).
  3. Create the desync Pre-patch, a new StyleRuleScope is bound to the wrapper’s group_rule_ while StyleSheetContents keeps the old rule, so wrapper and stylesheet now disagree.
  4. Mutate the rule further Script then manipulates the scope and its children (deleteRule/insertRule, nested @media.insertRule, selectorText change) and forces style recalc, driving operations through the mismatched object.
  5. 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 (via To<>), so the live outcome is a controlled crash rather than silent confusion.

Impact Assessment

An actor with devtools access (or a devtools-permissioned extension) can desynchronize a @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.cc
  • third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach-expected.txt
  • third_party/blink/web_tests/inspector-protocol/css/css-set-scope-text-reattach.js

Audit Directions

  • Wrapper-only mutation of backing rules
    Audit 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 the StyleSheetContents rule tree.
  • Nested grouping rules
    Focus 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 signals
    Treat 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.
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;
+})()
+`);
+    },
+  ]);
+})
Loading diff…

Regression Test / PoC

shipped with the fix
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;
+})()
+`);
+    },
+  ]);
+})
Loading diff…

Original Bug Report

reported by [email protected]

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>).

View on issue tracker