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 <[email protected]> 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 <[email protected]> Reviewed-by: Nate Chapin <[email protected]> Commit-Queue: Andrey Kosyakov <[email protected]> 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();
+})