High chrome UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Frames
DescriptionUse after free in Frames
ComponentFrames
Bug ClassUAF
Tracker573264074
Fix commitbd5ef225208e (chromium/src) +67/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-29

Background

`ExecutionContext::DispatchErrorEvent`
Reports an uncaught script error (ErrorEvent) to listeners and, via ExceptionThrown, to the inspector/Runtime domain. Nested errors raised while one is being reported are queued and drained afterward.
`pending_exceptions_`
A HeapVector<std::pair<Member<ErrorEvent>, SanitizeScriptErrors>> holding error events that arrived re-entrantly while in_dispatch_error_event_ was set, to be drained after the first error is reported.
`ExceptionThrown`
The reporting sink that forwards an error to DevTools’ Runtime domain; with the Runtime domain enabled it can synchronously run script (e.g. via Error.prepareStackTrace), which can raise further errors.
`in_dispatch_error_event_` / `is_draining_pending_exceptions_`
Re-entrancy flags on ExecutionContext; the latter is added by this patch.
HeapVector growth
Appending to a HeapVector can reallocate its backing store, invalidating references/iterators obtained before the append.

Root Cause Analysis

DispatchErrorEvent drained the queue with a range-based loop over pending_exceptions_ and only cleared it afterward:

for (const auto& [pending_event, pending_sanitize] : pending_exceptions_) {
  ExceptionThrown(pending_event, pending_sanitize);
}
pending_exceptions_.clear();

ExceptionThrown can re-enter the error-reporting machinery: with the inspector Runtime domain enabled, reporting an exception can run page script (the regression test drives this through Error.prepareStackTrace dispatching a nested event whose listener throws). That nested error path calls back into DispatchErrorEvent, which emplace_backs onto pending_exceptions_ while the outer loop is still iterating it. Appending can reallocate the HeapVector’s backing array, so the loop’s structured-binding references (pending_event, pending_sanitize) — and the iterator — dangle into freed storage, a use-after-free / iterator-invalidation during iteration. Unbounded re-entry can also recurse without limit.

The patch adds an is_draining_pending_exceptions_ guard. DispatchErrorEvent returns early if it is called while already draining, so re-entrant errors reported during drain are dropped rather than queued. It wraps both the initial ExceptionThrown and the drain loop in base::AutoReset(&is_draining_pending_exceptions_, true), and — critically — moves the queue out with auto pending_exceptions = std::move(pending_exceptions_) before iterating, so the loop walks a stable local vector while any (now-suppressed) re-entrant append would target the emptied member. A final CHECK(pending_exceptions_.empty()) asserts no new pending exceptions were added during drain.

The advisory’s “Use after free” label is consistent with the mechanism here: the freed memory is the reallocated HeapVector backing store whose elements the loop still references (freed-memory access inferred from the reallocation-on-append semantics, since the diff shows the mutation-during-iteration but not the allocator event directly).

Key insight
The pending-exception drain loop iterated pending_exceptions_ by reference while ExceptionThrown could run script that re-entered DispatchErrorEvent and appended to the very same vector, reallocating it and leaving the loop pointing at freed element storage.

Attack Path

  1. Enable a reporting sink that runs script The vulnerable re-entry requires ExceptionThrown to execute page JS; the diff and test use the DevTools Runtime domain (Runtime.enable) with Error.prepareStackTrace (inferred for a pure-web attacker — reachability outside DevTools is not proven by the diff).
  2. Throw an initial uncaught error Page raises an exception, entering DispatchErrorEvent → ExceptionThrown.
  3. Re-enter during reporting The reporting path runs script (e.g. prepareStackTrace) that throws again; DispatchErrorEvent re-enters and emplace_backs onto pending_exceptions_ while the outer drain loop iterates it.
  4. Reallocate the queue The append grows/reallocates the HeapVector backing store, freeing the array the loop references.
  5. Use-after-free The outer loop dereferences the dangling pending_event/binding, reading freed memory (primitive: UAF read in the renderer; exploitability as controlled corruption is inferred, not shown).

Impact Assessment

Renderer-process bug in Blink’s core execution-context error handling. The demonstrated trigger is through the inspector Runtime domain; whether an ordinary web page can drive the same re-entry (and thus the extent of unprivileged web-reachability) is not established by the diff. The primitive is a use-after-free / iterator invalidation of the pending_exceptions_ HeapVector during drain, plus potential unbounded recursion (DoS). Turning the UAF into a controlled read/write or RCE is not demonstrated by the patch and is inferred. No elevated privilege beyond script execution (and, per the test, inspector enablement) is required.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/execution_context/execution_context.cc
modified
for
third_party/blink/renderer/core/execution_context/execution_context.cc
modified
if
third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
modified

Files Changed

  • third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
  • third_party/blink/renderer/core/execution_context/execution_context.h
  • third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt
  • third_party/blink/renderer/core/execution_context/execution_context.cc

Audit Directions

  • Mutation during iteration of Heap containers
    Audit other sites that iterate a HeapVector/HeapHashMap by reference while calling into code (event dispatch, inspector, GC callbacks) that can append to the same container.
  • Re-entrancy through `ExceptionThrown`/inspector hooks
    Review every reporting/probe path (probe::, Runtime domain) that can synchronously run page script mid-operation for re-entrant state corruption.
  • Queue-drain patterns
    Look for “iterate then clear” drain loops on member vectors that should instead std::move the queue out first and guard against re-entrant refill.
From bd5ef225208e665908208a9123c5acdb0fea2993 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
Date: Thu, 22 Oct 2026 10:37:32 -0700
Subject: [PATCH] Avoid re-entry when draining pending error events

Fixed: 573264074
Change-Id: If8503bd2e9ca28fc5dd316d8aacb58ec168c6d38
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8622063
Reviewed-by: Michael Lippautz <mlippautz@chromium.org>
Reviewed-by: Nate Chapin <japhet@chromium.org>
Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1724943}
---
diff --git a/third_party/blink/renderer/core/execution_context/execution_context.cc b/third_party/blink/renderer/core/execution_context/execution_context.cc
index 5d6b121..0f95cf1 100644
--- a/third_party/blink/renderer/core/execution_context/execution_context.cc
+++ b/third_party/blink/renderer/core/execution_context/execution_context.cc
@@ -27,6 +27,7 @@
 
 #include "third_party/blink/renderer/core/execution_context/execution_context.h"
 
+#include "base/auto_reset.h"
 #include "base/metrics/histogram_functions.h"
 #include "base/tracing/protos/chrome_track_event.pbzero.h"
 #include "build/build_config.h"
@@ -277,21 +278,31 @@
 void ExecutionContext::DispatchErrorEvent(
     ErrorEvent* error_event,
     SanitizeScriptErrors sanitize_script_errors) {
+  if (is_draining_pending_exceptions_) {
+    // Disregard errors reported while draining pending exceptions. This would
+    // help to avoid potential infinite recursion and, given this only
+    // happens with with inspector Runtime domain enabled, somewhat reduces
+    // devtools side effects.
+    return;
+  }
   if (in_dispatch_error_event_) {
     pending_exceptions_.emplace_back(error_event, sanitize_script_errors);
     return;
   }
 
   // First report the original exception and only then all the nested ones.
-  if (!DispatchErrorEventInternal(error_event, sanitize_script_errors))
+  if (!DispatchErrorEventInternal(error_event, sanitize_script_errors)) {
+    base::AutoReset auto_reset(&is_draining_pending_exceptions_, true);
     ExceptionThrown(error_event, sanitize_script_errors);
+  }
 
-  if (pending_exceptions_.empty())
-    return;
-  for (const auto& [pending_event, pending_sanitize] : pending_exceptions_) {
+  base::AutoReset auto_reset(&is_draining_pending_exceptions_, true);
+  auto pending_exceptions = std::move(pending_exceptions_);
+  for (const auto& [pending_event, pending_sanitize] : pending_exceptions) {
     ExceptionThrown(pending_event, pending_sanitize);
   }
-  pending_exceptions_.clear();
+  // Assure handling pending exceptions doesn't add more pending exceptions.
+  CHECK(pending_exceptions_.empty());
 }
 
 bool ExecutionContext::DispatchErrorEventInternal(
diff --git a/third_party/blink/renderer/core/execution_context/execution_context.h b/third_party/blink/renderer/core/execution_context/execution_context.h
index 6c16b29..07c9dd9 100644
--- a/third_party/blink/renderer/core/execution_context/execution_context.h
+++ b/third_party/blink/renderer/core/execution_context/execution_context.h
@@ -541,6 +541,7 @@
   unsigned circular_sequential_id_;
 
   bool in_dispatch_error_event_;
+  bool is_draining_pending_exceptions_ = false;
   HeapVector<std::pair<Member<ErrorEvent>, SanitizeScriptErrors>>
       pending_exceptions_;
 
diff --git a/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt
new file mode 100644
index 0000000..ea3debc
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt
@@ -0,0 +1,5 @@
+Tests that nested exceptions thrown while reporting an exception to Runtime domain do not crash.
+<no description>
+Error: error listener 1
+Error: error listener 2
+
diff --git a/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
new file mode 100644
index 0000000..1ed21a0
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
@@ -0,0 +1,45 @@
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+  const {session, dp} = await testRunner.startHTML(
+      `
+    <script>
+      let nested = false;
+      Error.prepareStackTrace = (err, stack) => {
+        if (!nested) {
+          nested = true;
+          window.dispatchEvent(new Event('nested'));
+        }
+        return 'custom stack';
+      };
+
+      window.addEventListener('nested', () => {
+        throw new Error('from prepareStackTrace');
+      });
+
+      let firstError = true;
+      window.addEventListener('error', () => {
+        if (firstError) {
+          throw new Error('error listener 1');
+        }
+      });
+      window.addEventListener('error', () => {
+        if (firstError) {
+          firstError = false;
+          throw new Error('error listener 2');
+        }
+      });
+
+      window.addEventListener('trigger', () => {
+        throw 'initial';
+      });
+    </script>
+  `,
+      'Tests that nested exceptions thrown while reporting an exception to Runtime domain do not crash.');
+
+  await dp.Runtime.enable();
+  dp.Runtime.onExceptionThrown(event => {
+    testRunner.log(event.params.exceptionDetails.exception.description ||
+                   '<no description>');
+  });
+  await session.evaluate(`window.dispatchEvent(new Event('trigger'))`);
+  testRunner.completeTest();
+})
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt
new file mode 100644
index 0000000..ea3debc
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txt
@@ -0,0 +1,5 @@
+Tests that nested exceptions thrown while reporting an exception to Runtime domain do not crash.
+<no description>
+Error: error listener 1
+Error: error listener 2
+
diff --git a/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
new file mode 100644
index 0000000..1ed21a0
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested.js
@@ -0,0 +1,45 @@
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+  const {session, dp} = await testRunner.startHTML(
+      `
+    <script>
+      let nested = false;
+      Error.prepareStackTrace = (err, stack) => {
+        if (!nested) {
+          nested = true;
+          window.dispatchEvent(new Event('nested'));
+        }
+        return 'custom stack';
+      };
+
+      window.addEventListener('nested', () => {
+        throw new Error('from prepareStackTrace');
+      });
+
+      let firstError = true;
+      window.addEventListener('error', () => {
+        if (firstError) {
+          throw new Error('error listener 1');
+        }
+      });
+      window.addEventListener('error', () => {
+        if (firstError) {
+          firstError = false;
+          throw new Error('error listener 2');
+        }
+      });
+
+      window.addEventListener('trigger', () => {
+        throw 'initial';
+      });
+    </script>
+  `,
+      'Tests that nested exceptions thrown while reporting an exception to Runtime domain do not crash.');
+
+  await dp.Runtime.enable();
+  dp.Runtime.onExceptionThrown(event => {
+    testRunner.log(event.params.exceptionDetails.exception.description ||
+                   '<no description>');
+  });
+  await session.evaluate(`window.dispatchEvent(new Event('trigger'))`);
+  testRunner.completeTest();
+})
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.