Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Bug ClassUAF
Tracker536676756
Fix commit11d511606e9d (chromium/src) +138/-0
CISA KEVNot listed
Credited@bean5oup
Disclosed2026-08-11

Changed Functions

FunctionChangeNotes
get
chrome/browser/extensions/native_bindings_apitest.cc
modified

Files Changed

  • chrome/browser/extensions/native_bindings_apitest.cc
  • extensions/renderer/bindings/event_emitter.cc
From 11d511606e9d75ff9149f7b10fddd5e6af55af4a Mon Sep 17 00:00:00 2001
From: Justin Lulejian <[email protected]>
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 <[email protected]>
Commit-Queue: Justin Lulejian <[email protected]>
Auto-Submit: Justin Lulejian <[email protected]>
Reviewed-by: Devlin Cronin <[email protected]>
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",
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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?

https://source.chromium.org/chromium/chromium/src/+/586fb22d7314488f688c0d2d6b572e19ef3a880b:extensions/renderer/bindings/event_emitter.cc;l=243-319;bpv=0;bpt=0


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.

  1. event_stack_getter (UAF)

    chrome.storage.onChanged dispatch enters EventEmitter::DispatchSync() and snapshots listener state. When a listener throws, ExceptionHandler::HandleException() reads exception.stack. The stack getter 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 through JSRunner in EventEmitter::DispatchSync().

  2. event_stack_getter_webrequest_reclaim (reclaim)

    Before staring this version, set ASAN_OPTIONS="quarantine_size_mb=0:...". In the reclaim variant, an accessor-bearing extraInfo array is used in chrome.webRequest.onBeforeRequest.addListener(...). Controlled reads of those accessors provide attacker-controlled ArrayBuffer data. V8ValueConverter conversion materializes this into native base::BlobStorage objects, 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

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

View on issue tracker