High chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in XML
DescriptionMemory corruption in XML
ComponentXML
Sub-componentBlink>XML
Bug ClassMemory Corruption
Tracker553121395
Fix commit14df19403b03 (chromium/src) +304/-31
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
modified
FinishResponseListener
third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
modified
for
third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
modified
if
third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
modified
XMLDocumentParserSimTest
third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
modified
TEST_F
third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.h
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
  • third_party/blink/renderer/core/xml/build.gni
  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
From 14df19403b0358d99e58b0229df916db6372e56f Mon Sep 17 00:00:00 2001
From: Emmanuele Bassi <ebassi@igalia.com>
Date: Fri, 18 Sep 2026 09:36:37 -0700
Subject: [PATCH] Defer XMLDocumentParser::Finish() while inside ParseChunk

XMLDocumentParser buffers data that arrives while ParseChunk() is
running into pending_src_, but a Finish() call received during this
state—such as when a navigation body load completes synchronously under
a running script—previously invoked end() immediately and re-entered
the parser before the active chunk finished processing.

Treat an in-chunk Finish() call like the paused parser case by recording
it in finish_called_ and allowing DoWrite() to process completion after
the current chunk and any buffered data are parsed. Additionally, reset
finish_called_ in end() so that DoWrite() and ResumeParsing() do not
process the same deferred finish twice.

Bug: https://issues.chromium.org/issues/553121395#comment8
Change-Id: I8dca1106bf642003351bf56feb15f71c3b0e163e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419603
Reviewed-by: Dominik Röttsches <drott@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1700858}
---
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
index f41a90c..ffe3a59 100644
--- a/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
@@ -492,6 +492,10 @@
   if (parser_paused_)
     return;
 
+  // Any deferred Finish() is carried out below; reset the flag so that
+  // DoWrite() and ResumeParsing() do not call end() a second time.
+  finish_called_ = false;
+
   // StopParsing() calls InsertErrorMessageBlock() if there was a parsing
   // error. Avoid showing the error message block twice.
   // TODO(crbug.com/898775): Rationalize this.
@@ -525,10 +529,14 @@
   if (IsDetached())
     return;
 
-  if (parser_paused_)
+  // If ParseChunk() is on the stack, ending the document now would re-enter
+  // libxml2 (see DoWrite()). Defer exactly like the paused case; DoWrite()
+  // honors finish_called_ once the current chunk has been parsed.
+  if (parser_paused_ || in_parse_chunk_) {
     finish_called_ = true;
-  else
+  } else {
     end();
+  }
 }
 
 void XMLDocumentParser::InsertErrorMessageBlock() {
@@ -976,35 +984,59 @@
   // Protect the libxml context from deletion during a callback
   scoped_refptr<XMLParserContext> context = context_;
 
-  // libxml2's push parser is not re-entrant: xmlParseEndTag2 holds multiple
-  // raw pointers inside ctxt, and a nested xmlParseChunk can xmlRealloc()
-  // those buffers. Crash safely rather than corrupt the heap. (Append()
-  // routes re-entrant data to pending_src_ so this should be unreachable.)
-  CHECK(!in_parse_chunk_);
-  base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true);
+  {
+    // libxml2's push parser is not re-entrant: xmlParseEndTag2 holds multiple
+    // raw pointers inside ctxt, and a nested xmlParseChunk can xmlRealloc()
+    // those buffers. Crash safely rather than corrupt the heap. (Append()
+    // routes re-entrant data to pending_src_ and Finish() defers to
+    // finish_called_, so this should be unreachable.)
+    CHECK(!in_parse_chunk_);
+    base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true);
 
-  // libXML throws an error if you try to switch the encoding for an empty
-  // string.
-  if (parse_string.length()) {
-    XMLDocumentParserScope scope(GetDocument());
-    base::AutoReset<bool> encoding_scope(&is_currently_parsing8_bit_chunk_,
-                                         parse_string.Is8Bit());
-    ParseChunk(context->Context(), parse_string);
+    // libXML throws an error if you try to switch the encoding for an empty
+    // string.
+    if (parse_string.length()) {
+      XMLDocumentParserScope scope(GetDocument());
+      base::AutoReset<bool> encoding_scope(&is_currently_parsing8_bit_chunk_,
+                                           parse_string.Is8Bit());
+      ParseChunk(context->Context(), parse_string);
 
-    // JavaScript (which may be run under the parseChunk callstack) may
-    // cause the parser to be stopped or detached.
-    if (IsStopped())
-      return;
+      // JavaScript (which may be run under the parseChunk callstack) may
+      // cause the parser to be stopped or detached.
+      if (IsStopped() && !finish_called_) {
+        return;
+      }
+    }
+
+    // FIXME: Why is this here? And why is it after we process the passed
+    // source?
+    if (!IsStopped() && GetDocument()->SawDecodingError()) {
+      // If the decoder saw an error, report it as fatal (stops parsing)
+      TextPosition position(
+          OrdinalNumber::FromOneBasedInt(context->Context()->input->line),
+          OrdinalNumber::FromOneBasedInt(context->Context()->input->col));
+      HandleError(XMLErrors::kErrorTypeFatal, "Encoding error", position);
+    }
   }
 
-  // FIXME: Why is this here? And why is it after we process the passed
-  // source?
-  if (GetDocument()->SawDecodingError()) {
-    // If the decoder saw an error, report it as fatal (stops parsing)
-    TextPosition position(
-        OrdinalNumber::FromOneBasedInt(context->Context()->input->line),
-        OrdinalNumber::FromOneBasedInt(context->Context()->input->col));
-    HandleError(XMLErrors::kErrorTypeFatal, "Encoding error", position);
+  if (IsDetached() || parser_paused_) {
+    return;
+  }
+
+  // Data appended and a Finish() call received while ParseChunk() was on the
+  // stack above were deferred; take care of them now that the chunk has been
+  // parsed.
+  if (!pending_src_.IsEmpty()) {
+    SegmentedString rest = pending_src_;
+    pending_src_.Clear();
+    Append(rest.ToString());
+    if (IsDetached() || parser_paused_) {
+      return;
+    }
+  }
+
+  if (finish_called_ && pending_callbacks_.empty()) {
+    end();
   }
 }
 
@@ -1811,7 +1843,10 @@
 void XMLDocumentParser::DoEnd() {
   if (!IsStopped()) {
     if (context_) {
-      // Tell libxml we're done.
+      // Tell libxml we're done. The terminating chunk may run SAX callbacks,
+      // so it needs the same re-entrancy protection as DoWrite().
+      CHECK(!in_parse_chunk_);
+      base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true);
       {
         XMLDocumentParserScope scope(GetDocument());
         FinishParsing(Context());
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser.h b/third_party/blink/renderer/core/xml/parser/xml_document_parser.h
index d6fa920c..4781ff6 100644
--- a/third_party/blink/renderer/core/xml/parser/xml_document_parser.h
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser.h
@@ -229,9 +229,9 @@
   bool saw_first_element_;
   bool is_xhtml_document_;
   bool parser_paused_;
-  // Re-entrancy guard for DoWrite()/xmlParseChunk(). libxml2 push-parser
-  // contexts are not re-entrant; calling xmlParseChunk while already inside
-  // a SAX callback corrupts ctxt->pushTab/nsTab.
+  // Re-entrancy guard for DoWrite()/DoEnd()/xmlParseChunk(). libxml2
+  // push-parser contexts are not re-entrant; calling xmlParseChunk while
+  // already inside a SAX callback corrupts ctxt->pushTab/nsTab.
   bool in_parse_chunk_ = false;
   bool requesting_script_;
   bool finish_called_;
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
new file mode 100644
index 0000000..2f659db
--- /dev/null
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
@@ -0,0 +1,237 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/dom/events/event.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/html/html_element.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_test.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
+#include "third_party/blink/renderer/platform/wtf/text/strcat.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
+
+namespace blink {
+
+namespace {
+
+// Writes the given data (if any) to the response and then finishes it when
+// an event is dispatched. Dispatching the event from an inline script lets a
+// test deliver the end of the navigation body while XMLDocumentParser is
+// still parsing the chunk that contains the script.
+class FinishResponseListener final : public NativeEventListener {
+ public:
+  FinishResponseListener(SimRequest* response, const String& final_data)
+      : response_(response), final_data_(final_data) {}
+
+  void Invoke(ExecutionContext*, Event*) override {
+    EXPECT_FALSE(was_invoked_);
+    was_invoked_ = true;
+    if (!final_data_.empty()) {
+      response_->Write(final_data_);
+    }
+    response_->Finish();
+  }
+
+  bool WasInvoked() const { return was_invoked_; }
+
+ private:
+  SimRequest* const response_;
+  const String final_data_;
+  bool was_invoked_ = false;
+};
+
+constexpr int kNestingDepth = 24;
+
+// An inline script that reports the end of the response and records the
+// document's readyState right afterwards, followed by a deeply nested run of
+// elements with namespace declarations so that plenty of markup remains to
+// be parsed after the script.
+String BuildChunkAfterExternalScript(bool close_document) {
+  StringBuilder markup;
+  markup.Append(
+      "<script>"
+      "document.dispatchEvent(new Event('finish-response'));"
+      "document.documentElement.setAttribute('data-ready-state',"
+      " document.readyState);"
+      "</script>");
+  for (int i = 0; i < kNestingDepth; ++i) {
+    markup.Append("<e");
+    markup.AppendNumber(i);
+    markup.Append(" xmlns:n");
+    markup.AppendNumber(i);
+    markup.Append("=\"http://example.com/ns/");
+    markup.AppendNumber(i);
+    markup.Append("\">");
+  }
+  markup.Append("<p id=\"last\">x</p>");
+  for (int i = kNestingDepth - 1; i >= 0; --i) {
+    markup.Append("</e");
+    markup.AppendNumber(i);
+    markup.Append(">");
+  }
+  if (close_document) {
+    markup.Append("</body></html>");
+  }
+  return markup.ToString();
+}
+
+class XMLDocumentParserSimTest : public SimTest {};
+
+// The response of an XHTML navigation may finish while the parser is in the
+// middle of a chunk, e.g. when an inline script run by the parser causes the
+// remainder of the body to be consumed. The parser must defer the completion
+// until the chunk has been fully parsed instead of ending the document from
+// inside the script.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScript) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  // The external script pauses parsing until it has loaded.
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/String());
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  // Buffered while the parser waits for the external script.
+  main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/true));
+
+  // Resumes parsing; the buffered chunk above is parsed in one go and the
+  // inline script finishes the response from within it.
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
+
+  // The document must not have been finished while the inline script was
+  // still running.
+  EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+                AtomicString("data-ready-state")),
+            "loading");
+
+  // The markup following the inline script must have been parsed normally.
+  Element* last = GetDocument().getElementById(AtomicString("last"));
+  ASSERT_TRUE(last);
+  ASSERT_TRUE(last->parentElement());
+  EXPECT_EQ(last->parentElement()->localName(),
+            StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+  // The deferred completion must still finish the document.
+  EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Same as above, but the final piece of the body arrives together with the
+// completion while the parser is inside the chunk. The data must be parsed
+// before the document is finished.
+TEST_F(XMLDocumentParserSimTest, FinalDataAndLoadCompletionDuringInlineScript) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/"<p id=\"tail\">y</p></body></html>");
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/false));
+
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
+
+  EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+                AtomicString("data-ready-state")),
+            "loading");
+
+  Element* last = GetDocument().getElementById(AtomicString("last"));
+  ASSERT_TRUE(last);
+  ASSERT_TRUE(last->parentElement());
+  EXPECT_EQ(last->parentElement()->localName(),
+            StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+  // The data received together with the completion must have been parsed.
+  Element* tail = GetDocument().getElementById(AtomicString("tail"));
+  ASSERT_TRUE(tail);
+  EXPECT_EQ(tail->parentElement(), GetDocument().body());
+
+  EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Like LoadCompletionDuringInlineScript, but the markup after the inline
+// script is not well-formed: a stray end tag followed by a deep run of
+// unclosed elements with namespace declarations. The response finishing
+// during the inline script must not end the document from inside the chunk;
+// the parser has to fail the document in an orderly way afterwards.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScriptBadMarkup) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/String());
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  StringBuilder markup;
+  markup.Append(
+      "<script>"
+      "document.dispatchEvent(new Event('finish-response'));"
+      "document.documentElement.setAttribute('data-ready-state',"
+      " document.readyState);"
+      "</script>"
+      "</script>");
+  for (int i = 0; i < kNestingDepth; ++i) {
+    markup.Append("<e");
+    markup.AppendNumber(i);
+    markup.Append(" xmlns:n");
+    markup.AppendNumber(i);
+    markup.Append("=\"http://example.com/ns/");
+    markup.AppendNumber(i);
+    markup.Append("\">");
+  }
+  main_resource.Write(markup.ToString());
+
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
new file mode 100644
index 0000000..2f659db
--- /dev/null
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
@@ -0,0 +1,237 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/dom/events/event.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/html/html_element.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_test.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
+#include "third_party/blink/renderer/platform/wtf/text/strcat.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
+
+namespace blink {
+
+namespace {
+
+// Writes the given data (if any) to the response and then finishes it when
+// an event is dispatched. Dispatching the event from an inline script lets a
+// test deliver the end of the navigation body while XMLDocumentParser is
+// still parsing the chunk that contains the script.
+class FinishResponseListener final : public NativeEventListener {
+ public:
+  FinishResponseListener(SimRequest* response, const String& final_data)
+      : response_(response), final_data_(final_data) {}
+
+  void Invoke(ExecutionContext*, Event*) override {
+    EXPECT_FALSE(was_invoked_);
+    was_invoked_ = true;
+    if (!final_data_.empty()) {
+      response_->Write(final_data_);
+    }
+    response_->Finish();
+  }
+
+  bool WasInvoked() const { return was_invoked_; }
+
+ private:
+  SimRequest* const response_;
+  const String final_data_;
+  bool was_invoked_ = false;
+};
+
+constexpr int kNestingDepth = 24;
+
+// An inline script that reports the end of the response and records the
+// document's readyState right afterwards, followed by a deeply nested run of
+// elements with namespace declarations so that plenty of markup remains to
+// be parsed after the script.
+String BuildChunkAfterExternalScript(bool close_document) {
+  StringBuilder markup;
+  markup.Append(
+      "<script>"
+      "document.dispatchEvent(new Event('finish-response'));"
+      "document.documentElement.setAttribute('data-ready-state',"
+      " document.readyState);"
+      "</script>");
+  for (int i = 0; i < kNestingDepth; ++i) {
+    markup.Append("<e");
+    markup.AppendNumber(i);
+    markup.Append(" xmlns:n");
+    markup.AppendNumber(i);
+    markup.Append("=\"http://example.com/ns/");
+    markup.AppendNumber(i);
+    markup.Append("\">");
+  }
+  markup.Append("<p id=\"last\">x</p>");
+  for (int i = kNestingDepth - 1; i >= 0; --i) {
+    markup.Append("</e");
+    markup.AppendNumber(i);
+    markup.Append(">");
+  }
+  if (close_document) {
+    markup.Append("</body></html>");
+  }
+  return markup.ToString();
+}
+
+class XMLDocumentParserSimTest : public SimTest {};
+
+// The response of an XHTML navigation may finish while the parser is in the
+// middle of a chunk, e.g. when an inline script run by the parser causes the
+// remainder of the body to be consumed. The parser must defer the completion
+// until the chunk has been fully parsed instead of ending the document from
+// inside the script.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScript) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  // The external script pauses parsing until it has loaded.
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/String());
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  // Buffered while the parser waits for the external script.
+  main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/true));
+
+  // Resumes parsing; the buffered chunk above is parsed in one go and the
+  // inline script finishes the response from within it.
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
+
+  // The document must not have been finished while the inline script was
+  // still running.
+  EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+                AtomicString("data-ready-state")),
+            "loading");
+
+  // The markup following the inline script must have been parsed normally.
+  Element* last = GetDocument().getElementById(AtomicString("last"));
+  ASSERT_TRUE(last);
+  ASSERT_TRUE(last->parentElement());
+  EXPECT_EQ(last->parentElement()->localName(),
+            StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+  // The deferred completion must still finish the document.
+  EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Same as above, but the final piece of the body arrives together with the
+// completion while the parser is inside the chunk. The data must be parsed
+// before the document is finished.
+TEST_F(XMLDocumentParserSimTest, FinalDataAndLoadCompletionDuringInlineScript) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/"<p id=\"tail\">y</p></body></html>");
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/false));
+
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
+
+  EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+                AtomicString("data-ready-state")),
+            "loading");
+
+  Element* last = GetDocument().getElementById(AtomicString("last"));
+  ASSERT_TRUE(last);
+  ASSERT_TRUE(last->parentElement());
+  EXPECT_EQ(last->parentElement()->localName(),
+            StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+  // The data received together with the completion must have been parsed.
+  Element* tail = GetDocument().getElementById(AtomicString("tail"));
+  ASSERT_TRUE(tail);
+  EXPECT_EQ(tail->parentElement(), GetDocument().body());
+
+  EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Like LoadCompletionDuringInlineScript, but the markup after the inline
+// script is not well-formed: a stray end tag followed by a deep run of
+// unclosed elements with namespace declarations. The response finishing
+// during the inline script must not end the document from inside the chunk;
+// the parser has to fail the document in an orderly way afterwards.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScriptBadMarkup) {
+  SimRequest main_resource("https://example.com/test.xhtml",
+                           "application/xhtml+xml");
+  SimSubresourceRequest external_script("https://example.com/ext.js",
+                                        "text/javascript");
+
+  LoadURL("https://example.com/test.xhtml");
+
+  main_resource.Write(
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+      "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+      "<script src=\"ext.js\"></script>");
+
+  auto* listener = MakeGarbageCollected<FinishResponseListener>(
+      &main_resource, /*final_data=*/String());
+  GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+  StringBuilder markup;
+  markup.Append(
+      "<script>"
+      "document.dispatchEvent(new Event('finish-response'));"
+      "document.documentElement.setAttribute('data-ready-state',"
+      " document.readyState);"
+      "</script>"
+      "</script>");
+  for (int i = 0; i < kNestingDepth; ++i) {
+    markup.Append("<e");
+    markup.AppendNumber(i);
+    markup.Append(" xmlns:n");
+    markup.AppendNumber(i);
+    markup.Append("=\"http://example.com/ns/");
+    markup.AppendNumber(i);
+    markup.Append("\">");
+  }
+  main_resource.Write(markup.ToString());
+
+  external_script.Complete("");
+  test::RunPendingTasks();
+
+  EXPECT_TRUE(listener->WasInvoked());
+
+  // The document must not have been finished while the inline script was
+  // still running, even though the markup afterwards fails to parse.
+  EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+                AtomicString("data-ready-state")),
+            "loading");
+
+  // The malformed document must still reach completion.
+  EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+}  // namespace
+
+}  // namespace blink
Loading diff…

Original Bug Report

reported by vm...@google.com

Heap UAF in libxml2 via XMLDocumentParser::Finish reentrancy guard bypass

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential reentrancy guard bypass in XMLDocumentParser::Finish allows xmlParseChunk to be called re-entrantly on an active libxml2 parser context during a suspended SAX callback. While XMLDocumentParser::Append and DoWrite protect against data reentrancy, Finish only checks parser_paused_ and proceeds to call end() -> DoEnd() unconditionally when unpaused. A nested parse chunk can reallocate ctxt->pushTab via xmlRealloc, leading to a use-after-free read in xmlParseEndTag2 and subsequent out-of-bounds heap operations in libxml2.

Affected files:

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser.h
  • third_party/blink/renderer/platform/loader/fetch/url_loader/navigation_body_loader.cc

Estimated timestamp from git blame: 2026-07-09

Potential heap UAF in libxml2 via XMLDocumentParser::Finish reentrancy guard bypass

Overview

Chromium’s XMLDocumentParser includes reentrancy protection against nested calls into libxml2’s push parser (xmlParseChunk). However, this protection covers only the data parsing entry points (Append and DoWrite). The document completion path (XMLDocumentParser::Finish) only checks whether parser_paused_ is true. If Finish() is invoked synchronously while the parser is inside an active xmlParseChunk call with parser_paused_ == false (for instance, during synchronous inline script execution under a SAX callback), Finish() invokes DoEnd(), which calls FinishParsing(Context()) -> xmlParseChunk(ctxt, nullptr, 0, 1) directly on the live parser context.

This re-entrant parse can reallocate internal libxml2 parser structures (such as ctxt->pushTab), leaving stale pointers in outer stack frames and resulting in a Use-After-Free (UAF) read and out-of-bounds heap operations.

Note: These steps and primitives are derived from static source code analysis; our automated tooling has not executed a live proof-of-concept run.

Affected Files

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser.h
  • third_party/libxml/src/parser.c
  • third_party/blink/renderer/platform/loader/fetch/url_loader/navigation_body_loader.cc

Root Cause Analysis

In third_party/blink/renderer/core/xml/parser/xml_document_parser.cc:

  1. Guarded data path:
// xml_document_parser.cc:423-426
if (parser_paused_ || in_parse_chunk_) {
  pending_src_.Append(source);
  return;
}
// xml_document_parser.cc:978-979
CHECK(!in_parse_chunk_);
base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true);
  1. Unguarded finish path:
// xml_document_parser.cc:514-527
void XMLDocumentParser::Finish() {
  Flush();
  if (IsDetached())
    return;
  if (parser_paused_)
    finish_called_ = true;
  else
    end(); // Missing check for in_parse_chunk_
}

end() (xml_document_parser.cc:477-484) calls DoEnd(), which bypasses DoWrite() and its CHECK(!in_parse_chunk_):

// xml_document_parser.cc:1809-1820
if (!IsStopped()) {
  if (context_) {
    {
      XMLDocumentParserScope scope(GetDocument());
      FinishParsing(Context()); // -> xmlParseChunk(ctxt, nullptr, 0, 1)
    }
    context_ = nullptr;
  }
}

Because the outer DoWrite() holds scoped_refptr<XMLParserContext> context = context_; (line 972), the libxml2 ctxt remains alive when DoEnd() clears context_ = nullptr.

  1. Inline script execution under SAX callback: When an inline <script> is processed in EndElementNs (xml_document_parser.cc:1343-1354), script_runner_->ProcessScriptElement() runs the script block synchronously. For inline scripts, IsWaitingForScripts() is false, so PauseParsing() is not called and parser_paused_ remains false while in_parse_chunk_ is true.

  2. Undeferral and Drain Chain: If the inline script opens a JavaScript dialog (e.g. alert()), ScopedPagePauser is created (chrome_client.cc:102). When the dialog is dismissed, ~ScopedPagePauser() unpauses the page (page.cc:723), which triggers LocalFrame::SetContextPaused(false) (local_frame.cc:3241-3256) -> NavigationBodyLoader::SetDefersLoading(kNone) (navigation_body_loader.cc:430-438), which synchronously invokes OnReadable(MOJO_RESULT_OK).

If the response pipe has closed and network completion arrived (OnComplete), OnReadable() finishes the stream and synchronously calls BodyLoadingFinished() -> DocumentLoader::FinishedLoading() -> XMLDocumentParser::Finish().

  1. libxml2 Memory Corruption: In third_party/libxml/src/parser.c:11091, xmlParseEndTag2(ctxt, &ctxt->pushTab[ctxt->nameNr - 1]) passes a pointer into ctxt->pushTab as tag. During the synchronous Finish() call, inner xmlParseChunk executes on ctxt. If the inner parse pushes additional elements exceeding ctxt->nameMax (initial 10), nameNsPush() calls xmlRealloc(ctxt->pushTab, ...) (line 2080), freeing the memory block where tag points.

When the outer xmlParseEndTag2 frame resumes after callback unwinding:

// third_party/libxml/src/parser.c:9424-9425
if (tag->nsNr != 0)
    xmlParserNsPop(ctxt, tag->nsNr);

tag->nsNr is read from freed heap memory (UAF read). If tag->nsNr holds a value greater than ctxt->nsNr, xmlParserNsPop (parser.c:1820-1846) iterates with a negative index i:

  • Reads out-of-bounds before ctxt->nsTab and ctxt->nsdb->extra.
  • Writes bucket->index = extra->oldIndex with OOB data.
  • Underflows ctxt->nsNr -= nr, causing subsequent namespace pushes to perform out-of-bounds heap writes.

Potential Steps to Trigger

  1. Host a top-level XHTML resource served as application/xhtml+xml.
  2. Stream Chunk A: XML prolog + root + <script src="ext.js"></script> to pause parsing while waiting for ext.js.
  3. Stream Chunk B: <script>alert(1)</script></script> followed by nested XML elements (<a1 xmlns:n1="u1">...<a64>). Because the parser is paused, Chunk B is buffered into pending_src_.
  4. Stream Chunk C: A body tail > 1 MiB (exceeding network::kMaxNumConsumedBytesInTask), then close the response so URLLoaderClient::OnComplete arrives while residual bytes remain in the data pipe.
  5. Respond to ext.js so PendingScriptFinished runs ResumeParsing() -> DoWrite(Chunk B) -> outer xmlParseChunk.
  6. The inline script executes alert(1) from within the SAX callback with parser_paused_ == false and in_parse_chunk_ == true.
  7. User dismisses the alert. ~ScopedPagePauser undeferral drains the remaining body and synchronously calls DocumentLoader::FinishedLoading() -> XMLDocumentParser::Finish().
  8. Finish() observes parser_paused_ == false and re-enters xmlParseChunk(ctxt, nullptr, 0, 1) via DoEnd(), triggering xmlRealloc on ctxt->pushTab and causing UAF memory corruption when alert() unwinds.

Suggested Fix

In XMLDocumentParser::Finish() (third_party/blink/renderer/core/xml/parser/xml_document_parser.cc), treat in_parse_chunk_ similarly to parser_paused_ by deferring the completion call:

void XMLDocumentParser::Finish() {
  Flush();
  if (IsDetached())
    return;

  if (parser_paused_ || in_parse_chunk_)
    finish_called_ = true;
  else
    end();
}

Ensure that when outer DoWrite() exits and finish_called_ is true (and !parser_paused_), end() is called after the outer ParseChunk() call stack completely unwinds.

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker