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.
Tracker536512612
Fix commit6fb5bf539857 (chromium/src) +98/-5
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-19 (per vendor advisory)
Fix landed2026-07-31 (public git)
Disclosed2026-08-06
Patch gap6 days — fix public in source before the advisory

Background

Argument massager
a JS function registered with APIEventHandler::RegisterArgumentMassager that rewrites an extension event’s arguments before they are dispatched, and that receives a native dispatch callback to hand the rewritten arguments back to C++.
`emitters` map
a per-context map in APIEventPerContextData (reached via data->emitters) from event name to a v8::Global<v8::Object> that wraps the EventEmitter for that event; the map is cleared when the context is invalidated.
Context invalidation
tearing down a script context (e.g. a frame detaching), which runs APIEventHandler::InvalidateContext and binding::InvalidateContext and frees the per-context binding state; binding::IsContextValid reports whether this has happened.
`gin::Converter<v8::LocalVector<v8::Value>>::FromV8`
converts a JS array into a vector of values by reading its elements, which can call user-defined getters (for example one placed on Object.prototype['0']).

Root Cause Analysis

The vulnerable code is DispatchEvent in extensions/renderer/bindings/api_event_handler.cc, the native dispatch callback that an argument massager calls. The hunk does not name it, but it is the enclosing function in the file. After it looked up the event’s entry in data->emitters, the old code kept a reference into the map (v8::Global<v8::Object>& v8_emitter = iter->second;). It then converted the massager-supplied info[0] array with FromV8 under a CHECK, and only afterwards read v8_emitter.Get(isolate) to recover the EventEmitter. The conversion re-enters JS: the regression test plants a getter on Object.prototype['0'] and calls dispatch with a sparse array, so reading index 0 runs that getter. If the getter invalidates the context, the emitters map is cleared and the v8::Global that v8_emitter refers to is destroyed, so the later v8_emitter.Get(isolate) reads freed memory.

The fix makes three changes: it copies the emitter into a v8::Local<v8::Object> before any JS can run, it replaces the CHECK with an early return when conversion fails, and it adds if (!binding::IsContextValid(context)) return; after the conversion, so nothing touches the emitter or fires the event on a torn-down context.

Key insight
The code held a reference into a per-context container across a call that can re-enter JS and destroy that container. The fix snapshots the value into a v8::Local before the re-entrant call and checks that the context is still valid afterwards.

Attack Path

  1. Prepare a re-entrant getter Script in a context that has extension bindings defines a getter for index '0' on Object.prototype, as the regression test does.
  2. Trigger an event that has a massager An extension event with a registered argument massager is dispatched into that context, and the massager calls the native dispatch callback with an array whose element 0 is missing (so lookup falls through to the prototype).
  3. Re-enter during conversion FromV8 reads index 0, runs the getter, and the getter synchronously invalidates the context. The commit’s comment gives detaching an iframe as an example. Invalidation clears data->emitters.
  4. Dangling use Before the patch, execution then reaches v8_emitter.Get(isolate) through the stale reference into the freed map entry, which is a use-after-free in the renderer. Turning this into memory corruption beyond the stale read is (inferred).

Impact Assessment

The bug is a use-after-free in the renderer process, in extension binding code. An attacker who wins it gets renderer memory corruption, and possible code execution inside the sandboxed renderer is (inferred). It requires attacker script in the same JS context as the extension bindings, an extension event that uses an argument massager being dispatched to that context, and a way to invalidate the context synchronously from a getter. The commit itself calls the scenario “theoretical”. It is not shown that arbitrary web content in a stock build can reach it, since that depends on which contexts expose massaged events (for example extension pages, or pages given extension APIs). The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 7.5. Separately, before the patch a getter that throws during conversion would hit the CHECK and crash the renderer.

Changed Functions

FunctionChangeNotes
TEST_F
extensions/renderer/bindings/api_event_handler_unittest.cc
modified
FunctionFromString
extensions/renderer/bindings/api_event_handler_unittest.cc
modified

Files Changed

  • extensions/renderer/bindings/api_event_handler.cc
  • extensions/renderer/bindings/api_event_handler_unittest.cc

Audit Directions

  • References into per-context maps across JS calls
    In extensions/renderer/bindings, look for iter->second references or raw pointers into per-context data (emitters, listeners, request maps) that are held while gin/V8 conversion, Call, or property access runs. Copy the value to a Local or re-look it up afterwards.
  • Missing validity checks after `FromV8`/getters
    Any native callback that converts JS values (arrays, dictionaries) can run user getters. Check that each one re-checks binding::IsContextValid (or an equivalent liveness signal) before using context-scoped state.
  • `CHECK` on conversions of untrusted JS input
    Look for CHECK(gin::Converter<...>::FromV8(...)) on values that script can shape. These turn script-triggered exceptions into crashes and usually also mark spots where re-entrancy was never considered.
From 6fb5bf539857775c56dbbf57a7e11abd1683c6e8 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <[email protected]>
Date: Fri, 31 Jul 2026 15:53:38 -0700
Subject: [PATCH] [Extensions] Handle context destruction during event dispatch

Theoretically, malicious script could run as part of event dispatch,
and could trigger context invalidation.

Fix this and add a regression test.

Bug: 536512612
Change-Id: Iaf2436a2413af78489583d7b758fb80c08f6472c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8178928
Commit-Queue: Devlin Cronin <[email protected]>
Reviewed-by: Justin Lulejian <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1672149}
---
diff --git a/extensions/renderer/bindings/api_event_handler.cc b/extensions/renderer/bindings/api_event_handler.cc
index cfec6e7..aa947647 100644
--- a/extensions/renderer/bindings/api_event_handler.cc
+++ b/extensions/renderer/bindings/api_event_handler.cc
@@ -19,6 +19,7 @@
 #include "base/values.h"
 #include "content/public/renderer/v8_value_converter.h"
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
+#include "extensions/renderer/bindings/api_binding_util.h"
 #include "extensions/renderer/bindings/api_response_validator.h"
 #include "extensions/renderer/bindings/event_emitter.h"
 #include "extensions/renderer/bindings/get_per_context_data.h"
@@ -110,15 +111,24 @@
   if (iter == data->emitters.end()) {
     return;
   }
-  v8::Global<v8::Object>& v8_emitter = iter->second;
+  v8::Local<v8::Object> v8_emitter = iter->second.Get(isolate);
 
+  // Converting `info[0]` to a vector of arguments can fail if script execution
+  // (such as running getters during property conversion) throws an exception.
   v8::LocalVector<v8::Value> args(isolate);
-  CHECK(gin::Converter<v8::LocalVector<v8::Value>>::FromV8(isolate, info[0],
-                                                           &args));
+  if (!gin::Converter<v8::LocalVector<v8::Value>>::FromV8(isolate, info[0],
+                                                          &args)) {
+    return;
+  }
+
+  // The conversion above re-enters JS (e.g., via getters on array properties)
+  // which can synchronously invalidate the context (e.g., detaching an iframe).
+  if (!binding::IsContextValid(context)) {
+    return;
+  }
 
   EventEmitter* emitter = nullptr;
-  gin::Converter<EventEmitter*>::FromV8(isolate, v8_emitter.Get(isolate),
-                                        &emitter);
+  gin::Converter<EventEmitter*>::FromV8(isolate, v8_emitter, &emitter);
   CHECK(emitter);
   // Note: It's safe to use EventEmitter::FireSync() here because this should
   // only be triggered from a JS call, so we know JS is running.
diff --git a/extensions/renderer/bindings/api_event_handler_unittest.cc b/extensions/renderer/bindings/api_event_handler_unittest.cc
index 1b71f034..7fce198 100644
--- a/extensions/renderer/bindings/api_event_handler_unittest.cc
+++ b/extensions/renderer/bindings/api_event_handler_unittest.cc
@@ -15,11 +15,13 @@
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
 #include "extensions/renderer/bindings/api_binding_test.h"
 #include "extensions/renderer/bindings/api_binding_test_util.h"
+#include "extensions/renderer/bindings/api_binding_util.h"
 #include "extensions/renderer/bindings/exception_handler.h"
 #include "extensions/renderer/bindings/test_js_runner.h"
 #include "gin/arguments.h"
 #include "gin/converter.h"
 #include "gin/public/context_holder.h"
+#include "gin/public/gin_embedders.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "v8/include/v8-object.h"
 #include "v8/include/v8-primitive.h"
@@ -1407,4 +1409,85 @@
   ::testing::Mock::VerifyAndClearExpectations(&change_handler);
 }
 
+// Tests the behavior of a context getting invalidated during event dispatch.
+// Regression test for https://crbug.com/536512612.
+TEST_F(APIEventHandlerTest, ContextInvalidationDuringEventDispatch) {
+  TestJSRunner::AllowErrors allow_errors;
+  v8::HandleScope handle_scope(isolate());
+  v8::Local<v8::Context> context = MainContext();
+  v8::Context::Scope context_scope(context);
+
+  const char kEventName[] = "alpha";
+  v8::Local<v8::Object> event = handler()->CreateEventInstance(
+      kEventName, /*supports_filters=*/false, /*supports_lazy_listeners=*/true,
+      binding::kNoListenerMax, /*notify_on_change=*/true, context);
+  ASSERT_FALSE(event.IsEmpty());
+
+  // Craft a JS function to invalidate the context directly and expose it on
+  // the global.
+  auto invalidate_context =
+      [](const v8::FunctionCallbackInfo<v8::Value>& info) {
+        v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();
+        APIEventHandler* handler =
+            static_cast<APIEventHandler*>(info.Data().As<v8::External>()->Value(
+                gin::kExternalPointerTypeTagDefaultTag));
+        handler->InvalidateContext(context);
+        binding::InvalidateContext(context);
+      };
+  v8::Local<v8::Function> invalidate_func =
+      v8::Function::New(
+          context, invalidate_context,
+          v8::External::New(isolate(), handler(),
+                            gin::kExternalPointerTypeTagDefaultTag))
+          .ToLocalChecked();
+  context->Global()
+      ->Set(context, gin::StringToSymbol(isolate(), "invalidateContext"),
+            invalidate_func)
+      .Check();
+
+  // An attacker script that defines a sneaky getter on index '0' for all
+  // objects in an effort to inject itself into our bindings. It then
+  // invalidates the context.
+  const char kAttackerScript[] = R"(
+    (function() {
+      Object.defineProperty(Object.prototype, '0', {
+        get: function() {
+          globalThis.invalidateContext();
+          return 'foo';
+        },
+        configurable: true
+      });
+    })
+  )";
+  v8::Local<v8::Function> attacker_script =
+      FunctionFromString(context, kAttackerScript);
+  RunFunction(attacker_script, context, 0, nullptr);
+
+  // An unsuspecting argument massager that accidentally triggers the attacker
+  // getter.
+  const char kArgumentMassager[] = R"(
+    (function(originalArgs, dispatch) {
+        let args = [];
+        args.length = 1;
+        dispatch(args);
+    });
+    )";
+  v8::Local<v8::Function> massager =
+      FunctionFromString(context, kArgumentMassager);
+  handler()->RegisterArgumentMassager(context, kEventName, massager);
+
+  v8::Local<v8::Function> listener_function =
+      FunctionFromString(context, "(function() {})");
+  AddListener(context, listener_function, event);
+
+  const char kArguments[] = "[{}]";
+  base::ListValue event_args = ListValueFromString(kArguments);
+  // Dispatching the event will invoke the massager, which calls `dispatch()`.
+  // `dispatch` attempts to convert `args` via `FromV8()`, which triggers the
+  // getter on index '0'. The getter calls `invalidateContext()`, invalidating
+  // the context and clearing emitters.
+  handler()->FireEventInContext(kEventName, context, event_args, nullptr);
+  EXPECT_FALSE(binding::IsContextValid(context));
+}
+
 }  // namespace extensions
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/extensions/renderer/bindings/api_event_handler_unittest.cc b/extensions/renderer/bindings/api_event_handler_unittest.cc
index 1b71f034..7fce198 100644
--- a/extensions/renderer/bindings/api_event_handler_unittest.cc
+++ b/extensions/renderer/bindings/api_event_handler_unittest.cc
@@ -15,11 +15,13 @@
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
 #include "extensions/renderer/bindings/api_binding_test.h"
 #include "extensions/renderer/bindings/api_binding_test_util.h"
+#include "extensions/renderer/bindings/api_binding_util.h"
 #include "extensions/renderer/bindings/exception_handler.h"
 #include "extensions/renderer/bindings/test_js_runner.h"
 #include "gin/arguments.h"
 #include "gin/converter.h"
 #include "gin/public/context_holder.h"
+#include "gin/public/gin_embedders.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "v8/include/v8-object.h"
 #include "v8/include/v8-primitive.h"
@@ -1407,4 +1409,85 @@
   ::testing::Mock::VerifyAndClearExpectations(&change_handler);
 }
 
+// Tests the behavior of a context getting invalidated during event dispatch.
+// Regression test for https://crbug.com/536512612.
+TEST_F(APIEventHandlerTest, ContextInvalidationDuringEventDispatch) {
+  TestJSRunner::AllowErrors allow_errors;
+  v8::HandleScope handle_scope(isolate());
+  v8::Local<v8::Context> context = MainContext();
+  v8::Context::Scope context_scope(context);
+
+  const char kEventName[] = "alpha";
+  v8::Local<v8::Object> event = handler()->CreateEventInstance(
+      kEventName, /*supports_filters=*/false, /*supports_lazy_listeners=*/true,
+      binding::kNoListenerMax, /*notify_on_change=*/true, context);
+  ASSERT_FALSE(event.IsEmpty());
+
+  // Craft a JS function to invalidate the context directly and expose it on
+  // the global.
+  auto invalidate_context =
+      [](const v8::FunctionCallbackInfo<v8::Value>& info) {
+        v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();
+        APIEventHandler* handler =
+            static_cast<APIEventHandler*>(info.Data().As<v8::External>()->Value(
+                gin::kExternalPointerTypeTagDefaultTag));
+        handler->InvalidateContext(context);
+        binding::InvalidateContext(context);
+      };
+  v8::Local<v8::Function> invalidate_func =
+      v8::Function::New(
+          context, invalidate_context,
+          v8::External::New(isolate(), handler(),
+                            gin::kExternalPointerTypeTagDefaultTag))
+          .ToLocalChecked();
+  context->Global()
+      ->Set(context, gin::StringToSymbol(isolate(), "invalidateContext"),
+            invalidate_func)
+      .Check();
+
+  // An attacker script that defines a sneaky getter on index '0' for all
+  // objects in an effort to inject itself into our bindings. It then
+  // invalidates the context.
+  const char kAttackerScript[] = R"(
+    (function() {
+      Object.defineProperty(Object.prototype, '0', {
+        get: function() {
+          globalThis.invalidateContext();
+          return 'foo';
+        },
+        configurable: true
+      });
+    })
+  )";
+  v8::Local<v8::Function> attacker_script =
+      FunctionFromString(context, kAttackerScript);
+  RunFunction(attacker_script, context, 0, nullptr);
+
+  // An unsuspecting argument massager that accidentally triggers the attacker
+  // getter.
+  const char kArgumentMassager[] = R"(
+    (function(originalArgs, dispatch) {
+        let args = [];
+        args.length = 1;
+        dispatch(args);
+    });
+    )";
+  v8::Local<v8::Function> massager =
+      FunctionFromString(context, kArgumentMassager);
+  handler()->RegisterArgumentMassager(context, kEventName, massager);
+
+  v8::Local<v8::Function> listener_function =
+      FunctionFromString(context, "(function() {})");
+  AddListener(context, listener_function, event);
+
+  const char kArguments[] = "[{}]";
+  base::ListValue event_args = ListValueFromString(kArguments);
+  // Dispatching the event will invoke the massager, which calls `dispatch()`.
+  // `dispatch` attempts to convert `args` via `FromV8()`, which triggers the
+  // getter on index '0'. The getter calls `invalidateContext()`, invalidating
+  // the context and clearing emitters.
+  handler()->FireEventInContext(kEventName, context, event_args, nullptr);
+  EXPECT_FALSE(binding::IsContextValid(context));
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by [email protected]

UAF in Extension API event dispatch


Report description

UAF in Extension API 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/api_event_handler.cc;l=109-121;bpv=0;bpt=0


The problem

Please describe the technical details of the vulnerability

VULNERABILITY DETAILS

Summary

DispatchEvent() stores a reference to a v8::Global from the emitter map and then calls FromV8() on event arguments, which can reenter JS. During reentrancy, the extension context can be torn down, triggering emitters.clear() and freeing that map. When control returns, DispatchEvent() uses the stale v8::Global again via Get(), causing a use-after-free.

Details

Through FromV8(), Array conversion performs observable indexed property access and can synchronously execute JS. An getter controlled by extension removes its own iframe during this conversion. Context teardown clears the emitter map and frees the referenced map.

auto iter = data->emitters.find(event_name);
if (iter == data->emitters.end()) {
  return;
}
v8::Global<v8::Object>& v8_emitter = iter->second;

v8::LocalVector<v8::Value> args(isolate);
CHECK(gin::Converter<v8::LocalVector<v8::Value>>::FromV8(isolate, info[0],
                                                         &args));

EventEmitter* emitter = nullptr;
gin::Converter<EventEmitter*>::FromV8(isolate, v8_emitter.Get(isolate),
                                      &emitter);

To call DispatchEvent(), printerProvider extension API is used.

The sequence is:

printerProvider event dispatch
  -> save reference to the emitter map node
  -> convert argument array
  -> execute attacker-controlled getter
  -> remove the extension iframe
  -> synchronous ScriptContext invalidation
  -> APIEventHandler::InvalidateContext()
  -> emitters.clear() frees the map node
  -> return to DispatchEvent()
  -> stale v8_emitter.Get() dereference

REPRODUCTION CASE

Run Chrome:

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/poc0 \
--load-extension=/path/to/extension \
about:blank

Open chrome://newtab to load top.html in the extension context

Open Print Preview (Ctrl + P) and click See more… on the destination section

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 vulnerability enables a renderer-side native use-after-free triggered via extension-controlled event dispatch


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