Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CSS
DescriptionInappropriate implementation in CSS
ComponentCSS
Bug ClassLogic Error
Tracker514445398
Fix commitca29db2f56d3 (chromium/src) +154/-23
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
modified

Files Changed

  • third_party/blink/renderer/core/css/css_font_feature_values_map.h
  • third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
  • third_party/blink/renderer/core/css/css_font_feature_values_rule.h
  • third_party/blink/renderer/core/css/css_font_feature_values_rule.idl
  • third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html
From ca29db2f56d3ccefd0820157a1bbd5b4d35fca77 Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <[email protected]>
Date: Wed, 27 May 2026 03:24:10 -0700
Subject: [PATCH] Make readonly attributes on CSSFontFeatureValuesRule [SameObject]

This is based on the proposal in [1] and makes it straightforward to
keep the CSSOM wrappers work for setting/getting including wrapper
reattachments that happen when shared stylesheets are decoupled on
modifications.

[1] https://github.com/w3c/csswg-drafts/issues/13953

Bug: 514445398, 515494291
Change-Id: I5436e4feeff3935f1999630d775ad34a8a26194f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7865910
Reviewed-by: Dominik Röttsches <[email protected]>
Commit-Queue: Rune Lillesveen <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1636821}
---

diff --git a/third_party/blink/renderer/core/css/css_font_feature_values_map.h b/third_party/blink/renderer/core/css/css_font_feature_values_map.h
index fa15bd43..ea55b11bb 100644
--- a/third_party/blink/renderer/core/css/css_font_feature_values_map.h
+++ b/third_party/blink/renderer/core/css/css_font_feature_values_map.h
@@ -37,6 +37,12 @@
   CSSFontFeatureValuesMap(const CSSFontFeatureValuesMap&) = delete;
   CSSFontFeatureValuesMap& operator=(const CSSFontFeatureValuesMap&) = delete;
 
+  void Reattach(StyleRuleFontFeatureValues* style_rule_font_feature_values,
+                FontFeatureAliases* aliases) {
+    backing_style_rule_ = style_rule_font_feature_values;
+    aliases_ = aliases;
+  }
+
   // IDL attributes / methods
   uint32_t size() const;
 
diff --git a/third_party/blink/renderer/core/css/css_font_feature_values_rule.cc b/third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
index 099503b..0611419 100644
--- a/third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
+++ b/third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
@@ -42,33 +42,51 @@
 }
 
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::annotation() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_,
-      font_feature_values_rule_->GetAnnotation());
+  if (!annotation_) {
+    annotation_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_,
+        font_feature_values_rule_->GetAnnotation());
+  }
+  return annotation_.Get();
 }
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::ornaments() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_,
-      font_feature_values_rule_->GetOrnaments());
+  if (!ornaments_) {
+    ornaments_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_,
+        font_feature_values_rule_->GetOrnaments());
+  }
+  return ornaments_.Get();
 }
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::stylistic() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_,
-      font_feature_values_rule_->GetStylistic());
+  if (!stylistic_) {
+    stylistic_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_,
+        font_feature_values_rule_->GetStylistic());
+  }
+  return stylistic_.Get();
 }
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::swash() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_, font_feature_values_rule_->GetSwash());
+  if (!swash_) {
+    swash_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_, font_feature_values_rule_->GetSwash());
+  }
+  return swash_.Get();
 }
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::characterVariant() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_,
-      font_feature_values_rule_->GetCharacterVariant());
+  if (!character_variant_) {
+    character_variant_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_,
+        font_feature_values_rule_->GetCharacterVariant());
+  }
+  return character_variant_.Get();
 }
 CSSFontFeatureValuesMap* CSSFontFeatureValuesRule::styleset() {
-  return MakeGarbageCollected<CSSFontFeatureValuesMap>(
-      this, font_feature_values_rule_,
-      font_feature_values_rule_->GetStyleset());
+  if (!styleset_) {
+    styleset_ = MakeGarbageCollected<CSSFontFeatureValuesMap>(
+        this, font_feature_values_rule_,
+        font_feature_values_rule_->GetStyleset());
+  }
+  return styleset_.Get();
 }
 
 String CSSFontFeatureValuesRule::cssText() const {
@@ -115,10 +133,41 @@
 void CSSFontFeatureValuesRule::Reattach(StyleRuleBase* rule) {
   DCHECK(rule);
   font_feature_values_rule_ = To<StyleRuleFontFeatureValues>(rule);
+  if (annotation_) {
+    annotation_->Reattach(font_feature_values_rule_.Get(),
+                          font_feature_values_rule_->GetAnnotation());
+  }
+  if (ornaments_) {
+    ornaments_->Reattach(font_feature_values_rule_.Get(),
+                         font_feature_values_rule_->GetOrnaments());
+  }
+  if (stylistic_) {
+    stylistic_->Reattach(font_feature_values_rule_.Get(),
+                         font_feature_values_rule_->GetStylistic());
+  }
+  if (swash_) {
+    swash_->Reattach(font_feature_values_rule_.Get(),
+                     font_feature_values_rule_->GetSwash());
+  }
+  if (character_variant_) {
+    character_variant_->Reattach(
+        font_feature_values_rule_.Get(),
+        font_feature_values_rule_->GetCharacterVariant());
+  }
+  if (styleset_) {
+    styleset_->Reattach(font_feature_values_rule_.Get(),
+                        font_feature_values_rule_->GetStyleset());
+  }
 }
 
 void CSSFontFeatureValuesRule::Trace(blink::Visitor* visitor) const {
   visitor->Trace(font_feature_values_rule_);
+  visitor->Trace(annotation_);
+  visitor->Trace(ornaments_);
+  visitor->Trace(stylistic_);
+  visitor->Trace(swash_);
+  visitor->Trace(character_variant_);
+  visitor->Trace(styleset_);
   CSSRule::Trace(visitor);
 }
 
diff --git a/third_party/blink/renderer/core/css/css_font_feature_values_rule.h b/third_party/blink/renderer/core/css/css_font_feature_values_rule.h
index ae054f3..9cf308e 100644
--- a/third_party/blink/renderer/core/css/css_font_feature_values_rule.h
+++ b/third_party/blink/renderer/core/css/css_font_feature_values_rule.h
@@ -43,6 +43,12 @@
   CSSRule::Type GetType() const override { return kFontFeatureValuesRule; }
 
   Member<StyleRuleFontFeatureValues> font_feature_values_rule_;
+  Member<CSSFontFeatureValuesMap> annotation_;
+  Member<CSSFontFeatureValuesMap> ornaments_;
+  Member<CSSFontFeatureValuesMap> stylistic_;
+  Member<CSSFontFeatureValuesMap> swash_;
+  Member<CSSFontFeatureValuesMap> character_variant_;
+  Member<CSSFontFeatureValuesMap> styleset_;
 };
 
 template <>
diff --git a/third_party/blink/renderer/core/css/css_font_feature_values_rule.idl b/third_party/blink/renderer/core/css/css_font_feature_values_rule.idl
index 436f78d..4ecacc2 100644
--- a/third_party/blink/renderer/core/css/css_font_feature_values_rule.idl
+++ b/third_party/blink/renderer/core/css/css_font_feature_values_rule.idl
@@ -7,12 +7,12 @@
 interface CSSFontFeatureValuesRule : CSSRule {
   attribute CSSOMString fontFamily;
 
-  readonly attribute CSSFontFeatureValuesMap annotation;
-  readonly attribute CSSFontFeatureValuesMap ornaments;
-  readonly attribute CSSFontFeatureValuesMap stylistic;
-  readonly attribute CSSFontFeatureValuesMap swash;
-  readonly attribute CSSFontFeatureValuesMap characterVariant;
-  readonly attribute CSSFontFeatureValuesMap styleset;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap annotation;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap ornaments;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap stylistic;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap swash;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap characterVariant;
+  [SameObject] readonly attribute CSSFontFeatureValuesMap styleset;
 };
 
 interface CSSFontFeatureValuesMap {
diff --git a/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html
new file mode 100644
index 0000000..ed73676
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<title>CSS Fonts Test: CSSFontFeatureValuesMap is live and reflect changes</title>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html
new file mode 100644
index 0000000..ed73676
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-map-live.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<title>CSS Fonts Test: CSSFontFeatureValuesMap is live and reflect changes</title>
+<link rel="help" href="https://drafts.csswg.org/css-fonts/#cssfontfeaturevaluesmap">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+  @font-feature-values MyFamily {
+    @annotation { black-boxed: 3 }
+  }
+</style>
+<!-- Two identical stylesheets exposes a stylesheet sharing issue in Chrome -->
+<style>
+  @font-feature-values MyFamily {
+    @annotation { circled: 1; black-boxed: 3; }
+  }
+</style>
+<script>
+  test(() => {
+    const a1 = document.styleSheets[0].cssRules[0].annotation;
+    a1.set("black-boxed", 42);
+    const a2 = document.styleSheets[0].cssRules[0].annotation;
+    assert_array_equals(a1.get("black-boxed"), [42]);
+    assert_array_equals(a2.get("black-boxed"), [42]);
+  }, "Two retreived maps for CSSFontFeatureValuesRule.annotation should reflect the same values");
+
+  test(() => {
+    assert_array_equals(document.styleSheets[1].cssRules[0].annotation.get("black-boxed"), [3]);
+  }, "The second stylesheet should not be affected by modifications in the first");
+
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-sameobject.tentative.html b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-sameobject.tentative.html
new file mode 100644
index 0000000..375f955
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-fonts/font-feature-values-sameobject.tentative.html
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<title>CSS Fonts Test: CSSFontFeatureValuesRule SameObject test</title>
+<link rel="help" href="https://drafts.csswg.org/css-fonts/#om-fontfeaturevalues">
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/13953">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style id="style">
+  @font-feature-values TestFont {
+    @annotation { a: 1; }
+    @ornaments { b: 2; }
+    @stylistic { c: 3; }
+    @swash { d: 4; }
+    @character-variant { e: 5; }
+    @styleset { f: 6; }
+  }
+</style>
+<script>
+  test(() => {
+    const style = document.getElementById('style');
+    const rule = style.sheet.cssRules[0];
+    assert_true(rule instanceof CSSFontFeatureValuesRule, "Should be CSSFontFeatureValuesRule");
+
+    const attributes = [
+      'annotation',
+      'ornaments',
+      'stylistic',
+      'swash',
+      'characterVariant',
+      'styleset'
+    ];
+
+    for (const attr of attributes) {
+      const map1 = rule[attr];
+      const map2 = rule[attr];
+      assert_equals(map1, map2, `${attr} should return the same object`);
+    }
+  }, "CSSFontFeatureValuesMap attributes in CSSFontFeatureValuesRule should be [SameObject]");
+
+</script>
+
Loading diff…

Original Bug Report

reported by [email protected]

Potential same-process CSS poisoning via broken Copy-On-Write in CSSPositionTryRule and others

Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Multiple CSS rule types fail to properly reattach their CSSOM wrappers during Copy-On-Write (COW) forks and rely on shallow copying during rule cloning. This can allow an attacker to bypass immutability and poison shared, cached stylesheets in the MemoryCache. Consequently, an attacker could potentially inject malicious styles into cross-origin documents sharing the same renderer process.

Affected files:

  • third_party/blink/renderer/core/css/css_position_try_rule.cc
  • third_party/blink/renderer/core/css/css_font_feature_values_rule.cc
  • third_party/blink/renderer/core/css/css_property_rule.cc
  • third_party/blink/renderer/core/css/css_counter_style_rule.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

There is a logic flaw in Blink’s CSS Copy-On-Write (COW) mechanism affecting several CSS rule types, most notably @position-try (CSSPositionTryRule) and @font-feature-values (CSSFontFeatureValuesRule). When a shared, cached stylesheet is mutated, Blink forks the stylesheet to keep the cached version immutable. However, due to shallow copying of internal rule properties and a failure to reattach CSSOM wrappers to the newly cloned rules, an attacker can maintain a reference to the shared internal property sets and arbitrarily mutate them. This leads to same-process cross-origin CSS poisoning.

Root Cause Analysis

When a script mutates a CORS-enabled shared stylesheet, a COW fork is initiated via CSSStyleSheet::WillMutateRules() -> StyleSheetContents::Copy().

There are two combined flaws that enable this vulnerability:

  1. Shallow Copying during Rule Cloning: During StyleSheetContents::Copy(), internal rules are cloned via StyleRuleBase::Clone(). For rule types like StyleRulePositionTry, the implicit default copy constructor is used (StyleRulePositionTry(const StyleRulePositionTry&) = default;). If an attacker previously accessed the .style property, MutableProperties() is called, making the underlying properties_ set mutable. The default copy constructor performs a shallow copy of the Member<CSSPropertyValueSet> properties_ pointer. As a result, both the original rule in the MemoryCache and the new cloned rule point to the exact same MutableCSSPropertyValueSet.

  2. Missing CSSOM Wrapper Reattachment: After cloning, CSSStyleSheet::ReattachChildRuleCSSOMWrappers() delegates to each rule’s Reattach() method to update its internal pointers to the newly cloned rule. While safe rules like CSSStyleRule correctly reattach their child properties (properties_cssom_wrapper_->Reattach(...)), CSSPositionTryRule::Reattach() and others completely omit this step. Therefore, the CSSPositionTryDescriptors CSSOM wrapper retains a stale property_set_ pointer referencing the shared, now-mutable MutableCSSPropertyValueSet.

Subsequent mutations through the attacker’s CSSOM wrapper bypass the COW protections and directly modify the shared MutableCSSPropertyValueSet. Any other document in the same renderer process (e.g., same-site origins, or any origin on Android where site isolation is relaxed) loading the same cached stylesheet will receive the attacker’s injected styles.

Potential Exploitation Steps

(Note: These are suggested steps; our tooling agent does not yet have the ability to run code to confirm a working Proof of Concept).

  1. An attacker page embeds a common, cross-origin CORS-enabled stylesheet (e.g., a popular CSS framework from a CDN).
  2. Using JavaScript, the attacker retrieves the CSSPositionTryRule from the stylesheet’s cssRules list.
  3. The attacker accesses the .style property (let s = rule.style;), which instantiates the CSSOM wrapper and forces the underlying property set to become mutable.
  4. The attacker mutates the style (e.g., s.marginTop = "100px";).
  5. This mutation triggers a COW fork. Because of the shallow copy, the MemoryCache’s stylesheet and the attacker’s stylesheet share the mutable property set.
  6. Because the CSSOM wrapper fails to reattach, subsequent modifications via s directly poison the shared property set in the MemoryCache.
  7. A victim navigating to a different origin in the same renderer process that relies on the same framework will have the poisoned CSS applied, potentially leading to UI spoofing or data exfiltration (e.g., using background-image requests to steal typed passwords).

Suggested Fix

  1. Fix Reattachment: Update CSSPositionTryRule::Reattach, CSSFontFeatureValuesRule::Reattach, CSSPropertyRule::Reattach, and CSSCounterStyleRule::Reattach to properly call Reattach() on their respective inner CSSOM wrappers (e.g., properties_cssom_wrapper_->Reattach(...)).
  2. Fix Shallow Copying: In StyleRuleBase::Clone, update the instantiation of affected rule types to safely handle properties, similar to StyleRule::Create. Instead of using default copy constructors, use Properties().ImmutableCopyIfNeeded() to ensure that the cloned rule does not inadvertently share a mutable property set with the cached original.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker