High patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Blink
DescriptionUse after free in Blink
ComponentBlink
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker540482895
Fix commit6c0cbb849651 (chromium/src) +3/-1
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedWinD39 - Huynh Dinh Vu
Reported2026-07-30 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-08-11
Patch gap12 days — fix public in source before the advisory

Background

Presentation attribute cache
Blink caches the CSSPropertyValueSet built from an element’s presentational HTML attributes (legacy attributes such as bgcolor or width that 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 (0 here) 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, so Member<> 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.

Key insight
A raw hash derived from content was used directly as an integer hash-map key without excluding the table’s reserved empty/deleted values. As a result, a cache entry that hashed to the deleted sentinel became invisible to GC tracing and was freed while it was still reachable through the map. EnsureValidHash makes the key always valid.

Attack Path

  1. Choose attributes that hit the sentinel hash The attacker picks an element tag and presentational attribute values whose combined HashInts(tag_hash, attribute_hash) equals 0xFFFFFFFF. Values that the page controls feed attribute_hash. Finding such values offline assumes the hash is not randomized per process (inferred).
  2. 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 HeapHashMap treats as deleted.
  3. 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.
  4. Lookup returns freed data Styling another element with the same tag and attributes looks up the same key and gets back a dangling CSSPropertyValueSet pointer, which is a use-after-free in the renderer. Turning this into memory corruption or code execution is (inferred).

Impact Assessment

The bug is a use-after-free of a 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 unsigned or int whose keys come from HashInts, StringHasher, or GetHash() without EnsureValidHash (or an explicit check against HashTraits::EmptyValue/DeletedValue), especially when the input is controlled by web content.
  • Caches that hold GC objects under computed keys
    Review Blink caches similar to ElementDataCache, MatchedPropertiesCache and font/shape caches that store Member<> 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 traits
    Check custom HashTraits and IsHashTraitsEmptyOrDeletedValue users for keys derived from attacker input, where a collision with the empty or deleted marker silently breaks the map’s invariants.
From 6c0cbb8496513976cb3d1b446b3ffe37f1463f72 Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
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 <masonf@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
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(
Loading diff…

Original Bug Report

reported by hd...@gmail.com

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

View on issue tracker