High watch CVSS 7.5 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker536676756
Fix commit11d511606e9d (chromium/src) +138/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited@bean5oup
Reported2026-07-20 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-08-11
Patch gap12 days — fix public in source before the advisory

Background

`EventEmitter::DispatchSync`
The renderer-side extension-bindings code that loops over the JS listeners registered on an extension event (for example chrome.runtime.onMessage) and calls each one synchronously, collecting their return values.
`ExceptionHandler::HandleException`
Called when a listener throws. It inspects the exception, including its stack trace through v8::TryCatch, and can therefore run JS that the page defined, such as a stack getter.
`binding::IsContextValid`
Reports whether a v8::Context is still alive in the extension bindings system. When a frame is detached, its context is invalidated and the per-context data, such as the JSRunner, is freed.
`JSRunner`
Per-context extension helper used to call JS functions, which is why dispatching to further listeners needs it to still be alive.

Root Cause Analysis

In the listener loop of EventEmitter::DispatchSync (the function named in the commit message and test; the hunk itself does not show the enclosing function name), a listener that throws is passed to exception_handler_->HandleException(context, "Error in event handler", &try_catch). Then try_catch.Reset() runs and the loop continues to the next listener. The loop assumed the v8::Context stayed valid across HandleException. That assumption fails because reading the exception’s stack can call a user-defined stack getter, and that getter can detach the frame (for example with frameElement.remove()), which tears down the context and frees its per-context data. The next iteration then called the next listener using that freed data, which the commit describes as a heap-use-after-free read of a dangling JSRunner.

The fix adds a binding::IsContextValid(context) check right after HandleException. If the context has gone, it calls context.Clear() and breaks out of the loop before try_catch.Reset() and before any further listener runs, while still returning the values that earlier listeners produced.

Key insight
Exception reporting was treated as a side-effect-free step even though it can run arbitrary JS. Re-checking context validity after that re-entrant call and stopping the dispatch loop removes the use of freed per-context state.

Attack Path

  1. Register several listeners in a frame context Script in a frame with extension bindings adds at least three listeners to an event such as chrome.runtime.onMessage. The second listener throws an object whose stack property is a getter.
  2. Trigger synchronous dispatch An event fires (in the test, a one-time message from the parent extension page through chrome.runtime.sendMessage), and EventEmitter::DispatchSync starts calling the listeners in order.
  3. Destroy the context from inside error handling When ExceptionHandler::HandleException reads the thrown object’s stack, the getter runs again inside the handler and calls frameElement.remove(), which detaches the iframe, invalidates its v8::Context, and frees the per-context JSRunner.
  4. Use of freed data Before the fix, the loop went on to the third listener and used the dangling per-context data, causing a renderer heap-use-after-free read. Turning this into a controlled read/write or code execution is (inferred) and not shown by the patch.

Impact Assessment

The bug is a heap use-after-free in a renderer process that hosts extension bindings, reached during synchronous event dispatch. The regression test reaches it with script running in an extension’s own pages (an extension page and an extension iframe), so the attacker needs JS running in a context that has extension event bindings, such as a malicious extension or an attacker who can run script in such a context. The patch does not show ordinary web content in a stock build reaching it. The advisory gives only “Use after free in Extensions”. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 7.5. Any use of extension privileges that follows, is (inferred), and no severity or in-the-wild status is stated in the provided metadata.

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

Audit Directions

  • Calls that can run JS inside native loops
    Audit other bindings loops (for example APIEventHandler, the promise/callback dispatch in APIRequestHandler, and ModuleSystem calls) that call into V8, or inspect exceptions through TryCatch::StackTrace/Message, and then keep using cached v8::Local<v8::Context> or per-context pointers without re-checking binding::IsContextValid.
  • Exception inspection as a hidden way back into JS
    Anywhere Chrome reads stack, message, or calls ToString on a thrown value (error reporting, console logging, ExceptionHandler) can run getters the page defined. Check that callers treat these as calls that may destroy the context or frame.
  • Frame detachment inside synchronous dispatch
    Look for frameElement.remove()-style teardown during other synchronous dispatch paths, such as messaging OneTimeMessageHandler, GinPort events and NativeExtensionBindingsSystem hooks, where per-context objects are freed while a caller further up the stack still holds raw pointers to them.
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/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",
                                           &try_catch);
+      // `ExceptionHandler:HandleException()` can run arbitrary JS code (e.g. if
+      // the thrown error defines a custom `stack` property getter), which might
+      // invalidate and tear down `context`. In this case, bail out to avoid
+      // executing remaining listeners on a destroyed context or dereferencing
+      // freed per-context data. We `break` so that any previous listeners
+      // return values are sent back to interested callers.
+      if (!binding::IsContextValid(context)) {
+        context.Clear();
+        break;
+      }
       try_catch.Reset();
     }
   }
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…

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