CVE-2026-19560
Overview
Background
- Presentation attribute cache
- Blink caches the
CSSPropertyValueSetbuilt from an element’s presentational HTML attributes (legacy attributes such asbgcolororwidththat map to CSS). The cache is keyed by a hash of the tag name and attribute name/value pairs. - `HashTraits` sentinel values
- WTF hash tables reserve particular key values to mark empty buckets (
0here) and deleted buckets (0xFFFFFFFF), so a real key must never equal either one. - `EnsureValidHash`
- A helper (from
hash_traits.h) that maps a computed hash away from the reserved empty/deleted sentinel values, so the result can safely be used as an integer hash-table key. - Oilpan tracing of `HeapHashMap`
- During garbage collection, Oilpan traces only the live buckets of a
HeapHashMap. Buckets whose key looks empty or deleted are skipped, soMember<>values stored in them are not kept alive.
Root Cause Analysis
ComputePresentationAttributeCacheHash returned HashInts(key.tag_name->ExistingHash(), attribute_hash) directly, where attribute_hash is a StringHasher::HashMemory32 hash of the attributes and values. It did not exclude the HashTraits sentinel values. According to the commit, when the combined hash equals 0xFFFFFFFF (the deleted-bucket value), the presentation attribute cache’s HeapHashMap stores the entry in a bucket that it considers deleted. The GC then skips tracing that bucket’s Member and frees the entry while the map still points to it. A later cache lookup with the same key then returns a pointer to the freed CSSPropertyValueSet. The violated invariant is that a key inserted into a WTF hash table must never be a reserved sentinel.
The fix wraps the result in EnsureValidHash(...), which remaps both 0 and 0xFFFFFFFF to valid values. This matches what the commit says ElementDataCache and MatchedPropertiesCache already do.
EnsureValidHash makes the key always valid.Attack Path
- Choose attributes that hit the sentinel hash
The attacker picks an element tag and presentational attribute values whose combined
HashInts(tag_hash, attribute_hash)equals0xFFFFFFFF. Values that the page controls feedattribute_hash. Finding such values offline assumes the hash is not randomized per process (inferred). - Insert into the cache under the sentinel key
Style resolution for that element computes the presentation-attribute style and caches it. The commit says the entry ends up in a bucket the
HeapHashMaptreats as deleted. - Garbage collection frees the entry Oilpan skips the “deleted” bucket while tracing, so the cached entry is swept even though the map still refers to it.
- Lookup returns freed data
Styling another element with the same tag and attributes looks up the same key and gets back a dangling
CSSPropertyValueSetpointer, which is a use-after-free in the renderer. Turning this into memory corruption or code execution is (inferred).
Impact Assessment
CSSPropertyValueSet in Blink’s style code in the renderer process. Presentational HTML attributes are ordinary web content, so a stock build can be reached by a web page that sets attribute values chosen to produce the sentinel hash, plus a garbage collection between insertion and lookup. Whether an attacker can compute such values offline depends on the hash being deterministic (inferred). The advisory gives only “Use after free in Blink”. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8.Files Changed
third_party/blink/renderer/core/dom/presentation_attribute_style.cc
Audit Directions
- Integer hash keys in `HashMap`/`HeapHashMap`Search for maps keyed by
unsignedorintwhose keys come fromHashInts,StringHasher, orGetHash()withoutEnsureValidHash(or an explicit check againstHashTraits::EmptyValue/DeletedValue), especially when the input is controlled by web content. - Caches that hold GC objects under computed keysReview Blink caches similar to
ElementDataCache,MatchedPropertiesCacheand font/shape caches that storeMember<>values keyed by a computed hash, because a sentinel key there turns into an object that is not traced but can still be reached. - Sentinel-collision handling in key traitsCheck custom
HashTraitsandIsHashTraitsEmptyOrDeletedValueusers for keys derived from attacker input, where a collision with the empty or deleted marker silently breaks the map’s invariants.
Patch
From 6c0cbb8496513976cb3d1b446b3ffe37f1463f72 Mon Sep 17 00:00:00 2001 From: Mason Freed <[email protected]> Date: Thu, 30 Jul 2026 14:57:52 -0700 Subject: [PATCH] Sanitize presentation attribute cache hash using EnsureValidHash ComputePresentationAttributeCacheHash returns HashInts without using EnsureValidHash to filter out HashTraits sentinel values (0 and 0xFFFFFFFF). When the computed hash happens to equal 0xFFFFFFFF (DeletedValue), HeapHashMap stores the Member in a bucket treated as a deleted sentinel. During garbage collection, Oilpan skips tracing deleted buckets, allowing the entry to be swept while the map retains a reference to it. A subsequent cache lookup for the same element key then returns a freed CSSPropertyValueSet pointer, leading to a use-after-free. This patch wraps the output of HashInts in EnsureValidHash in ComputePresentationAttributeCacheHash, matching the pattern used by ElementDataCache and MatchedPropertiesCache. Fixed: 540482895 Change-Id: I0f23507e31a76b62556a4fd8588784665ac336d4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8178803 Auto-Submit: Mason Freed <[email protected]> Reviewed-by: Joey Arhar <[email protected]> Commit-Queue: Joey Arhar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671442} --- diff --git a/third_party/blink/renderer/core/dom/presentation_attribute_style.cc b/third_party/blink/renderer/core/dom/presentation_attribute_style.cc index 6fadc50..bde4cc9 100644 --- a/third_party/blink/renderer/core/dom/presentation_attribute_style.cc +++ b/third_party/blink/renderer/core/dom/presentation_attribute_style.cc @@ -40,6 +40,7 @@ #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/wtf/hash_functions.h" #include "third_party/blink/renderer/platform/wtf/hash_map.h" +#include "third_party/blink/renderer/platform/wtf/hash_traits.h" namespace blink { @@ -91,7 +92,8 @@ DCHECK(key.attributes_and_values.size()); unsigned attribute_hash = StringHasher::HashMemory32(base::as_byte_span(key.attributes_and_values)); - return HashInts(key.tag_name->ExistingHash(), attribute_hash); + return EnsureValidHash( + HashInts(key.tag_name->ExistingHash(), attribute_hash)); } static unsigned MakePresentationAttributeCacheKey(
Original Bug Report
Security: use-after-free in Blink's PresentationAttributeCache: the cache hash is not passed through EnsureValidHash, so a hash equal to the deleted sentinel hides a live entry from Oilpan
Blink has three HeapHashMaps keyed directly by a hash value through AlreadyHashedTraits. Two of them sanitise the hash before insertion so it can never collide with the hash table’s own sentinels. The third does not, and it stores a Member<> to a garbage collected object, so a hash that happens to equal the deleted sentinel parks a live entry in a bucket Oilpan never traces. The entry is swept while the map still points at it, and the next lookup hands the freed object straight back to the caller.
I have the use-after-free reproducing under ASAN against the real cache type. What I do not yet have is a web page that produces the sentinel hash on demand, for reasons I go into below, so I am reporting the defect rather than an exploit.
The three call sites
The sanitised ones:
third_party/blink/renderer/core/dom/element_data_cache.cc:49
unsigned hash = HashInts(tag_name->GetHash(), AttributeHash(attributes));
third_party/blink/renderer/core/dom/element_data_cache.cc:59
hash = EnsureValidHash(hash);
third_party/blink/renderer/core/css/resolver/matched_properties_cache.h:185
(same AlreadyHashedTraits map, hash sanitised before use)
The unsanitised one:
third_party/blink/renderer/core/dom/presentation_attribute_style.cc:88-95
static unsigned ComputePresentationAttributeCacheHash(
const PresentationAttributeCacheKey& key) {
DCHECK(key.tag_name);
DCHECK(key.attributes_and_values.size());
unsigned attribute_hash =
StringHasher::HashMemory32(base::as_byte_span(key.attributes_and_values));
return HashInts(key.tag_name->ExistingHash(), attribute_hash);
}
EnsureValidHash is at third_party/blink/renderer/platform/wtf/hash_traits.h:395 and maps both HashTraits<unsigned>::EmptyValue() and HashTraits<unsigned>::DeletedValue() to 1. The presentation attribute path never calls it. grep for EnsureValidHash in presentation_attribute_style.cc returns nothing.
The map is declared at presentation_attribute_style.cc:70-72:
using PresentationAttributeCache =
HeapHashMap<unsigned,
Member<PresentationAttributeCacheEntry>,
AlreadyHashedTraits>;
so the key is the hash itself, and DeletedValue() for unsigned is 0xFFFFFFFF.
What goes wrong
:140 if (cache_hash) {
:141 cache_value = cache.insert(cache_hash, nullptr).stored_value;
:142 if (cache_value->value && cache_value->value->key != cache_key)
:143 cache_hash = 0;
...
:150 if (cache_hash && cache_value->value) {
:151 return cache_value->value->value;
:152 }
...
:175 cache_value->value = new_entry;
The guard at :140 rejects the empty sentinel, zero, and nothing else. Insert a key of 0xFFFFFFFF and the bucket is indistinguishable from a deleted bucket. Oilpan’s trace of the map skips it, the Member<PresentationAttributeCacheEntry> it holds is never marked, and the entry is collected at the next GC while the map still references it. On the following call for a matching element, :142 dereferences the freed entry and :151 returns a CSSPropertyValueSet* read out of freed memory, which then becomes that element’s presentation style.
Reproduction
I built a probe that links the real blink platform libraries and drives the actual PresentationAttributeCache, its insert path and two full GCs, once with an ordinary hash and once with the sentinel. Everything except the hash value is identical between the two runs.
$ cd /root/cr-build/src/out/asan
$ ASAN_OPTIONS=detect_odr_violation=0:symbolize=1 ./probe 12345678
[*] HashTraits<unsigned>: Empty=00000000 Deleted=ffffffff | probe hash=12345678
[call1] insert(12345678) -> bucket=0x79b402020c10 value=(nil)
[*] cache.size()=9 buckets reached by iteration=9
[*] two full GCs done; now replaying ComputePresentationAttributeStyle()
[call2] insert(12345678) -> bucket=0x79b402020c10 value=0x79b402040a50
[call2] dereferencing cache_value->value->key (line :142) ...
[call2] key_tag=c0ffee01
[call2] returned CSSPropertyValueSet*=0x79b402060850 magic=5717e501
[*] done (no ASAN report)
$ ASAN_OPTIONS=detect_odr_violation=0:symbolize=1 ./probe ffffffff
[*] HashTraits<unsigned>: Empty=00000000 Deleted=ffffffff | probe hash=ffffffff
[call1] insert(ffffffff) -> bucket=0x7a0402021048 value=(nil)
[*] cache.size()=9 buckets reached by iteration=8
[*] two full GCs done; now replaying ComputePresentationAttributeStyle()
[call2] insert(ffffffff) -> bucket=0x7a0402021048 value=0x7a0402040a50
[call2] dereferencing cache_value->value->key (line :142) ...
=================================================================
==1563076==ERROR: AddressSanitizer: use-after-poison on address 0x7a0402040a60
READ of size 4 at 0x7a0402040a60 thread T0
#0 in main .../pas_uaf_probe.cc:116:38
SUMMARY: AddressSanitizer: use-after-poison ... in main
The line that matters is the size mismatch. With an ordinary hash the map has nine entries and iteration reaches nine. With the sentinel hash the map still reports nine but iteration reaches eight, which is the live entry Oilpan cannot see. use-after-poison rather than heap-use-after-free is just cppgc’s ASAN signature for a swept payload: UnmarkedObjectsPoisoner poisons unmarked objects at sweep time, and SetMemoryInaccessible poisons on free under V8_USE_ADDRESS_SANITIZER.
pas_uaf_probe.cc:116 is the probe’s transcription of presentation_attribute_style.cc:142.
On reaching it from a page
The honest position, so nobody has to work it out for themselves: I have not produced a page that reaches the sentinel value yet, and it is not simply a matter of writing better JavaScript. HashMemory32 runs over base::as_byte_span of a Vector<std::pair<StringImpl*, AtomicString>, 3>, so the bytes being hashed are pointers. A page cannot choose them and cannot compute the result offline, because they depend on ASLR and on allocation order. What a page can do is sample: every distinct tag and attribute-set it gets style resolved is one draw of a 32 bit value, and it needs the draw to land on 0xFFFFFFFF.
Nothing rate limits that sampling, the cache clears itself at 4096 entries (presentation_attribute_style.cc:169-174) so memory stays flat while a page grinds, atomised strings give stable pointers so a small pool of values recombines into billions of distinct byte spans, and each renderer process has its own cache and its own layout, so tabs and iframes multiply the rate. I have a search running now and will add the result to this bug either way.
I am filing before that finishes because the defect does not depend on it. The same expression in element_data_cache.cc was considered worth sanitising, and this is the same expression feeding the same insert pattern into the same map type, with the difference that this one holds a garbage collected pointer.
Upstream state
Still unfixed. I fetched third_party/blink/renderer/core/dom/presentation_attribute_style.cc from chromium main today (googlesource, format=TEXT) and it is byte identical to my tree in the relevant region: HashInts at :94 with no sanitisation, and if (cache_hash) at :140 as the only filter. EnsureValidHash appears zero times in the file.
I also checked every other AlreadyHashedTraits user I could find in blink/renderer. The three HashSet<unsigned> instances at core/execution_context/security_context.h:91, core/frame/csp/content_security_policy.h:531 and platform/loader/fetch/fetch_client_settings_object.h:93 store no garbage collected pointer, so a sentinel key there costs at most a lost set entry. platform/wtf/text/string_impl.h:88 is safe by construction because StringHasher masks the top bits. presentation_attribute_style.cc is the only remaining one that stores a Member<>.
Suggested fix
One line, matching what element_data_cache.cc already does:
return EnsureValidHash(HashInts(key.tag_name->ExistingHash(), attribute_hash));
Alternatively reject the deleted sentinel alongside the empty one at :140, though sanitising at the point of computation is closer to how the sibling was fixed and leaves less room for a fourth call site to repeat this.
Happy to attach the probe source, its build script and the full ASAN logs, or to upload them here if you would rather have them on the bug from the start.
Version
Chrome Version: 152.0.7941.0, ASAN component build of upstream Chromium (src HEAD 0f9ed52dddfeb86f328018d632b0806249e76243), is_asan=true, dcheck_always_on=false.
Operating System: Ubuntu 22.04.2 LTS, x86_64.
Credit
Reporter credit: WinD39 - Huynh Dinh Vu