CVE-2026-3541
Overview
Background
- `FindVariableName`
- The Typed OM helper in
css_unparsed_value.ccthat reads the variable name identifier following avar(function token in aCSSUnparsedValue. - `CSSParserToken`
- A compact CSS parser token whose
value_length_and value storage are only meaningful for token types (likekIdentToken) that actually carry a string value. - `CSSUnparsedValue`
- A CSS Typed OM object that models an unparsed CSS value (including
var()references) and can be constructed from author-supplied strings via JavaScript. - `kIdentToken`
- The
CSSParserTokenTypeenum value marking an identifier token, the only token type for which avar()variable nameValue()is valid.
Root Cause Analysis
CSSUnparsedValue containing a var( function, FindVariableName consumed the token after var( and immediately called stream.Consume().Value().ToString() without verifying the token was actually an identifier. The invariant violated is that CSSParserToken::Value() may only be called on tokens whose type carries value storage; for a token like , (as in var(,)), value_length_ and the value pointer were never initialized, so Value() read uninitialized memory. The primary fix guards the read with a stream.Peek().GetType() == CSSParserTokenType::kIdentToken check and returns an empty String otherwise, so Value() is only reached on a token that legitimately holds a string. Defense in depth also hardens CSSParserToken itself: value_length_ is now explicitly zero-initialized in the constructor, a has_value_ bit plus a DCHECK in Value() catch invalid calls in debug builds, and callers up the chain (VariableReferenceValue, ParserTokenStreamToTokens) now propagate a null result instead of dereferencing it. Together these ensure a malformed var() name aborts parsing cleanly rather than consuming garbage token data.var( is always an identifier and blindly calling Value() on it, when it can be any token type (e.g. a comma) with no value storage. The fix adds an explicit kIdentToken type check before the Value() read and zero-initializes value_length_ so even an errant read yields an empty token rather than uninitialized data.Attack Path
- Craft malformed input
An attacker uses JavaScript to build
new CSSUnparsedValue(['var(,)']), avar()reference whose first inner token is a comma instead of an identifier. - Force reparse via Typed OM
They assign it through
div.attributeStyleMap.set('--x', bad)and read it back with.get('--x'), driving the value throughFindVariableName. - Trigger the invalid read
FindVariableNameconsumes the comma token and callsValue()on it, readingvalue_length_and value bytes that were never initialized for a comma token. - Amplify the effect Repeating the operation thousands of times (as in the crash test) reliably surfaces the uninitialized read, producing a crash or leaking whatever stack/heap bytes happened to back the token.
Impact Assessment
CSSUnparsedValue from ordinary page JavaScript, with no special privileges required beyond running script. Depending on memory contents this yields a renderer crash (denial of service) or potential disclosure of adjacent memory contents interpreted as a string. The only precondition is the ability to execute JavaScript that constructs and round-trips a malformed var() value through the Typed OM attributeStyleMap.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/css/cssom/css_unparsed_value.cc |
modified | |
forthird_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html |
modified |
Files Changed
third_party/blink/renderer/core/css/cssom/css_unparsed_value.ccthird_party/blink/renderer/core/css/parser/css_parser_token.hthird_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html
Audit Directions
- Unchecked `Value()` on parser tokensSearch other CSS/Typed OM parser paths that call
CSSParserToken::Value()afterConsume()/Peek()without first asserting the token type carries a value. - Assumed token shape after function tokensReview code that consumes tokens following a known function (
var(,env(,attr(, etc.) and presumes the next token is an identifier without validatingGetType(). - Uninitialized bitfield/length membersAudit compact token and value structs for
_lengthor pointer fields left uninitialized for token types that do not use them, especially where the same accessor is shared across valued and valueless types.
Patch
From 2e0a8f0ab03e48d823d391d9f77e1e96d68b7796 Mon Sep 17 00:00:00 2001
From: Steinar H. Gunderson <sesse@chromium.org>
Date: Thu, 19 Feb 2026 05:43:25 -0800
Subject: [PATCH] Fix reading of invalid data in Typed OM.
After seeing var(, we would not properly check that the next token
was an ident, and would just call Value(), potentially reading
uninitialized data. Our fix has a little defense in depth:
1. For this specific case, we check the token type. (The spec is not
exactly clear on exactly _what_ we should do, but right now,
we abort the parsing. We should probably throw an exception,
but getting an ExceptionState all the way down here is not trivial.)
2. For CSSParserTokens without a value, we add a DCHECK if someone
calls Value().
3. In addition, we explicitly initialize data_length_ to zero,
so that if someone calls Value() in a non-DCHECK build, we'll
get an empty token instead of uninitialized data.
Style perftest parsing performance is neutral.
Fixed: 484811719
Change-Id: I749989639af9836abc74f90de9135c08cad804d2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7593255
Reviewed-by: Anders Hartvoll Ruud <andruud@chromium.org>
Commit-Queue: Steinar H Gunderson <sesse@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1587044}
---
diff --git a/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc b/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
index 3d43306..486e9d1 100644
--- a/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
+++ b/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
@@ -19,7 +19,11 @@
String FindVariableName(CSSParserTokenStream& stream) {
stream.ConsumeWhitespace();
- return stream.Consume().Value().ToString();
+ if (stream.Peek().GetType() == CSSParserTokenType::kIdentToken) {
+ return stream.Consume().Value().ToString();
+ } else {
+ return {};
+ }
}
V8CSSUnparsedSegment* VariableReferenceValue(
@@ -35,6 +39,11 @@
CSSStyleVariableReferenceValue* variable_reference =
CSSStyleVariableReferenceValue::Create(variable_name.ToString(),
unparsed_value);
+ if (!variable_reference) {
+ // TODO(sesse): Plumb the ExceptionState here so that we can use
+ // the Create() variant that properly throws an exception.
+ return nullptr;
+ }
return MakeGarbageCollected<V8CSSUnparsedSegment>(variable_reference);
}
@@ -57,8 +66,12 @@
if (stream.Peek().GetType() == CSSParserTokenType::kCommaToken) {
stream.Consume();
}
- tokens.push_back(VariableReferenceValue(
- variable_name, ParserTokenStreamToTokens(stream)));
+ V8CSSUnparsedSegment* ref = VariableReferenceValue(
+ variable_name, ParserTokenStreamToTokens(stream));
+ if (!ref) {
+ break;
+ }
+ tokens.push_back(ref);
} else {
if (stream.Peek().GetBlockType() == CSSParserToken::kBlockStart) {
++nesting_level;
diff --git a/third_party/blink/renderer/core/css/parser/css_parser_token.h b/third_party/blink/renderer/core/css/parser/css_parser_token.h
index 0f8efb7..b294c9dc3 100644
--- a/third_party/blink/renderer/core/css/parser/css_parser_token.h
+++ b/third_party/blink/renderer/core/css/parser/css_parser_token.h
@@ -93,7 +93,8 @@
unit_(0), // Don't care.
value_is_inline_(false),
value_is_8bit_(false), // Don't care.
- padding_(0) // Don't care.
+ padding_(0), // Don't care.
+ value_length_(0) // For security.
{}
// The resulting CSSParserToken may hold a reference to the data in value.
@@ -128,6 +129,9 @@
return static_cast<CSSParserTokenType>(type_);
}
StringView Value() const {
+#if DCHECK_IS_ON()
+ DCHECK(has_value_);
+#endif
return value_is_8bit_ ? StringView(Span8()) : StringView(Span16());
}
@@ -221,6 +225,9 @@
value_data_char_raw_ = string.Bytes();
value_is_inline_ = false;
}
+#if DCHECK_IS_ON()
+ has_value_ = true;
+#endif
}
bool ValueDataCharRawEqual(const CSSParserToken& other) const;
const void* ValueDataCharRaw() const {
@@ -268,8 +275,15 @@
// tightly with the rest of this object for a smaller object size.
unsigned value_is_8bit_ : 1;
+#if DCHECK_IS_ON()
+ unsigned has_value_ : 1 = false;
+
+ // These are free bits. You may take from them if you need.
+ [[maybe_unused]] unsigned padding_ : 11;
+#else
// These are free bits. You may take from them if you need.
[[maybe_unused]] unsigned padding_ : 12;
+#endif
unsigned value_length_;
union {
diff --git a/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html b/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html
new file mode 100644
index 0000000..b92bd62d
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<title>Crash Test: Missing variable name in CSSUnparsedValue</title>
+<link rel="help" href="https://issues.chromium.org/issues/484811719">
+<div id="div"></div>
+<script>
+ for (let i = 0; i < 5000; ++i) {
+ const bad = new CSSUnparsedValue(['var(,)']);
+ div.attributeStyleMap.set('--x', bad);
+ div.attributeStyleMap.get('--x');
+ }
+</script>
+<p>PASS if no crash</p>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html b/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html
new file mode 100644
index 0000000..b92bd62d
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-typed-om/missing-variable-in-unparsed-value-crash.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<title>Crash Test: Missing variable name in CSSUnparsedValue</title>
+<link rel="help" href="https://issues.chromium.org/issues/484811719">
+<div id="div"></div>
+<script>
+ for (let i = 0; i < 5000; ++i) {
+ const bad = new CSSUnparsedValue(['var(,)']);
+ div.attributeStyleMap.set('--x', bad);
+ div.attributeStyleMap.get('--x');
+ }
+</script>
+<p>PASS if no crash</p>
Original Bug Report
Heap-buffer-overflow in CSSUnparsedValue::FindVariableName
Summary
CSSUnparsedValue’s var()/env() tokenization path assumes the first argument is always string-backed, and calls FindVariableName() as stream.Consume().Value().ToString() without validating the token type. When the first token inside var(...) is a comma (e.g. var(,)), the consumed token is kCommaToken (not string-backed), so CSSParserToken::Value() constructs a StringView from uninitialized value_length_/value_data_char_raw_.Therefore, OOB happens when we convert that StringView to a String copies an attacker-influenced size from an invalid pointer.
Details
When converting a serialized custom property back into Typed OM segments, ParserTokenStreamToTokens() treats any var()/env() function token as a variable-reference and immediately parses a “variable name” token.
In css_unparsed_value.cc, the variable-name extraction does not check token type or whether the token is string-backed:
String FindVariableName(CSSParserTokenStream& stream) {
stream.ConsumeWhitespace();
return stream.Consume().Value().ToString();
}
For malformed input like var(,), after consuming whitespace the next token is a comma (kCommaToken). Comma tokens are not string-backed (see CSSParserToken::HasStringBacking()), but FindVariableName() still calls Value().
In css_parser_token.h, CSSParserToken::Value() returns a StringView built from value_length_ and value_data_char_raw_:
StringView Value() const {
return value_is_8bit_ ? StringView(Span8()) : StringView(Span16());
}
In css_parser_token.h, non-string-backed tokens constructed via CSSParserToken(CSSParserTokenType, BlockType) do not initialize the “value” fields, so value_length_ and value_data_char_raw_ contain indeterminate data:
explicit CSSParserToken(CSSParserTokenType type,
BlockType block_type = kNotBlock)
: type_(type),
block_type_(block_type),
numeric_value_type_(0),
numeric_sign_(0),
unit_(0),
value_is_inline_(false),
value_is_8bit_(false),
padding_(0) {}
As a result, Value().ToString() can attempt to copy an attacker-controlled large length from an attacker-controlled pointer, leading to the OOB during the copy inside StringView::ToString().
Bisection
This issue was introduced in commit fb6b1c467f2705a7d8c607d512cacefd17f1488d (“Convert CSSUnparsedValue parsing to the streaming parser”) by Steinar H. Gunderson on 2024-08-27 (CL 5803173).
Reproduction
Using https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1585188.zip
Run:
./chrome --no-sandbox --user-data-dir=/tmp/xx poc.html
You should ASAN crash which shown in the asan.txt
- https://chromium-review.googlesource.com/c/chromium/src/+/5803173
- https://chromium.googlesource.com/chromium/src/+/fb6b1c467f2705a7d8c607d512cacefd17f1488d
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc;l=20
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc;l=41
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/parser/css_parser_token.cc;l=126
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/parser/css_parser_token.h;l=130
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/parser/css_parser_token.h;l=87
- https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1585188.zip