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 DOM
DescriptionUse after free in DOM
ComponentDOM
Sub-componentBlink>DOM
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — factors set 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.
Additionally cross-checked: a second, different model independently derived the same bug class, vulnerable function and fix from the commit.
Tracker515155946
Fix commitdffb572d0b0c (chromium/src) +26/-18
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
Creditedpwn2addr
Reported2026-05-20 (per vendor advisory)
Fix landed2026-05-22 (public git)
Disclosed2026-05-27
Patch gap5 days — fix public in source before the advisory

Background

`Document::ProcessBaseElement`
recomputes the document’s base URL and base target from the first <base> element’s attributes (third_party/blink/renderer/core/dom/document.cc:5095).
`AtomicString`
Blink’s interned string; FastGetAttribute returns a const AtomicString& referencing the attribute’s storage slot inside the element’s attribute vector.
`UpdateBaseURL`
applies the new base URL and, per the fix’s own note, can fire events and run script (document.cc:5165).

Root Cause Analysis

Pre-fix, ProcessBaseElement saved raw pointers into attribute storage: const AtomicString* href = nullptr; / const AtomicString* target = nullptr; (third_party/blink/renderer/core/dom/document.cc:5100-5101), assigning href = &value; and target = &value; where value is the reference returned by base->FastGetAttribute(...) (document.cc:5106-5114). With href live, when base_element_url != base_element_url_ the function calls UpdateBaseURL() (document.cc:5165), a synchronous callout that can run author script. That script can mutate the same <base> element’s attributes, reallocating the backing attribute vector and freeing the buffer the saved target pointer references. On return, the function dereferences the now-stale pointer three times: target->contains('\n') || target->contains('\r'), target->contains('<'), and base_target_ = *target; (document.cc:5170-5176), a use-after-free. The invariant — values borrowed across a script-running callout must not be raw pointers into mutable element storage — was violated.

The fix changes href/target to owning AtomicString values copied out of FastGetAttribute (AtomicString href; ... href = base->FastGetAttribute(html_names::kHrefAttr);), replaces the pointer tests with href.IsNull()/target.IsNull() and value dereferences (target.contains(...), base_target_ = target;), and adds the comment that UpdateBaseURL can run script (diff @@ -5097, @@ -5123, @@ -5162). Holding copies means re-entrant attribute mutation can no longer dangle the borrowed slot.

Key insight
The function borrowed raw pointers into an element’s attribute storage and then ran script (via UpdateBaseURL) that could free that storage before the pointers were read; the fix stores AtomicString copies instead of pointers.

Attack Path

  1. Set up a `<base>` with a speculation-rules script The page has a <base> and a <script type="speculationrules"> whose source is not a JSON object (e.g. []), as in the added crashtest.
  2. Mutate `href` from script Assigning b.href enters ProcessBaseElement, which borrows target as a raw pointer and, since the URL changed, calls UpdateBaseURL() (third_party/blink/renderer/core/dom/document.cc:5113-5114, 5165).
  3. Re-enter and free UpdateBaseURL synchronously dispatches an error event whose handler calls setAttribute on the same <base>, reallocating and freeing the attribute buffer. (inferred: the speculation-rules dispatch chain is the reporter’s trace, not shown in document.cc)
  4. Use after free Control returns and target->contains(...) / *target read the freed slot (document.cc:5170-5176).

Impact Assessment

A page could free a <base> element’s attribute storage from a re-entrant event handler while ProcessBaseElement still held a raw pointer into it, yielding a use-after-free read in the renderer process. The advisory rates this high severity with no in-the-wild exploitation (reporter credit pwn2addr); escalation beyond the freed-memory read is not shown in the material (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/dom/document.cc
modified
for
third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html
modified

Files Changed

  • third_party/blink/renderer/core/dom/document.cc
  • third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html

Audit Directions

  • Borrowed pointers across callouts
    Flag raw pointers/references into DOM attribute or node storage that stay live across any call able to run script.
  • Hidden script execution
    Treat URL/base/style updates (e.g. UpdateBaseURL) as potential synchronous script-dispatch points when reasoning about object lifetimes.
  • Container reallocation invalidation
    When holding a pointer into a Vector-backed store, confirm no intervening operation can push_back/grow and reallocate it.
From dffb572d0b0cdf92d0aaef482ffc4d22cef4cabc Mon Sep 17 00:00:00 2001
From: David Baron <[email protected]>
Date: Thu, 21 May 2026 17:23:40 -0700
Subject: [PATCH] Make Document::ProcessBaseElement store strings rather than pointers to strings.

This changes Document::ProcessBaseElement to use AtomicString rather
than using const AtomicString* that point into an element's attribute
storage (which could be modified in the middle of the function).

Fixed: 515155946
Change-Id: I56dba34c3df8e0b3752b4bfcc2ee6c248694bcc0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7868910
Reviewed-by: Mason Freed <[email protected]>
Commit-Queue: David Baron <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1634618}
---

diff --git a/third_party/blink/renderer/core/dom/document.cc b/third_party/blink/renderer/core/dom/document.cc
index 5bf48af2..e73c68f 100644
--- a/third_party/blink/renderer/core/dom/document.cc
+++ b/third_party/blink/renderer/core/dom/document.cc
@@ -5097,21 +5097,16 @@
 
   // Find the first href attribute in a base element and the first target
   // attribute in a base element.
-  const AtomicString* href = nullptr;
-  const AtomicString* target = nullptr;
+  AtomicString href;
+  AtomicString target;
   for (HTMLBaseElement* base = Traversal<HTMLBaseElement>::FirstWithin(*this);
-       base && (!href || !target);
+       base && (href.IsNull() || target.IsNull());
        base = Traversal<HTMLBaseElement>::Next(*base)) {
-    if (!href) {
-      const AtomicString& value = base->FastGetAttribute(html_names::kHrefAttr);
-      if (!value.IsNull())
-        href = &value;
+    if (href.IsNull()) {
+      href = base->FastGetAttribute(html_names::kHrefAttr);
     }
-    if (!target) {
-      const AtomicString& value =
-          base->FastGetAttribute(html_names::kTargetAttr);
-      if (!value.IsNull())
-        target = &value;
+    if (target.IsNull()) {
+      target = base->FastGetAttribute(html_names::kTargetAttr);
     }
     if (GetExecutionContext() &&
         GetExecutionContext()->GetContentSecurityPolicy()->IsActive()) {
@@ -5123,8 +5118,8 @@
   // FIXME: Since this doesn't share code with completeURL it may not handle
   // encodings correctly.
   KURL base_element_url;
-  if (href) {
-    StringView stripped_href = StripLeadingAndTrailingHtmlSpaces(*href);
+  if (!href.IsNull()) {
+    StringView stripped_href = StripLeadingAndTrailingHtmlSpaces(href);
     if (!stripped_href.empty())
       base_element_url = KURL(FallbackBaseURL(), stripped_href);
   }
@@ -5162,18 +5157,19 @@
     } else {
       base_element_url_ = FallbackBaseURL();
     }
+    // NOTE: UpdateBaseURL can fire events and thus run script.
     UpdateBaseURL();
   }
 
   AtomicString old_base_target = base_target_;
-  if (target) {
-    if (target->contains('\n') || target->contains('\r')) {
+  if (!target.IsNull()) {
+    if (target.contains('\n') || target.contains('\r')) {
       UseCounter::Count(*this, WebFeature::kBaseWithNewlinesInTarget);
     }
-    if (target->contains('<')) {
+    if (target.contains('<')) {
       UseCounter::Count(*this, WebFeature::kBaseWithOpenBracketInTarget);
     }
-    base_target_ = *target;
+    base_target_ = target;
   } else {
     base_target_ = g_null_atom;
   }
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html
new file mode 100644
index 0000000..1a42ece
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<base id="b" href="original-base-url" target="_top" att1="1" att2="2" att3="3">
+<script id="s" type="speculationrules">[]</script>
+<script>
+let b = document.getElementById("b");
+document.getElementById("s").addEventListener("error", event => {
+  for (let i = 1; i <= 10; ++i) {
+    b.setAttribute(`newatt${i}`, `value${i}`);
+  }
+});
+b.href = "/new-base-url";
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html
new file mode 100644
index 0000000..1a42ece
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-with-speculation-rules-onerror.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<base id="b" href="original-base-url" target="_top" att1="1" att2="2" att3="3">
+<script id="s" type="speculationrules">[]</script>
+<script>
+let b = document.getElementById("b");
+document.getElementById("s").addEventListener("error", event => {
+  for (let i = 1; i <= 10; ++i) {
+    b.setAttribute(`newatt${i}`, `value${i}`);
+  }
+});
+b.href = "/new-base-url";
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free via re-entrant attribute mutation during synchronous speculation-rules error dispatch

Steps to reproduce the problem

  1. Download ASAN Chromium Download asan-win32-release_x64-1633823.zip

  2. Launch Chrome

chrome.exe --no-sandbox poc.html

Problem Description

Document::ProcessBaseElement borrows a raw const AtomicString* into a <base> element’s mutable AttributeVector slot and then calls Document::UpdateBaseURL. UpdateBaseURL synchronously dispatches a JavaScript error event on any speculation-rules <script> whose source is not a JSON object. The handler can mutate the same <base> element so the AttributeVector backing buffer is freed and the slot pointed to by the saved raw pointer is reclaimed. When the outer call resumes, it reads target->impl_ from the freed bucket via StringImpl::find and scoped_refptr::operator=, dereferencing freed memory and calling StringImpl::AddRef on an attacker-influenced pointer value.

Any b.href = ... or b.target = ... from script enters Document::ProcessBaseElement via HTMLBaseElement::ParseAttribute (third_party/blink/renderer/core/html/html_base_element.cc:36). That function saves a raw const AtomicString* pointing into the first <base>’s target attribute storage, then calls UpdateBaseURL while that pointer is still live on the stack:

// Document::ProcessBaseElement  third_party/blink/renderer/core/dom/document.cc:5099
5099:    const AtomicString* href = nullptr;
5100:    const AtomicString* target = nullptr;
...
5113:          target = &value;  // raw pointer into a mutable Attribute slot inside AttributeVector
...
5147:    if (base_element_url != base_element_url_) {  // forced true by the attacker changing `b.href`
...
5164:      UpdateBaseURL();  // synchronous callout while `target` is still borrowed above
5165:    }

UpdateBaseURL (document.cc:5019) iterates every HTMLScriptElement descendant on the same stack and calls ScriptLoader::DocumentBaseURLChanged on each. For a <script type="speculationrules"> element, that re-parses the original source under the new base URL via AddSpeculationRuleSet, which fires an error event when the JSON parses to anything other than an object (e.g., the top-level array []):

// ScriptLoader::AddSpeculationRuleSet  third_party/blink/renderer/core/script/script_loader.cc:1380
1380:    if (speculation_rule_set_->error_type() ==
1381:            SpeculationRuleSetErrorType::kSourceIsNotJsonObject ||  // `[]` is JSON but not an object, this branch fires
1382:        speculation_rule_set_->error_type() ==
1383:            SpeculationRuleSetErrorType::kInvalidRulesetLevelTag) {
...
1386:      element_->DispatchErrorEvent();  // runs attacker `onerror` JS on the same stack while `target` is still borrowed

A single setAttribute from the onerror handler is enough to free the buffer target borrowed from. Vector::push_back falls into Vector::AppendSlowCase when at capacity, which allocates a new BufferPartition buffer, moves the elements over, and frees the old one:

// Vector::AppendSlowCase  third_party/blink/renderer/platform/wtf/vector.h:2380
2382:  Vector<T, InlineCapacity, Allocator>::AppendSlowCase(U&& val) {
2383:    DCHECK_EQ(size(), capacity());
...
2386:    ptr = ExpandCapacity(size() + 1, ptr);  // allocates new buffer, copies, FREES the buffer `target` points into
2392:  }

With six attributes the AttributeVector (Vector<Attribute, 4>) already uses an external 96-byte buffer, one setAttribute grows it and frees that buffer, leaving target dangling.

When the error event returns, Document::ProcessBaseElement resumes and dereferences the stale pointer three times:

// Document::ProcessBaseElement  third_party/blink/renderer/core/dom/document.cc:5167
5167:    AtomicString old_base_target = base_target_;
5168:    if (target) {
5169:      if (target->contains('\n') || target->contains('\r')) {  // UAF
...
5172:      if (target->contains('<')) {                              // UAF
...
5175:      base_target_ = *target;                                   // UAF

Summary

Use-After-Free via re-entrant attribute mutation during synchronous speculation-rules error dispatch

Custom Questions

Type of crash:

renderer

Crash state:

Please see asan.log

Reporter credit:

pwn2addr

Additional Data

Category: Security
Chrome Channel: Canary
Regression: N/A \

View on issue tracker