Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Parser
DescriptionUse after free in Parser
ComponentParser
Sub-componentBlink>HTML>Parser
Bug ClassUAF
Tracker566136674
Fix commit64fab9307cf2 (chromium/src) +79/-12
CISA KEVNot listed
CreditedXinyang Ge (Anthropic), assisted by Claude
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 <[email protected]>
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 <[email protected]>
Commit-Queue: Mason Freed <[email protected]>
Auto-Submit: Mason Freed <[email protected]>
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:[email protected]">
+<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:[email protected]">
+<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 [email protected]

Use-after-free in HTMLConstructionSite::ReconstructTheActiveFormattingElements

Summary

HTMLConstructionSite::ReconstructTheActiveFormattingElements() holds an Entry& into the active-formatting-elements HeapVector across AttachOrFosterParent(). Since 4ddabf432e018 (2026-09-01) that insertion is synchronous, so a script it runs can re-enter the parser through document.write and reallocate the vector. The loop then writes through the stale reference. Renderer, zero interaction, default flags.

Environment

  • Chrome for Testing 155.0.8059.12 (Beta) and 156.0.8072.0 (Canary); code present at main b7c07f35773a. Stable 154 is not affected (predates the change).
  • Linux x86-64
  • Evidence build: ASan, Chromium main 156.0.8074.0

Reproduction

chrome --user-data-dir=/tmp/p --no-first-run poc.html

Zero interaction; the renderer crashes after about 2 s.

Rate: Beta 8/8 at default flags. On Chromium/Chrome for Testing 156, add --disable-field-trial-config (4/4 Canary): their test config disables kHeapVectorPromptlyFree, which Google Chrome enables by default.

Second, independent set-up. poc-fieldset.html (needs page focus) reaches the same store: during the synchronous insertion, HTMLFieldSetElement::ChildrenChanged blurs a focused, disabled input, and its DOMFocusOut handler calls document.write. Beta 6/6 at default flags, renderer SIGSEGV writing into freed memory; ASan in asan-fieldset.txt.

Evidence

Renderer, main thread. Full reports: asan.txt; asan-default.txt is ASan 156 without the switch (container-overflow WRITE).

use-after-poison WRITE of size 4
  #0 HTMLConstructionSite::ReconstructTheActiveFormattingElements   :1478
  #1 HTMLTreeBuilder::ProcessStartTagForInBody   html_tree_builder.cc:1059
  #2 HTMLTreeBuilder::ProcessStartTagForInTable  html_tree_builder.cc:1408

Stock Beta 155 dies at a CFI check on a virtual call through the stale data (renderer SIGILL).

Vulnerable code

third_party/blink/renderer/core/html/parser/html_construction_site.cc (main)

1473  HTMLFormattingElementList::Entry& unopened_entry =
1474      active_formatting_elements_.at(unopen_entry_index);      [1]
1477  AttachOrFosterParent(reconstructed);                         [2]
1478  unopened_entry.ReplaceElement(reconstructed);                [3]

[1] Reference into the backing. [2] Script can reallocate it. [3] Stale write.

Impact

Any web page, no interaction. Renderer use-after-free write, introduced in 155. Reporter’s estimate: High (S1). A separate defect (ad tracker) reaches the same store on Stable 154; reported in 566156384.

Credit

Xinyang Ge (Anthropic), assisted by Claude

Attachments: poc.html, asan.txt, asan-default.txt, poc-fieldset.html, asan-fieldset.txt, report.md

View on issue tracker