Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Blink
DescriptionUse after free in Blink
ComponentBlink
Bug ClassUAF
Tracker540482895
Fix commit6c0cbb849651 (chromium/src) +3/-1
CISA KEVNot listed
CreditedWinD39 - Huynh Dinh Vu
Disclosed2026-08-11

Files Changed

  • third_party/blink/renderer/core/dom/presentation_attribute_style.cc
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(
Loading diff…

Original Bug Report

reported by [email protected]

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