CVE-2026-19165
Overview
Background
- Argument massager
- a JS function registered with
APIEventHandler::RegisterArgumentMassagerthat rewrites an extension event’s arguments before they are dispatched, and that receives a nativedispatchcallback to hand the rewritten arguments back to C++. - `emitters` map
- a per-context map in
APIEventPerContextData(reached viadata->emitters) from event name to av8::Global<v8::Object>that wraps theEventEmitterfor 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::InvalidateContextandbinding::InvalidateContextand frees the per-context binding state;binding::IsContextValidreports 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.
v8::Local before the re-entrant call and checks that the context is still valid afterwards.Attack Path
- Prepare a re-entrant getter
Script in a context that has extension bindings defines a getter for index
'0'onObject.prototype, as the regression test does. - 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
dispatchcallback with an array whose element 0 is missing (so lookup falls through to the prototype). - Re-enter during conversion
FromV8reads index 0, runs the getter, and the getter synchronously invalidates the context. The commit’s comment gives detaching an iframe as an example. Invalidation clearsdata->emitters. - 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
CHECK and crash the renderer.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fextensions/renderer/bindings/api_event_handler_unittest.cc |
modified | |
FunctionFromStringextensions/renderer/bindings/api_event_handler_unittest.cc |
modified |
Files Changed
extensions/renderer/bindings/api_event_handler.ccextensions/renderer/bindings/api_event_handler_unittest.cc
Audit Directions
- References into per-context maps across JS callsIn
extensions/renderer/bindings, look foriter->secondreferences 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 aLocalor re-look it up afterwards. - Missing validity checks after `FromV8`/gettersAny 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 inputLook 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.
Patch
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
Regression Test / PoC
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
Original Bug Report
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?
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
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