CVE-2026-119260
Overview
Background
- `ExecutionContext::DispatchErrorEvent`
- Reports an uncaught script error (
ErrorEvent) to listeners and, viaExceptionThrown, 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 whilein_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
HeapVectorcan 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).
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
- Enable a reporting sink that runs script
The vulnerable re-entry requires
ExceptionThrownto execute page JS; the diff and test use the DevTools Runtime domain (Runtime.enable) withError.prepareStackTrace(inferred for a pure-web attacker — reachability outside DevTools is not proven by the diff). - Throw an initial uncaught error
Page raises an exception, entering
DispatchErrorEvent→ExceptionThrown. - Re-enter during reporting
The reporting path runs script (e.g.
prepareStackTrace) that throws again;DispatchErrorEventre-enters andemplace_backs ontopending_exceptions_while the outer drain loop iterates it. - Reallocate the queue
The append grows/reallocates the
HeapVectorbacking store, freeing the array the loop references. - 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/execution_context/execution_context.cc |
modified | |
forthird_party/blink/renderer/core/execution_context/execution_context.cc |
modified | |
ifthird_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.jsthird_party/blink/renderer/core/execution_context/execution_context.hthird_party/blink/web_tests/inspector-protocol/runtime/runtime-exception-thrown-nested-expected.txtthird_party/blink/renderer/core/execution_context/execution_context.cc
Audit Directions
- Mutation during iteration of Heap containersAudit other sites that iterate a
HeapVector/HeapHashMapby reference while calling into code (event dispatch, inspector, GC callbacks) that can append to the same container. - Re-entrancy through `ExceptionThrown`/inspector hooksReview every reporting/probe path (
probe::, Runtime domain) that can synchronously run page script mid-operation for re-entrant state corruption. - Queue-drain patternsLook for “iterate then clear” drain loops on member vectors that should instead
std::movethe queue out first and guard against re-entrant refill.
Patch
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();
+})
Regression Test / PoC
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();
+})