Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in CSS
DescriptionOut of bounds read in CSS
ComponentCSS
Bug ClassOOB
Tracker488188166
Fix commit2bfa338165ee (chromium/src) +84/-8
CISA KEVNot listed
CreditedSyn4pse
Disclosed2026-03-23

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
From 2bfa338165eef94983c6cd35e281450d994d2215 Mon Sep 17 00:00:00 2001
From: Steinar H. Gunderson <[email protected]>
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 <[email protected]>
Reviewed-by: Anders Hartvoll Ruud <[email protected]>
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 [email protected]

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