CVE-2026-119267
Overview
Background
- `UniqueElementData::attribute_vector_`
- Blink’s per-element mutable attribute storage (
core/dom/element.cc), a vector ofAttributeentries. Appending/removing entries can reallocate the vector, invalidating any reference or index obtained earlier. - `Element::WillModifyAttribute()`
- Called before an attribute mutation; it can synchronously run script two ways: (1) by pausing in the debugger via
probe::WillModifyDOMAttr, and (2) viaUpdateName()→AddNamedItem, which can lazily initialize a frame’sWindowProxyand invoke a page-defined setter (e.g.chrome.loadTimes). - `SetAttributeInternal` / `RemoveAttributeInternal` / `AppendAttributeInternal`
- The three low-level mutators that acquire an
indexor aconst AtomicString&reference into the attribute storage and then callWillModifyAttribute()before using it. - `DidMoveToNewDocument()`
- Runs when an element is adopted into another document; synchronizes
id/classviaSetIdAttribute()/setAttribute(), which canEnsureUniqueElementData()(reallocating storage). - Re-entrant attribute mutation
- If script invoked during
WillModifyAttribute()mutates the same element’s attributes, previously-captured indices/references dangle.
Root Cause Analysis
The attribute mutators captured a position into attribute_vector_ and then called WillModifyAttribute(), which can run arbitrary script that re-indexes or reallocates that vector — leaving the captured reference/index dangling (use-after-free) or stale (wrong element / lost write). A prior CL (crrev.com/c/8510124) had already hardened SetAttributeInternal and RemoveAttributeInternal to re-validate index after WillModifyAttribute(). This CL closes the remaining gaps:
-
AppendAttributeInternalunconditionally didEnsureUniqueElementData().Attributes().Append(name, value)afterWillModifyAttribute(). If the re-entrant script already added the same attribute (the classic trigger: animgwhose emptynameattribute is modified →UpdateName→AddNamedItem→ lazyWindowProxyinit → pageloadTimessetter adds attributes), the unconditional append created a duplicate attribute and used storage that script may have reallocated. The fix checksFindAttributeIndex(name); if the attribute now exists it calls.at(index).SetValue(value), otherwise appends. -
DidMoveToNewDocumentheldconst AtomicString& id_attr = GetIdAttribute()(and the same forclass_attr) — a reference into the element’s attribute storage — then calledEnsureUniqueElementData()andSetIdAttribute(id_attr)/setAttribute(...), which can reallocate that storage, leavingid_attr/class_attrdangling and passing a freedAtomicStringreference into the setter. The fix copies them by value (AtomicString id_attr = GetIdAttribute()), so the subsequent reallocation can’t dangle them. -
Comments in
SetAttributeInternal/RemoveAttributeInternalare updated to note the newUpdateName()/WindowProxyscript-execution path (not just the debugger).
The advisory’s “Memory corruption in DOM” is substantiated and the diff narrows it to a use-after-free / dangling-reference on UniqueElementData attribute storage across a re-entrant WillModifyAttribute() (plus a duplicate-attribute state corruption). The WPTs drive exactly this via a sandboxed same-origin iframe whose WindowProxy initializes during AddNamedItem, running a loadTimes setter that reallocates the element’s attribute vector mid-mutation.
WillModifyAttribute() can synchronously run page script (debugger pause, or lazy WindowProxy init during UpdateName) that reallocates the element’s attribute vector, so any index or const AtomicString& into attribute storage captured before it — in AppendAttributeInternal and DidMoveToNewDocument — dangles and is used afterward.Attack Path
- Set up a deferred script context
Create a sandboxed
allow-same-originiframe so its script context stays uninitialized until named-item access (per the added WPT). - Prepare the re-entrancy hook
Name a child frame
chromeso that when itsWindowProxyinitializes,content::GetOrCreateChromeObject()setsloadTimeson it; define a JSloadTimessetter that mutates the target element’s attributes (appends enoughdata-*to reallocate the vector, and/or removes earlier attributes). - Trigger the vulnerable mutation
Call
img.setAttribute('name', 'target')(or adopt/append paths). This enters a mutator that captures an index/reference, then callsWillModifyAttribute()→UpdateName()→AddNamedItem→ lazyWindowProxyinit → theloadTimessetter. - Reallocate mid-mutation
The setter reallocates
attribute_vector_, freeing the backing store the captured index/reference points at. - Use freed/stale storage
The mutator resumes: pre-patch
AppendAttributeInternalappends into (orDidMoveToNewDocumentpasses) the dangling reference, yielding a use-after-free read/write on the attribute storage (and, in the append case, a duplicatenameattribute). Converting this to controlled corruption/RCE is inferred, not shown by the diff.
Impact Assessment
window.chrome/loadTimes lazy-init path — no DevTools or special privilege required). The primitive is a use-after-free / dangling-reference on heap-allocated UniqueElementData attribute storage during a re-entrant attribute mutation, plus an attribute-vector state-corruption (duplicate attribute) path. This is genuine memory corruption consistent with the advisory; escalation to a controlled read/write primitive or RCE is plausible for an attribute-vector UAF but is inferred, not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html |
modified | |
setthird_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html |
modified | |
ifthird_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.htmlthird_party/blink/renderer/core/dom/element.cc
Audit Directions
- References/indices across `WillModifyAttribute()`Audit every caller that reads an
AtomicString&/index/Attribute&from element data and then calls a function that can run script (WillModifyAttribute,UpdateName,AddNamedItem, debugger probes) before using it. - Lazy `WindowProxy` init side effectsReview DOM mutation paths that can trigger
LocalWindowProxy::Initialize→GetOrCreateChromeObject→ page setters synchronously, treating them as arbitrary-script re-entry points. - `EnsureUniqueElementData()` reallocationSearch for places holding a prior reference into
attribute_vector_across anEnsureUniqueElementData()/Append/Remove. - Adoption/`DidMoveToNewDocument` peersCheck other document-move/clone synchronizers that re-set
id/class/attributes from references into the source element’s storage.
Patch
From 3318e836e90b01f367be032759ad1368d7001bd3 Mon Sep 17 00:00:00 2001 From: Mason Freed <[email protected]> Date: Mon, 26 Oct 2026 12:55:08 -0700 Subject: [PATCH] Harden Element attribute mutation methods against re-entrant changes Element::WillModifyAttribute() can run script both by pausing in the debugger (via probe::WillModifyDOMAttr) and when UpdateName() lazily initializes a frame's WindowProxy and synchronously invokes a page-defined setter (e.g. via chrome.loadTimes). If script mutates the element's attributes and reallocates or re-indexes UniqueElementData::attribute_vector_, references and indices into the attribute storage acquired before WillModifyAttribute() become invalid. While https://crrev.com/c/8510124 already hardened SetAttributeInternal() and RemoveAttributeInternal(), this CL: 1. Checks in AppendAttributeInternal() whether WillModifyAttribute() already added the attribute re-entrantly before appending. 2. Copies id_attr and class_attr by value in DidMoveToNewDocument() before calling SetIdAttribute() / setAttribute(). 3. Updates comments in SetAttributeInternal() and RemoveAttributeInternal() and adds regression tests. Bug: 576203565, 575317938 Change-Id: Ic63c0b5a15568a9c8d2331e7b49dde89e36b5283 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8633017 Auto-Submit: Mason Freed <[email protected]> Commit-Queue: Mason Freed <[email protected]> Reviewed-by: Joey Arhar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1727352} --- diff --git a/third_party/blink/renderer/core/dom/element.cc b/third_party/blink/renderer/core/dom/element.cc index 4d7b016..1e198ec 100644 --- a/third_party/blink/renderer/core/dom/element.cc +++ b/third_party/blink/renderer/core/dom/element.cc @@ -8490,7 +8490,8 @@ DetachAttrNodeFromElementWithValue(attr_node, value_being_removed); } - // WillModifyAttribute() may pause in the debugger and allow script to mutate + // WillModifyAttribute() may run script (e.g. by pausing in the debugger or + // lazily initializing a WindowProxy during UpdateName()) and mutate // attributes, invalidating `index`. if (index >= mutable_attributes.size() || mutable_attributes[index].GetName() != name) { @@ -8519,7 +8520,15 @@ } attribute_or_class_bloom_ |= FilterForAttribute(name); UpdateSubtreeBloomFilterAfterInsert(); - EnsureUniqueElementData().Attributes().Append(name, value); + // WillModifyAttribute() may run script (e.g. by pausing in the debugger or + // lazily initializing a WindowProxy during UpdateName()) and add the + // attribute re-entrantly before we append it here. + if (wtf_size_t index = FindAttributeIndex(name); index != kNotFound) + [[unlikely]] { + EnsureUniqueElementData().Attributes().at(index).SetValue(value); + } else { + EnsureUniqueElementData().Attributes().Append(name, value); + } if (reason != AttributeModificationReason::kBySynchronizationOfLazyAttribute) { DidAddAttribute(name, value); @@ -12495,13 +12504,13 @@ // this element's attributes, we shouldn't make UniqueElementData, and this // element should point to the shareable one. - if (const AtomicString& id_attr = GetIdAttribute()) { + if (AtomicString id_attr = GetIdAttribute()) { if (!id_attr.ContainsNoAsciiUpper()) { EnsureUniqueElementData(); SetIdAttribute(id_attr); } } - if (const AtomicString& class_attr = GetClassAttribute()) { + if (AtomicString class_attr = GetClassAttribute()) { if (!class_attr.ContainsNoAsciiUpper()) { EnsureUniqueElementData(); // Going through setAttribute() to synchronize the attribute is only @@ -14132,8 +14141,9 @@ } MutableAttributeCollection mutable_attributes = EnsureUniqueElementData().Attributes(); - // WillModifyAttribute() may pause in the debugger and allow script to - // mutate attributes, invalidating `index`. + // WillModifyAttribute() may run script (e.g. by pausing in the debugger or + // lazily initializing a WindowProxy during UpdateName()) and mutate + // attributes, invalidating `index`. if (index >= mutable_attributes.size() || mutable_attributes[index].GetName() != existing_attribute_name) { index = mutable_attributes.FindIndex(existing_attribute_name); diff --git a/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html b/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html new file mode 100644 index 0000000..79195e1 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html @@ -0,0 +1,165 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<link rel="author" href="mailto:[email protected]"> +<link rel="help" href="https://crbug.com/575317938"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> + +<div id="container"></div> +<script> +test((t) => { + const container = document.getElementById('container'); + + // Create a sandboxed iframe without allow-scripts so its script context + // is not initialized until AddNamedItem accesses its WindowProxy. + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + // An empty name attribute exists in the attribute vector at index 0, + // but does not register a named item on insertion, leaving the sandboxed + // frame's WindowProxy uninitialized. + img.setAttribute('name', ''); + for (let i = 0; i < 7; i++) { + img.setAttribute('data-a' + i, 'v'); + } + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + for (let i = 7; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + // Modifying the existing 'name' attribute calls WillModifyAttribute -> + // UpdateName -> AddNamedItem, which initializes the WindowProxy and invokes + // the loadTimes setter above, reallocating img's attribute vector. + img.setAttribute('name', 'target_item'); + assert_equals(img.getAttribute('name'), 'target_item'); + assert_equals(innerDoc.target_item, img); +}, 'Reallocating attributes during WillModifyAttribute in SetAttributeInternal should not dangle or lose the new value'); + +test((t) => { + const container = document.getElementById('container'); + + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + img.setAttribute('data-first', '1'); + img.setAttribute('name', ''); + for (let i = 0; i < 7; i++) { + img.setAttribute('data-a' + i, 'v'); + } + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + let setterRan = false; + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + setterRan = true; + // Remove an earlier attribute so 'name' shifts from index 1 to index 0, + // and append enough attributes to reallocate the vector. + img.removeAttribute('data-first'); + for (let i = 7; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + img.setAttribute('name', 'shifted_item'); + if (setterRan) { + assert_equals(img.getAttribute('data-first'), null); + } + assert_equals(img.getAttribute('name'), 'shifted_item'); + assert_equals(img.getAttribute('data-a0'), 'v'); + assert_equals(innerDoc.shifted_item, img); +}, 'Removing earlier attributes during WillModifyAttribute in SetAttributeInternal should update the re-validated index'); + +test((t) => { + const container = document.getElementById('container'); + + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + img.setAttribute('name', 'inner_name'); + for (let i = 0; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + img.setAttribute('name', 'outer_name'); + assert_equals(img.getAttribute('name'), 'outer_name'); + let nameAttrCount = 0; + for (const attr of img.attributes) { + if (attr.name === 'name') { + nameAttrCount++; + } + } + assert_equals(nameAttrCount, 1); +}, 'Setting the same attribute re-entrantly during AppendAttributeInternal should not create duplicate attributes'); + +test((t) => { + const container = document.getElementById('container'); + const quirksFrame = document.createElement('iframe'); + container.appendChild(quirksFrame); + t.add_cleanup(() => quirksFrame.remove()); + + const quirksDoc = quirksFrame.contentDocument; + quirksDoc.open(); + quirksDoc.write('<div id="MyUpperId" class="MyUpperClass"></div>'); + quirksDoc.close(); + assert_equals(quirksDoc.compatMode, 'BackCompat'); + + const div = quirksDoc.getElementById('MyUpperId'); + document.adoptNode(div); + assert_equals(div.id, 'MyUpperId'); + assert_equals(div.className, 'MyUpperClass'); +}, 'Adopting an element with uppercase id and class between quirks and standards mode documents'); +</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html b/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html new file mode 100644 index 0000000..79195e1 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/html/dom/documents/dom-tree-accessors/nameditem-setattribute-attr-mutation.html @@ -0,0 +1,165 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<link rel="author" href="mailto:[email protected]"> +<link rel="help" href="https://crbug.com/575317938"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> + +<div id="container"></div> +<script> +test((t) => { + const container = document.getElementById('container'); + + // Create a sandboxed iframe without allow-scripts so its script context + // is not initialized until AddNamedItem accesses its WindowProxy. + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + // An empty name attribute exists in the attribute vector at index 0, + // but does not register a named item on insertion, leaving the sandboxed + // frame's WindowProxy uninitialized. + img.setAttribute('name', ''); + for (let i = 0; i < 7; i++) { + img.setAttribute('data-a' + i, 'v'); + } + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + for (let i = 7; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + // Modifying the existing 'name' attribute calls WillModifyAttribute -> + // UpdateName -> AddNamedItem, which initializes the WindowProxy and invokes + // the loadTimes setter above, reallocating img's attribute vector. + img.setAttribute('name', 'target_item'); + assert_equals(img.getAttribute('name'), 'target_item'); + assert_equals(innerDoc.target_item, img); +}, 'Reallocating attributes during WillModifyAttribute in SetAttributeInternal should not dangle or lose the new value'); + +test((t) => { + const container = document.getElementById('container'); + + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + img.setAttribute('data-first', '1'); + img.setAttribute('name', ''); + for (let i = 0; i < 7; i++) { + img.setAttribute('data-a' + i, 'v'); + } + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + let setterRan = false; + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + setterRan = true; + // Remove an earlier attribute so 'name' shifts from index 1 to index 0, + // and append enough attributes to reallocate the vector. + img.removeAttribute('data-first'); + for (let i = 7; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + img.setAttribute('name', 'shifted_item'); + if (setterRan) { + assert_equals(img.getAttribute('data-first'), null); + } + assert_equals(img.getAttribute('name'), 'shifted_item'); + assert_equals(img.getAttribute('data-a0'), 'v'); + assert_equals(innerDoc.shifted_item, img); +}, 'Removing earlier attributes during WillModifyAttribute in SetAttributeInternal should update the re-validated index'); + +test((t) => { + const container = document.getElementById('container'); + + const sandbox = document.createElement('iframe'); + sandbox.sandbox = 'allow-same-origin'; + container.appendChild(sandbox); + t.add_cleanup(() => sandbox.remove()); + + const innerDoc = sandbox.contentDocument; + const img = innerDoc.createElement('img'); + innerDoc.body.appendChild(img); + + // Name the child frame 'chrome' so that when LocalWindowProxy::Initialize() + // runs DidCreateScriptContext, content::GetOrCreateChromeObject() looks up + // window.chrome on innerDoc's global, gets child.contentWindow via named + // frame access, and sets 'loadTimes' on it, invoking the JS setter below + // synchronously inside WillModifyAttribute(). + const child = innerDoc.createElement('iframe'); + child.name = 'chrome'; + innerDoc.body.appendChild(child); + + Object.defineProperty(child.contentWindow, 'loadTimes', { + configurable: true, + set() { + img.setAttribute('name', 'inner_name'); + for (let i = 0; i < 20; i++) { + img.setAttribute('data-a' + i, 'v'); + } + } + }); + + img.setAttribute('name', 'outer_name'); + assert_equals(img.getAttribute('name'), 'outer_name'); + let nameAttrCount = 0; + for (const attr of img.attributes) { + if (attr.name === 'name') { + nameAttrCount++; + } + } + assert_equals(nameAttrCount, 1); +}, 'Setting the same attribute re-entrantly during AppendAttributeInternal should not create duplicate attributes'); + +test((t) => { + const container = document.getElementById('container'); + const quirksFrame = document.createElement('iframe'); + container.appendChild(quirksFrame); + t.add_cleanup(() => quirksFrame.remove()); + + const quirksDoc = quirksFrame.contentDocument; + quirksDoc.open(); + quirksDoc.write('<div id="MyUpperId" class="MyUpperClass"></div>'); + quirksDoc.close(); + assert_equals(quirksDoc.compatMode, 'BackCompat'); + + const div = quirksDoc.getElementById('MyUpperId'); + document.adoptNode(div); + assert_equals(div.id, 'MyUpperId'); + assert_equals(div.className, 'MyUpperClass'); +}, 'Adopting an element with uppercase id and class between quirks and standards mode documents'); +</script>