CVE-2026-19558
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
getchrome/browser/extensions/native_bindings_apitest.cc |
modified |
Files Changed
chrome/browser/extensions/native_bindings_apitest.ccextensions/renderer/bindings/event_emitter.cc
Patch
From 11d511606e9d75ff9149f7b10fddd5e6af55af4a Mon Sep 17 00:00:00 2001
From: Justin Lulejian <jlulejian@chromium.org>
Date: Thu, 30 Jul 2026 13:05:24 -0700
Subject: [PATCH] [Extensions] Prevent UAF in EventEmitter::DispatchSync
Prior to this commit, an issue occurred during synchronous event
dispatch in EventEmitter::DispatchSync(). An event listener could throw
an exception where its custom stack getter invalidates and tears down
the v8::Context during exception handling
(ExceptionHandler::HandleException()). When this happened, the dispatch
loop continued without verifying if the context was still valid. As a
result, subsequent listeners in the loop attempted to execute using a
dangling, heap-freed JSRunner pointer, causing a renderer
heap-use-after-free read.
After this commit, EventEmitter::DispatchSync() checks whether the
v8::Context is still valid immediately after exception handling. If the
context was invalidated during error inspection, event dispatch safely
breaks out without attempting to dispatch to remaining listeners or
dereferencing freed per-context data. Any prior listener (if any) return
values are still returned to interested observers.
TAG=agy
CONV=7010bfb3-0b53-48c6-a45c-eefea11623ec
Fixed: 536676756
Change-Id: Ie4c03c7d65526f98f3b417b1d7a7367b89ae432b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8130654
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Justin Lulejian <jlulejian@chromium.org>
Auto-Submit: Justin Lulejian <jlulejian@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671354}
---
diff --git a/chrome/browser/extensions/native_bindings_apitest.cc b/chrome/browser/extensions/native_bindings_apitest.cc
index 137e264d..dd40875 100644
--- a/chrome/browser/extensions/native_bindings_apitest.cc
+++ b/chrome/browser/extensions/native_bindings_apitest.cc
@@ -16,6 +16,7 @@
#include "chrome/browser/renderer_context_menu/render_view_context_menu_test_util.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/test/base/ui_test_utils.h"
#include "components/sessions/content/session_tab_helper.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -29,6 +30,7 @@
#include "extensions/browser/extension_util.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/script_result_queue.h"
+#include "extensions/common/extension.h"
#include "extensions/common/extension_features.h"
#include "extensions/common/mojom/view_type.mojom.h"
#include "extensions/common/switches.h"
@@ -36,6 +38,7 @@
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "ui/base/window_open_disposition.h"
namespace extensions {
@@ -1247,4 +1250,129 @@
EXPECT_EQ("success", call_in_service_worker("verifyApiIsNotAvailable();"));
}
+// Tests that when an event listener throws an exception whose `stack` getter
+// removes the `iframe` (destroying its context), synchronous event dispatch
+// safely breaks out without a heap-use-after-free on the freed
+// `extensions::JSRunner`, while ensuring a one-time message sender receives
+// the response sent by a previous listener before context teardown. This is a
+// regression test for `crbug.com/536676756` when run on AddressSanitizer
+// builders. It is not limited to run just on AddressSanitizer builders so we
+// can get code coverage too.
+IN_PROC_BROWSER_TEST_F(NativeBindingsApiTest,
+ ListenersDestroyingFrameContextInErrorStackGetter) {
+ static constexpr char kManifest[] =
+ R"({
+ "name": "Events UAF Reproducer",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["storage"]
+ })";
+
+ static constexpr char kPageHtml[] =
+ R"(<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="page.js"></script>
+ </head>
+ <body>
+ <iframe id="test_frame" src="frame.html"></iframe>
+ </body>
+ </html>)";
+
+ static constexpr char kPageJs[] =
+ R"('use strict';
+
+ window.onload = function() {
+ // Send a one-time message to trigger `chrome.runtime.onMessage`.
+ chrome.runtime.sendMessage('test_message', function(response) {
+ chrome.test.sendMessage('got_response: ' + response);
+ });
+ };)";
+
+ static constexpr char kFrameHtml[] =
+ R"(<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="frame.js"></script>
+ </head>
+ <body></body>
+ </html>)";
+
+ static constexpr char kFrameJs[] =
+ R"('use strict';
+
+ // Add the first listener, which sends a reply to the sender via
+ // `sendResponse()` and also returns a value.
+ chrome.runtime.onMessage.addListener(
+ function firstListener(message, sender, sendResponse) {
+ sendResponse('first_listener_reply');
+ return 'first_listener_return_value';
+ });
+
+ // Add the second listener, which calls `sendResponse()` and then
+ // throws an error object with a custom `stack` property getter.
+ // When `extensions::ExceptionHandler::HandleException()` inspects the
+ // exception stack trace via `v8::TryCatch::StackTrace()`, the getter
+ // executes reentrantly and calls `frameElement.remove()`. That
+ // detaches the `iframe` from the DOM, destroys its `v8::Context`,
+ // and frees the per-context `extensions::JSRunner` data on the heap.
+ chrome.runtime.onMessage.addListener(
+ function secondListener(message, sender, sendResponse) {
+ sendResponse('second_listener_reply');
+ const exception = {};
+ Object.defineProperty(exception, 'stack', {
+ get() {
+ frameElement.remove();
+ },
+ });
+ throw exception;
+ });
+
+ // Add a third listener. Previously,
+ // `extensions::EventEmitter::DispatchSync()` failed to check
+ // `extensions::binding::IsContextValid()` after exception handling
+ // and proceeded to execute this third listener using the dangling
+ // `extensions::JSRunner` pointer, triggering a heap-use-after-free
+ // read. `extensions::EventEmitter::DispatchSync()` now breaks the
+ // loop without calling this third listener.
+ chrome.runtime.onMessage.addListener(
+ function thirdListener(message, sender, sendResponse) {
+ sendResponse('third_listener_reply');
+ });)";
+
+ // Write the extension manifest, html, and js test files to the test
+ // directory.
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("page.html"), kPageHtml);
+ test_dir.WriteFile(FILE_PATH_LITERAL("page.js"), kPageJs);
+ test_dir.WriteFile(FILE_PATH_LITERAL("frame.html"), kFrameHtml);
+ test_dir.WriteFile(FILE_PATH_LITERAL("frame.js"), kFrameJs);
+
+ // Set up a message listener to wait for the expected response from the
+ // first event listener.
+ ExtensionTestMessageListener response_listener(
+ /*expected_message=*/"got_response: first_listener_reply");
+
+ // Load the unpacked extension and assert that it loaded successfully.
+ const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Navigate to `page.html`, which loads the iframe and triggers one-time
+ // messaging via `ui_test_utils::NavigateToURLWithDisposition()`.
+ ASSERT_TRUE(ui_test_utils::NavigateToURLWithDisposition(
+ browser(), extension->GetResourceURL("page.html"),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP));
+
+ // Verify that the one-time message sender received the reply from
+ // `firstListener` even though `secondListener` destroyed the iframe context.
+ {
+ SCOPED_TRACE(
+ "Waiting for the one-time message sender to receive the reply from "
+ "firstListener");
+ EXPECT_TRUE(response_listener.WaitUntilSatisfied());
+ }
+}
+
} // namespace extensions
diff --git a/extensions/renderer/bindings/event_emitter.cc b/extensions/renderer/bindings/event_emitter.cc
index a0c9d2fc..f09040b 100644
--- a/extensions/renderer/bindings/event_emitter.cc
+++ b/extensions/renderer/bindings/event_emitter.cc
@@ -300,6 +300,16 @@
}
exception_handler_->HandleException(context, "Error in event handler",
Regression Test / PoC
diff --git a/chrome/browser/extensions/native_bindings_apitest.cc b/chrome/browser/extensions/native_bindings_apitest.cc
index 137e264d..dd40875 100644
--- a/chrome/browser/extensions/native_bindings_apitest.cc
+++ b/chrome/browser/extensions/native_bindings_apitest.cc
@@ -16,6 +16,7 @@
#include "chrome/browser/renderer_context_menu/render_view_context_menu_test_util.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/test/base/ui_test_utils.h"
#include "components/sessions/content/session_tab_helper.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -29,6 +30,7 @@
#include "extensions/browser/extension_util.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/script_result_queue.h"
+#include "extensions/common/extension.h"
#include "extensions/common/extension_features.h"
#include "extensions/common/mojom/view_type.mojom.h"
#include "extensions/common/switches.h"
@@ -36,6 +38,7 @@
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "ui/base/window_open_disposition.h"
namespace extensions {
@@ -1247,4 +1250,129 @@
EXPECT_EQ("success", call_in_service_worker("verifyApiIsNotAvailable();"));
}
+// Tests that when an event listener throws an exception whose `stack` getter
+// removes the `iframe` (destroying its context), synchronous event dispatch
+// safely breaks out without a heap-use-after-free on the freed
+// `extensions::JSRunner`, while ensuring a one-time message sender receives
+// the response sent by a previous listener before context teardown. This is a
+// regression test for `crbug.com/536676756` when run on AddressSanitizer
+// builders. It is not limited to run just on AddressSanitizer builders so we
+// can get code coverage too.
+IN_PROC_BROWSER_TEST_F(NativeBindingsApiTest,
+ ListenersDestroyingFrameContextInErrorStackGetter) {
+ static constexpr char kManifest[] =
+ R"({
+ "name": "Events UAF Reproducer",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["storage"]
+ })";
+
+ static constexpr char kPageHtml[] =
+ R"(<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="page.js"></script>
+ </head>
+ <body>
+ <iframe id="test_frame" src="frame.html"></iframe>
+ </body>
+ </html>)";
+
+ static constexpr char kPageJs[] =
+ R"('use strict';
+
+ window.onload = function() {
+ // Send a one-time message to trigger `chrome.runtime.onMessage`.
+ chrome.runtime.sendMessage('test_message', function(response) {
+ chrome.test.sendMessage('got_response: ' + response);
+ });
+ };)";
+
+ static constexpr char kFrameHtml[] =
+ R"(<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="frame.js"></script>
+ </head>
+ <body></body>
+ </html>)";
+
+ static constexpr char kFrameJs[] =
+ R"('use strict';
+
+ // Add the first listener, which sends a reply to the sender via
+ // `sendResponse()` and also returns a value.
+ chrome.runtime.onMessage.addListener(
+ function firstListener(message, sender, sendResponse) {
+ sendResponse('first_listener_reply');
+ return 'first_listener_return_value';
+ });
+
+ // Add the second listener, which calls `sendResponse()` and then
+ // throws an error object with a custom `stack` property getter.
+ // When `extensions::ExceptionHandler::HandleException()` inspects the
+ // exception stack trace via `v8::TryCatch::StackTrace()`, the getter
+ // executes reentrantly and calls `frameElement.remove()`. That
+ // detaches the `iframe` from the DOM, destroys its `v8::Context`,
+ // and frees the per-context `extensions::JSRunner` data on the heap.
+ chrome.runtime.onMessage.addListener(
+ function secondListener(message, sender, sendResponse) {
+ sendResponse('second_listener_reply');
+ const exception = {};
+ Object.defineProperty(exception, 'stack', {
+ get() {
+ frameElement.remove();
+ },
+ });
+ throw exception;
+ });
+
+ // Add a third listener. Previously,
+ // `extensions::EventEmitter::DispatchSync()` failed to check
+ // `extensions::binding::IsContextValid()` after exception handling
+ // and proceeded to execute this third listener using the dangling
+ // `extensions::JSRunner` pointer, triggering a heap-use-after-free
+ // read. `extensions::EventEmitter::DispatchSync()` now breaks the
+ // loop without calling this third listener.
+ chrome.runtime.onMessage.addListener(
+ function thirdListener(message, sender, sendResponse) {
+ sendResponse('third_listener_reply');
+ });)";
+
+ // Write the extension manifest, html, and js test files to the test
+ // directory.
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("page.html"), kPageHtml);
+ test_dir.WriteFile(FILE_PATH_LITERAL("page.js"), kPageJs);
+ test_dir.WriteFile(FILE_PATH_LITERAL("frame.html"), kFrameHtml);
+ test_dir.WriteFile(FILE_PATH_LITERAL("frame.js"), kFrameJs);
+
+ // Set up a message listener to wait for the expected response from the
+ // first event listener.
+ ExtensionTestMessageListener response_listener(
+ /*expected_message=*/"got_response: first_listener_reply");
+
+ // Load the unpacked extension and assert that it loaded successfully.
+ const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Navigate to `page.html`, which loads the iframe and triggers one-time
+ // messaging via `ui_test_utils::NavigateToURLWithDisposition()`.
+ ASSERT_TRUE(ui_test_utils::NavigateToURLWithDisposition(
+ browser(), extension->GetResourceURL("page.html"),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP));
+
+ // Verify that the one-time message sender received the reply from
+ // `firstListener` even though `secondListener` destroyed the iframe context.
+ {
+ SCOPED_TRACE(
+ "Waiting for the one-time message sender to receive the reply from "
+ "firstListener");
+ EXPECT_TRUE(response_listener.WaitUntilSatisfied());
+ }
+}
+
} // namespace extensions
Original Bug Report
Extension UAF due to reentrant exception.stack access during event dispatch
Report description
Extension UAF due to reentrant exception.stack access during event dispatch
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?
The problem
Please describe the technical details of the vulnerability
VULNERABILITY DETAILS
Summary
A renderer use-after-free occurs in EventEmitter::DispatchSync() when an extension event listener throws and triggers reentrant JS execution through exception.stack access. The reentrancy can tear down the extension context, clear per-context runner state, and then DispatchSync() continues execution using stale state, leading to a crash in a sandboxed extension renderer process.
Details
There are two PoC variants. One demonstrates a deterministic UAF, and the other demonstrates controllable memory reuse after reclaim.
-
event_stack_getter(UAF)chrome.storage.onChangeddispatch entersEventEmitter::DispatchSync()and snapshots listener state. When a listener throws,ExceptionHandler::HandleException()readsexception.stack. Thestackgetter reenters JS during dispatch. The reentrant execution removes the extension iframe, triggering context teardown. During teardown, the context runner (JSRunner) is cleared from per-context storage, but the event-dispatch flow continues and still uses stale dispatch state (EventEmitter,JSRunner), resulting in a use-after-free during dispatch. The UAF is observed at the synchronous dispatch call path that runs callbacks throughJSRunnerinEventEmitter::DispatchSync(). -
event_stack_getter_webrequest_reclaim(reclaim)Before staring this version, set
ASAN_OPTIONS="quarantine_size_mb=0:...". In the reclaim variant, an accessor-bearingextraInfoarray is used inchrome.webRequest.onBeforeRequest.addListener(...). Controlled reads of those accessors provide attacker-controlledArrayBufferdata.V8ValueConverterconversion materializes this into nativebase::BlobStorageobjects, allowing predictable native allocation behavior. After the stale-state UAF condition is triggered, this setup just shows reuse of the freed slot with controlled native data.
VERSION
Chromium: Tested on asan-linux-release-1664338, 152.0.7958.0
REPRODUCTION CASE
The following steps can be fully automated via the extension background service worker.
Run Chrome (for the second variant, add quarantine_size_mb=0:):
ASAN_OPTIONS="symbolize=1:print_stacktrace=1:halt_on_error=1:abort_on_error=0:detect_leaks=0:external_symbolizer_path=/path/to/llvm-symbolizer" \
/path/to/chrome \
--user-data-dir=/tmp/poc1 \
--load-extension=/path/to/extension/ \
about:blank
Open chrome://newtab to load top.html in the extension context
Open DevTools on the parent frame and run window.TRIGGER()
Without --no-sandbox, ASAN symbols were not fully resolved, so I used:
python3 /path/to/chromium/src/tools/valgrind/asan/asan_symbolize.py < /path/to/asan.log
Attached as poc.mov which shows the full repro steps.
Impact analysis
This demonstrates controlled native memory reuseof a stale object allocation after UAF. By itself, it does not yet establish full AAR/AAW.
The cause
What version of Chrome have you found the security issue in?
Chromium: Tested on asan-linux-release-1664338, 152.0.7958.0
Is the security issue related to a crash?
Yes, it is 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?
@bean5oup