CVE-2026-10016
Overview
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;
FastGetAttributereturns aconst 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.
UpdateBaseURL) that could free that storage before the pointers were read; the fix stores AtomicString copies instead of pointers.Attack Path
- 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. - Mutate `href` from script
Assigning
b.hrefentersProcessBaseElement, which borrowstargetas a raw pointer and, since the URL changed, callsUpdateBaseURL()(third_party/blink/renderer/core/dom/document.cc:5113-5114,5165). - Re-enter and free
UpdateBaseURLsynchronously dispatches anerrorevent whose handler callssetAttributeon the same<base>, reallocating and freeing the attribute buffer. (inferred: the speculation-rules dispatch chain is the reporter’s trace, not shown indocument.cc) - Use after free
Control returns and
target->contains(...)/*targetread the freed slot (document.cc:5170-5176).
Impact Assessment
<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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/dom/document.cc |
modified | |
forthird_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.ccthird_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 calloutsFlag raw pointers/references into DOM attribute or node storage that stay live across any call able to run script.
- Hidden script executionTreat URL/base/style updates (e.g.
UpdateBaseURL) as potential synchronous script-dispatch points when reasoning about object lifetimes. - Container reallocation invalidationWhen holding a pointer into a
Vector-backed store, confirm no intervening operation canpush_back/grow and reallocate it.
Patch
From dffb572d0b0cdf92d0aaef482ffc4d22cef4cabc Mon Sep 17 00:00:00 2001
From: David Baron <dbaron@chromium.org>
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 <masonf@chromium.org>
Commit-Queue: David Baron <dbaron@chromium.org>
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>
Regression Test / PoC
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>
Original Bug Report
Use-After-Free via re-entrant attribute mutation during synchronous speculation-rules error dispatch
Steps to reproduce the problem
-
Download ASAN Chromium Download asan-win32-release_x64-1633823.zip
-
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 \
- https://www.googleapis.com/download/storage/v1/b/chromium-browser-asan/o/win32-release_x64%2Fasan-win32-release_x64-1633823.zip?generation=1779317172944118&alt=media
- https://www.googleapis.com/download/storage/v1/b/chromium-browser-asan/o/win32-release_x64/asan-win32-release_x64-1633823.zip?generation=1779317172944118&alt=media