Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Bindings
DescriptionUse after free in Bindings
ComponentBindings
Sub-componentBlink>Bindings, Blink>HTML>Parser
Bug ClassUAF
Tracker563673584
Fix commit64fab9307cf2 (chromium/src) +79/-12
CISA KEVNot listed
CreditedAlessandro Rizzo (0xAlessandro)
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
modified
for
third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
modified

Files Changed

  • third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
  • third_party/blink/renderer/core/html/parser/html_construction_site.cc
From 64fab9307cf2698c55fef3b21cde884f76f1113d Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
Date: Mon, 28 Sep 2026 12:03:11 -0700
Subject: [PATCH] Avoid stale entry reference in ReconstructTheActiveFormattingElements

HTMLConstructionSite::ReconstructTheActiveFormattingElements() held an
Entry& into active_formatting_elements_ across AttachOrFosterParent().
Because DOM insertion is synchronous, script executed during attachment
(e.g. via DOMFocusOut from fieldset child changes) can re-enter the
parser through document.write() and reallocate
active_formatting_elements_. The loop would then write through the
stale reference.

This CL updates ReconstructTheActiveFormattingElements() to avoid
holding references across AttachOrFosterParent(). It stores the
HTMLStackItem pointer before attachment and safely looks up the entry
again after attachment before calling ReplaceElement(). The loop is
also converted to check IndexOfFirstUnopenFormattingElement() on each
iteration so modifications made during re-entrancy are handled safely.

Fixed: 566136674,563673584
Change-Id: I30f0ff76deb6a0462c84d12e6db8a4f4bc1e5c96
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8469828
Reviewed-by: David Baron <dbaron@chromium.org>
Commit-Queue: Mason Freed <masonf@chromium.org>
Auto-Submit: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1706325}
---
diff --git a/third_party/blink/renderer/core/html/parser/html_construction_site.cc b/third_party/blink/renderer/core/html/parser/html_construction_site.cc
index 8d3333db..b97249b 100644
--- a/third_party/blink/renderer/core/html/parser/html_construction_site.cc
+++ b/third_party/blink/renderer/core/html/parser/html_construction_site.cc
@@ -1463,19 +1463,26 @@
 
 void HTMLConstructionSite::ReconstructTheActiveFormattingElements() {
   unsigned first_unopen_element_index;
-  if (!IndexOfFirstUnopenFormattingElement(first_unopen_element_index)) {
-    return;
-  }
-
-  unsigned unopen_entry_index = first_unopen_element_index;
-  for (; unopen_entry_index < active_formatting_elements_.size();
-       ++unopen_entry_index) {
-    HTMLFormattingElementList::Entry& unopened_entry =
-        active_formatting_elements_.at(unopen_entry_index);
-    HTMLStackItem* reconstructed =
-        CreateElementFromSavedToken(unopened_entry.StackItem());
+  while (IndexOfFirstUnopenFormattingElement(first_unopen_element_index)) {
+    HTMLStackItem* unopened_item =
+        active_formatting_elements_.at(first_unopen_element_index).StackItem();
+    HTMLStackItem* reconstructed = CreateElementFromSavedToken(unopened_item);
+    // AttachOrFosterParent() can execute script synchronously (e.g. via blur
+    // handlers on foster parenting), which might re-enter the parser and
+    // reallocate active_formatting_elements_. Avoid holding references to an
+    // HTMLFormattingElementList::Entry across the call and re-find the entry
+    // afterwards.
     AttachOrFosterParent(reconstructed);
-    unopened_entry.ReplaceElement(reconstructed);
+    if (first_unopen_element_index < active_formatting_elements_.size() &&
+        active_formatting_elements_.at(first_unopen_element_index)
+                .StackItem() == unopened_item) {
+      active_formatting_elements_.at(first_unopen_element_index)
+          .ReplaceElement(reconstructed);
+    } else if (HTMLFormattingElementList::Entry* entry =
+                   active_formatting_elements_.Find(
+                       unopened_item->GetElement())) {
+      entry->ReplaceElement(reconstructed);
+    }
   }
 }
 
diff --git a/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html b/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
new file mode 100644
index 0000000..5fab4ad59
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<link rel="author" href="mailto:masonf@chromium.org">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#reconstruct-the-active-formatting-elements">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+if (window.testRunner) {
+  testRunner.setWindowFocus(true);
+}
+
+function parseInIframe(t, html) {
+  const iframe = document.createElement("iframe");
+  document.documentElement.appendChild(iframe);
+  t.add_cleanup(() => iframe.remove());
+  return new Promise((resolve) => {
+    iframe.onload = () => resolve(iframe);
+    const doc = iframe.contentDocument;
+    doc.open();
+    doc.write(html);
+    doc.close();
+  });
+}
+
+promise_test(async (t) => {
+  const iframe = await parseInIframe(
+    t,
+    `<!DOCTYPE html>
+    <fieldset>
+      <legend><input id="inp"></legend>
+      <table>
+        <b>
+        <script>
+        window.focusOutFired = false;
+        const inp = document.getElementById("inp");
+        inp.addEventListener("focusout", () => {
+          window.focusOutFired = true;
+          let s = "";
+          for (let i = 0; i < 50; ++i) {
+            s += "<b a" + i + ">";
+          }
+          document.write(s);
+        }, { once: true });
+        window.focus();
+        inp.focus();
+        inp.disabled = true;
+        <\/script>
+        <tbody>
+        <img>
+      </table>
+    </fieldset>`,
+  );
+
+  const doc = iframe.contentDocument;
+  const win = iframe.contentWindow;
+  assert_true(win.focusOutFired, "focusout should have fired during foster-parented element attachment");
+  assert_equals(doc.querySelectorAll("[a0]").length, 1, "Injected formatting elements should not be duplicated");
+}, "ReconstructTheActiveFormattingElements reentrancy check, via document.write");
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html b/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
new file mode 100644
index 0000000..5fab4ad59
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/syntax/parsing/reconstruct-active-formatting-reentrant-document-write.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<link rel="author" href="mailto:masonf@chromium.org">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#reconstruct-the-active-formatting-elements">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+if (window.testRunner) {
+  testRunner.setWindowFocus(true);
+}
+
+function parseInIframe(t, html) {
+  const iframe = document.createElement("iframe");
+  document.documentElement.appendChild(iframe);
+  t.add_cleanup(() => iframe.remove());
+  return new Promise((resolve) => {
+    iframe.onload = () => resolve(iframe);
+    const doc = iframe.contentDocument;
+    doc.open();
+    doc.write(html);
+    doc.close();
+  });
+}
+
+promise_test(async (t) => {
+  const iframe = await parseInIframe(
+    t,
+    `<!DOCTYPE html>
+    <fieldset>
+      <legend><input id="inp"></legend>
+      <table>
+        <b>
+        <script>
+        window.focusOutFired = false;
+        const inp = document.getElementById("inp");
+        inp.addEventListener("focusout", () => {
+          window.focusOutFired = true;
+          let s = "";
+          for (let i = 0; i < 50; ++i) {
+            s += "<b a" + i + ">";
+          }
+          document.write(s);
+        }, { once: true });
+        window.focus();
+        inp.focus();
+        inp.disabled = true;
+        <\/script>
+        <tbody>
+        <img>
+      </table>
+    </fieldset>`,
+  );
+
+  const doc = iframe.contentDocument;
+  const win = iframe.contentWindow;
+  assert_true(win.focusOutFired, "focusout should have fired during foster-parented element attachment");
+  assert_equals(doc.querySelectorAll("[a0]").length, 1, "Injected formatting elements should not be duplicated");
+}, "ReconstructTheActiveFormattingElements reentrancy check, via document.write");
+</script>
Loading diff…

Original Bug Report

reported by al...@gmail.com

UAF in HTMLConstructionSite::ReconstructTheActiveFormattingElements(): stale Entry& ref

Steps to reproduce the problem

  1. Put the attached poc.html in an empty directory and serve it: python3 -m http.server 8000

  2. Release build, no flags: “/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary”
    –user-data-dir=/tmp/afe –no-first-run Open http://127.0.0.1:8000/poc.html and keep that window focused. The renderer dies while parsing. 3 out of 3 runs on Canary 156.0.8066.0.

  3. ASAN build, for a symbolized stack. Official prebuilt, nothing patched: https://commondatastorage.googleapis.com/chromium-browser-asan/mac-release-arm64/asan-mac-release-1701404.zip ASAN_OPTIONS=detect_leaks=0:handle_segv=2:print_stacktrace=1:symbolize=1:fast_unwind_on_fatal=0
    ASAN_SYMBOLIZER_PATH=<unzipped>/llvm-symbolizer
    “<unzipped>/Chromium.app/Contents/MacOS/Chromium”
    –user-data-dir=/tmp/afe-asan –no-first-run –no-sandbox
    –enable-features=HeapVectorPromptlyFree
    –disable-in-process-stack-traces
    “http://127.0.0.1:8000/poc.html?pre=2048” ASAN reports SEGV. 5 out of 5 runs.

Notes:

  • The window must be focused. The trigger is a blur on a focused element.
  • –enable-features=HeapVectorPromptlyFree is only needed on developer builds, which apply testing/variations/fieldtrial_testing_config.json; that file has a “HeapVectorPromptlyFree” study whose only experiment is “Disabled”, on every platform. –disable-field-trial-config works too. Branded Chrome needs no flags.
  • ?pre= is only how many objects the page pre-allocates to shape the heap. 2048 suits an ASAN heap, the 8192 default suits a release heap.
  • –disable-in-process-stack-traces and handle_segv=2 only make ASAN print the stack instead of Chromium’s own handler.

Problem Description

ReconstructTheActiveFormattingElements() holds a raw C++ reference into the active_formatting_elements_ HeapVector across a DOM insertion that runs author script, then stores through it.

// third_party/blink/renderer/core/html/parser/html_construction_site.cc (main, 1464-1480) HTMLFormattingElementList::Entry& unopened_entry = active_formatting_elements_.at(unopen_entry_index); // (1) raw ref into the vector HTMLStackItem* reconstructed = CreateElementFromSavedToken(unopened_entry.StackItem()); AttachOrFosterParent(reconstructed); // (2) synchronous, runs script unopened_entry.ReplaceElement(reconstructed); // (3) store through stale ref

If the script run by (2) appends to the list and crosses the vector’s capacity, the backing is reallocated and the old one is promptly freed. (3) then writes a Member<HTMLStackItem> into freed Oilpan memory.

(2) runs script because AttachOrFosterParent() -> Attach() -> Insert() now inserts immediately (html_construction_site.cc:330-379); AttachLater() no longer exists on main. Insert() calls ParserAppendChild() -> ChildrenChanged(), and HTMLFieldSetElement::ChildrenChanged() (html_field_set_element.cc:203-219) calls focused_element->blur() outside its EventDispatchForbiddenScope, so a blur handler runs inside the loop and can document.write() back into the parser.

The growth always relocates rather than growing in place: cppgc’s Grow() needs the object to sit immediately before the allocation buffer (explicit-management.cc:90-115), and InsertFormattingElement allocates the Element before appending, so an Element is always in between. The old backing goes to FreeVectorBacking() -> FreeUnreferencedObject(), a prompt synchronous free.

Result on the official ASAN prebuilt asan-mac-release-1701404.zip (Chromium 156.0.8067.0, main@1701404, unmodified), 5/5 runs, full log attached:

ERROR: AddressSanitizer: SEGV on unknown address 0x1008248d100a4845 The signal is caused by a READ memory access. #0 blink::(anonymous namespace)::v8_node_list::ItemOperationCallback(…) #16 blink::HTMLParserScriptRunner::ProcessScriptElementInternal(…) #19 blink::HTMLDocumentParser::PumpTokenizer()

That is the binding for NodeList.item(). The PoC reads item(0) on StaticNodeLists whose Member<Node> backing was reclaimed from the freed list backing; the stale ReplaceElement() stored a compressed HTMLStackItem* into a slot item() then reads as a Node*.

Controls:

official ASAN, –enable-features=HeapVectorPromptlyFree SEGV 5/5 official ASAN, –disable-features=HeapVectorPromptlyFree no crash, page completes Canary 156.0.8066.0, no flags renderer dies 3/3 Canary, –disable-features=HeapVectorPromptlyFree survives, page completes

With instrumented copies of the same page on Canary: same allocations but no focused input (no reentrancy) survives 3/3; reentrancy but no document.write growth (no realloc) survives; no heap grooming at all still kills the renderer.

A second attached PoC, poc_use_after_poison.html, places the same stale store at list index 7 and gives the write itself rather than its consequence: “use-after-poison, WRITE of size 4”, frame #0 ReconstructTheActiveFormattingElements()+0x474, reached from Document::write(). That one ran on a local build carrying a hand-ported copy of the current upstream attach ordering, because my checkout predates CL 7849912.

Summary

UAF in HTMLConstructionSite::ReconstructTheActiveFormattingElements(): stale Entry& ref

Custom Questions

Type of crash:

Tab (renderer process)

Crash state:

AddressSanitizer: SEGV on unknown address 0x1008248d100a4845, READ of size 8. Official ASAN prebuilt, unmodified: Chromium 156.0.8067.0, main@1701404, mac arm64, asan-mac-release-1701404.zip. 5 out of 5 runs.

ERROR: AddressSanitizer: SEGV on unknown address 0x1008248d100a4845 (pc 0x0003279d1678 bp 0x00016d2082e0 sp 0x00016d208240 T0) The signal is caused by a READ memory access. #0 blink::(anonymous namespace)::v8_node_list::ItemOperationCallback( v8::FunctionCallbackInfov8::Value const&)+0x278 #5 v8::internal::(anonymous namespace)::Invoke(…) #7 v8::Script::Run(…) #8 blink::V8ScriptRunner::RunCompiledScript(…) #13 blink::PendingScript::ExecuteScriptBlockInternal(…) #15 blink::ScriptLoader::PrepareScript(…) #16 blink::HTMLParserScriptRunner::ProcessScriptElementInternal(…) #18 blink::HTMLDocumentParser::RunScriptsForPausedTreeBuilder() #19 blink::HTMLDocumentParser::PumpTokenizer() SUMMARY: AddressSanitizer: SEGV … in v8_node_list::ItemOperationCallback

Full symbolized log attached as asan_official_156.0.8067.0.txt. ASAN reports no shadow bytes here because the faulting address is wild.

A second attached log, asan_ported_build_use_after_poison.txt, shows the write itself on a build carrying the current upstream attach ordering:

ERROR: AddressSanitizer: use-after-poison on address 0x7efc02051a7c WRITE of size 4 #0 blink::HTMLConstructionSite::ReconstructTheActiveFormattingElements()+0x474 #8 blink::HTMLDocumentParser::insert(…) #9 blink::Document::write(…) Shadow bytes: =>0x7efc02051a00: 00 00 … 00 f7 f7[f7] (f7 = user-poisoned)

Reporter credit:

Alessandro Rizzo (0xAlessandro)

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker