Overview

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

Changed Functions

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

Files Changed

  • third_party/blink/renderer/core/css/css_counter_content_value.cc
  • third_party/blink/renderer/core/css/css_counter_value.cc
  • third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
  • third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
  • third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
  • third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
From 9dbc492937175529d97379c8474945256539faf9 Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <futhark@chromium.org>
Date: Tue, 19 May 2026 10:30:59 -0700
Subject: [PATCH] Escaped idents when serializing CSS counter names

There was missing escaping for idents when serializing counter names in:

- The counter-set/reset/increment property values
- The content property value

Bug: 514429130
Change-Id: Ia21161017622af951fb6895bb995af396489f04e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7859556
Reviewed-by: Daniil Sakhapov <sakhapov@chromium.org>
Commit-Queue: Rune Lillesveen <futhark@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1632982}
---

diff --git a/third_party/blink/renderer/core/css/css_counter_content_value.cc b/third_party/blink/renderer/core/css/css_counter_content_value.cc
index 48e13cd..0769edc0 100644
--- a/third_party/blink/renderer/core/css/css_counter_content_value.cc
+++ b/third_party/blink/renderer/core/css/css_counter_content_value.cc
@@ -19,7 +19,7 @@
     result.Append("counters(");
   }
 
-  result.Append(Identifier());
+  SerializeIdentifier(Identifier(), result);
   if (!Separator().empty()) {
     result.Append(", ");
     result.Append(separator_->CssText());
diff --git a/third_party/blink/renderer/core/css/css_counter_value.cc b/third_party/blink/renderer/core/css/css_counter_value.cc
index 3dbb9a7..3344d21 100644
--- a/third_party/blink/renderer/core/css/css_counter_value.cc
+++ b/third_party/blink/renderer/core/css/css_counter_value.cc
@@ -4,6 +4,7 @@
 
 #include "third_party/blink/renderer/core/css/css_counter_value.h"
 
+#include "third_party/blink/renderer/core/css/css_markup.h"
 #include "third_party/blink/renderer/core/css/css_primitive_value.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 
@@ -14,7 +15,7 @@
   if (is_reversed_) {
     result.Append("reversed(");
   }
-  result.Append(identifier_->Value());
+  SerializeIdentifier(identifier_->Value(), result);
   if (is_reversed_) {
     result.Append(')');
   }
diff --git a/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html b/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
index 097bf66..80bb54f 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
@@ -47,6 +47,9 @@
 test_valid_value_combinations("content", `"(" counters(counter-name, ".", counter-style) ")"`);
 test_valid_value_combinations("content", `open-quote "hello" "world" close-quote`);
 test_valid_value_combinations("content", `url("picture.svg") "hello"`);
+
+test_valid_value_combinations("content", "counter(\\})");
+test_valid_value_combinations("content", `counters(\\}, ".")`);
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
index 01876223..557290f 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
@@ -20,6 +20,7 @@
 test_valid_value('counter-increment', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-increment', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-increment', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-increment', 'a\\ 8 9');
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
index 4e46291..55c9f5e 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
@@ -26,6 +26,7 @@
 test_valid_value('counter-reset', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-reset', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-reset', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-reset', 'a\\ 8 9');
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
index f7f1c66..e985fca 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
@@ -20,6 +20,7 @@
 test_valid_value('counter-set', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-set', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-set', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-set', 'a\\ 8 9');
 </script>
 </body>
 </html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html b/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
index 097bf66..80bb54f 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-content/parsing/content-valid.html
@@ -47,6 +47,9 @@
 test_valid_value_combinations("content", `"(" counters(counter-name, ".", counter-style) ")"`);
 test_valid_value_combinations("content", `open-quote "hello" "world" close-quote`);
 test_valid_value_combinations("content", `url("picture.svg") "hello"`);
+
+test_valid_value_combinations("content", "counter(\\})");
+test_valid_value_combinations("content", `counters(\\}, ".")`);
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
index 01876223..557290f 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-increment-valid.html
@@ -20,6 +20,7 @@
 test_valid_value('counter-increment', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-increment', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-increment', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-increment', 'a\\ 8 9');
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
index 4e46291..55c9f5e 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-reset-valid.html
@@ -26,6 +26,7 @@
 test_valid_value('counter-reset', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-reset', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-reset', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-reset', 'a\\ 8 9');
 </script>
 </body>
 </html>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
index f7f1c66..e985fca 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-lists/parsing/counter-set-valid.html
@@ -20,6 +20,7 @@
 test_valid_value('counter-set', 'section calc(10 + (5 * sign(2cqw - 10px)))');
 test_valid_value('counter-set', 'a 1 b 2 c 3 d 4 e 5');
 test_valid_value('counter-set', 'a 1 b 2 c 3 a 4 b 5');
+test_valid_value('counter-set', 'a\\ 8 9');
 </script>
 </body>
 </html>
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential CSS Injection via unescaped identifiers in CSSCounterValue serialization

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: Blink’s CSSOM serialization for counter values fails to escape custom identifiers, returning raw, unescaped strings. If a web application reads and dynamically re-injects this serialized CSS, an attacker can smuggle structural characters to inject arbitrary CSS rules. This breaks the CSSOM round-trip invariant, creating a potential mutation XSS (mXSS) vulnerability.

Affected files:

  • third_party/blink/renderer/core/css/css_counter_value.cc
  • third_party/blink/renderer/core/css/css_counter_content_value.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Overview

The CSSOM serialization logic for CSSCounterValue and CSSCounterContentValue in Blink’s CSS engine contains a potential vulnerability that can lead to CSS injection, often referred to as mutation XSS (mXSS).

Specifically, the CustomCSSText() methods in these classes append the raw, decoded identifier string to the serialization buffer instead of its properly escaped CSS representation. This allows structural CSS characters (like ;, {, and }) that were originally safely escaped in the input to be serialized as literal characters, breaking the CSSOM round-trip invariant.

Technical Details

In third_party/blink/renderer/core/css/css_counter_value.cc:

String CSSCounterValue::CustomCSSText() const {
  StringBuilder result;
  // ...
  result.Append(identifier_->Value()); // Vulnerability: appends raw, unescaped string
  // ...
}

A parallel issue exists in third_party/blink/renderer/core/css/css_counter_content_value.cc:

String CSSCounterContentValue::CustomCSSText() const {
  // ...
  result.Append(Identifier()); // Identifier() returns identifier_->Value()
  // ...
}

The identifier_ member is a CSSCustomIdentValue. During parsing, any CSS escape sequences (e.g., \3b for ;) are decoded, and the literal string is stored. When CSSCustomIdentValue::CustomCSSText() is called, it correctly uses SerializeIdentifier to re-escape these characters. However, the counter value classes bypass this and call Value(), directly embedding the unescaped string into the output.

Potential Attack Scenario

(Note: These are potential steps to trigger the issue, as our tooling agent does not run active code execution to provide a working PoC.)

  1. Injection: An attacker provides a payload where a safe CSS property value contains escaped structural characters. For example: counter-reset: \3b \7d * \7b color: red; \7d 1; (Decodes to: ; } * { color: red; } 1;)
  2. Parsing: The browser parses this safely. The tokenizer decodes the escapes into a single identifier token, isolating the attacker’s payload as a valid counter name.
  3. Serialization: A benign script reads the CSS text via the CSSOM (e.g., element.style.cssText or element.style.counterReset). Due to the bug, the browser serializes this as: counter-reset: ; } * { color: red; } 1;
  4. Re-parsing / Execution: The application dynamically injects this serialized string back into the DOM (a common pattern in CSS-in-JS libraries, SSR rehydration, or HTML sanitizers). Because the output is no longer escaped, the literal ; prematurely terminates the counter-reset declaration, the } closes the current rule block, and * { color: red; } is evaluated as a completely new, attacker-controlled CSS rule.

This grants the attacker the ability to inject arbitrary CSS, which can be leveraged for UI spoofing or data exfiltration via CSS-based side channels.

Suggested Fix

Update the CustomCSSText() implementations in both affected classes to rely on the underlying identifier’s safe serialization method rather than extracting the raw value.

For CSSCounterValue: Change result.Append(identifier_->Value()); to result.Append(identifier_->CustomCSSText());

For CSSCounterContentValue: Change result.Append(Identifier()); to result.Append(identifier_->CustomCSSText());

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