CVE-2026-95275
Overview
Background
- ScriptState / V8 World (Realm)
- the per-frame, per-world JavaScript execution context that owns the objects it creates; a single document hosts a main world plus isolated worlds (e.g. extension content scripts), each with distinct realms.
- `ScriptPromiseResolver` / `ScriptPromise`
- Blink wrappers that mint a JS
Promisebound to a specificScriptStateand laterResolve()orReject()it in that same world. - `CropTarget.fromElement` / `RestrictionTarget.fromElement` (Region Capture)
- web APIs that ask the browser process (over Mojo) to mint a
SubCaptureTargettoken for anElement, returning aPromisewhile the request is in flight. - `crop_target_resolvers_` / `restriction_target_resolvers_`
HeapHashMaps keyed byElementthat cache the pending resolver(s) for thatElementuntil the browser-process Mojo response arrives.
Root Cause Analysis
The vulnerable path is the “duplicate call before promise resolution” branch of MediaDevices::ProduceCropTarget and MediaDevices::ProduceRestrictionTarget. Before the patch, each map stored exactly one resolver per Element (Member<ScriptPromiseResolver<...>>), and a second fromElement call for the same Element — arriving before the browser process responded — returned it->value->Promise(), i.e. the Promise object minted from the first caller’s ScriptPromiseResolver. That resolver was created with the first caller’s script_state, so its Promise lives in the first caller’s Realm/World; handing it back to a second caller in a different world violated V8 world isolation and leaked a cross-world object.
The patch changes the map value to a HeapVector<Member<ScriptPromiseResolver<...>>>: every caller now mints its own resolver bound to its own script_state, returns its own promise, and duplicate callers are appended to the per-Element queue via it->value.push_back(resolver). When the Mojo response arrives, the callback std::moves the whole vector out of the map and iterates it, calling Resolve()/Reject() on each queued resolver so each caller is settled in its own world. This is a correctness fix to reference resolution, not a tightening or relaxing of a validation check.
Element alone, with no partitioning by ScriptState, so duplicate callers shared one Promise object across worlds; the fix queues a distinct per-caller resolver/promise per Element and settles all of them together.Attack Path
- Set up two worlds over one document An attacker arranges script in two V8 worlds sharing the same frame/document, e.g. an isolated world (extension content script) and the main world, both able to call the Region Capture APIs.
- First caller starts a mint
World A calls
CropTarget.fromElement(el)(orRestrictionTarget.fromElement); a resolver is created with World A’sscript_stateand cached incrop_target_resolvers_keyed byel, while the browser-process Mojo request is in flight. - Second caller races before resolution
Before the browser responds, World B calls
fromElementon the sameel; the pre-patchfindbranch runsreturn it->value->Promise(), returning World A’sPromise. - Cross-world object leak
World B now holds a
Promiseobject minted in World A’s Realm, breaking the isolation boundary between the two worlds. - Downstream escalation (inferred)
Any use of the leaked cross-realm object for type confusion or further memory-safety consequences is not demonstrated by the diff and is
(inferred).
Impact Assessment
Promise object created in another world that shares the same frame, breaking Realm/world isolation within the renderer process. The preconditions are that two worlds (e.g. an isolated world and the main world) invoke CropTarget.fromElement/RestrictionTarget.fromElement on the same Element within the window before the browser process returns the token, with the Region Capture APIs available. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. The impact as incorrect reference resolution in MediaStream; it reports no exploited-in-the-wild status or severity, and any escalation beyond cross-world object leakage is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/modules/mediastream/media_devices.cc |
modified | |
ifthird_party/blink/renderer/modules/mediastream/media_devices_test.cc |
modified | |
TEST_Pthird_party/blink/renderer/modules/mediastream/media_devices_test.cc |
modified |
Files Changed
third_party/blink/renderer/modules/mediastream/media_devices_test.ccthird_party/blink/renderer/modules/mediastream/media_devices.ccthird_party/blink/renderer/modules/mediastream/media_devices.h
Audit Directions
- Per-DOM-key caching without `ScriptState` partitioningaudit any container keyed only by a DOM object (
Element,Node, etc.) that stashesScriptPromiseResolvers or other JS objects and returns them to later callers, verifying each caller receives a wrapper minted in its own world. - Shared-frame multi-world APIsreview Blink module APIs reachable from isolated worlds (extensions) that memoize in-flight async results, ensuring the returned
Promise/wrapper always matches the callingScriptState’s Realm. - Fan-out on async Mojo callbackswhere one browser-process response settles cached promises, confirm every queued resolver is settled exactly once, in its own context, and that none is dropped or reused across worlds.
Patch
From 3297fecca21b31f7a288d3061784d324aa22d059 Mon Sep 17 00:00:00 2001 From: Tove Petersson <[email protected]> Date: Thu, 20 Aug 2026 02:33:44 -0700 Subject: [PATCH] [Region Capture] Fix cross-world Promise leak in ProduceSubCaptureTarget CropTarget.fromElement and RestrictionTarget.fromElement cached pending ScriptPromiseResolver instances keyed by Element without partitioning by ScriptState. If a second caller in a different V8 Realm / World called fromElement on the same Element before the browser process responded to the initial Mojo request, it received a Promise created in the first caller's Realm / World, causing cross-world object leakage. This CL updates MediaDevices to create distinct ScriptPromiseResolvers bound to each caller's ScriptState and queue them per Element until the Mojo callback resolves or rejects all queued resolvers. TAG=agy CONV=2060f502-4f3e-4756-8051-e9bd4b006e92 Bug: 542926849 Change-Id: Ibeecde72087bf96da82e50b9ebb025430eacb369 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8263669 Reviewed-by: Guido Urdaneta <[email protected]> Commit-Queue: Tove Petersson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1682978} --- diff --git a/third_party/blink/renderer/modules/mediastream/media_devices.cc b/third_party/blink/renderer/modules/mediastream/media_devices.cc index 4fe2cb4..6bf7953 100644 --- a/third_party/blink/renderer/modules/mediastream/media_devices.cc +++ b/third_party/blink/renderer/modules/mediastream/media_devices.cc @@ -1066,6 +1066,10 @@ return promise; } + auto* resolver = MakeGarbageCollected<ScriptPromiseResolver<CropTarget>>( + script_state, exception_state.GetContext()); + const ScriptPromise<CropTarget> promise = resolver->Promise(); + const auto it = crop_target_resolvers_.find(element); if (it != crop_target_resolvers_.end()) { // The Element does not yet have the SubCaptureTarget attached, @@ -1075,15 +1079,14 @@ RecordUma( SubCaptureTarget::Type::kCropTarget, ProduceTargetFunctionResult::kDuplicateCallBeforePromiseResolution); - return it->value->Promise(); + it->value.push_back(resolver); + return promise; } // Mints a new ID on the browser process. // Resolves after it has been produced and is ready to be used. - auto* resolver = MakeGarbageCollected<ScriptPromiseResolver<CropTarget>>( - script_state, exception_state.GetContext()); - crop_target_resolvers_.insert(element, resolver); - const ScriptPromise<CropTarget> promise = resolver->Promise(); + crop_target_resolvers_.insert( + element, HeapVector<Member<ScriptPromiseResolver<CropTarget>>>{resolver}); LocalDOMWindow* const window = To<LocalDOMWindow>(GetExecutionContext()); CHECK(window); // Guaranteed by MayProduceSubCaptureTarget() earlier. @@ -1125,6 +1128,11 @@ return promise; } + auto* resolver = + MakeGarbageCollected<ScriptPromiseResolver<RestrictionTarget>>( + script_state, exception_state.GetContext()); + const ScriptPromise<RestrictionTarget> promise = resolver->Promise(); + const auto it = restriction_target_resolvers_.find(element); if (it != restriction_target_resolvers_.end()) { // The Element does not yet have the SubCaptureTarget attached, @@ -1134,16 +1142,15 @@ RecordUma( SubCaptureTarget::Type::kRestrictionTarget, ProduceTargetFunctionResult::kDuplicateCallBeforePromiseResolution); - return it->value->Promise(); + it->value.push_back(resolver); + return promise; } // Mints a new ID on the browser process. // Resolves after it has been produced and is ready to be used. - auto* resolver = - MakeGarbageCollected<ScriptPromiseResolver<RestrictionTarget>>( - script_state, exception_state.GetContext()); - restriction_target_resolvers_.insert(element, resolver); - const ScriptPromise<RestrictionTarget> promise = resolver->Promise(); + restriction_target_resolvers_.insert( + element, + HeapVector<Member<ScriptPromiseResolver<RestrictionTarget>>>{resolver}); LocalDOMWindow* const window = To<LocalDOMWindow>(GetExecutionContext()); CHECK(window); // Guaranteed by MayProduceSubCaptureTarget() earlier. @@ -1622,19 +1629,24 @@ const auto it = restriction_target_resolvers_.find(element); CHECK_NE(it, restriction_target_resolvers_.end()); - ScriptPromiseResolver<RestrictionTarget>* const resolver = it->value; + HeapVector<Member<ScriptPromiseResolver<RestrictionTarget>>> resolvers = + std::move(it->value); restriction_target_resolvers_.erase(it); const base::Token token = SubCaptureTargetIdToToken(id); if (token.is_zero()) { - resolver->Reject(); + for (auto& resolver : resolvers) { + resolver->Reject(); + } RecordUma(SubCaptureTarget::Type::kRestrictionTarget, ProduceTargetPromiseResult::kPromiseRejected); return; } element->SetRestrictionTargetId(std::make_unique<RestrictionTargetId>(token)); - resolver->Resolve(MakeGarbageCollected<RestrictionTarget>(id)); + for (auto& resolver : resolvers) { + resolver->Resolve(MakeGarbageCollected<RestrictionTarget>(id)); + } RecordUma(SubCaptureTarget::Type::kRestrictionTarget, ProduceTargetPromiseResult::kPromiseResolved); } @@ -1699,19 +1711,24 @@ const auto it = crop_target_resolvers_.find(element); CHECK_NE(it, crop_target_resolvers_.end()); - ScriptPromiseResolver<CropTarget>* const resolver = it->value; + HeapVector<Member<ScriptPromiseResolver<CropTarget>>> resolvers = + std::move(it->value); crop_target_resolvers_.erase(it); const base::Token token = SubCaptureTargetIdToToken(id); if (token.is_zero()) { - resolver->Reject(); + for (auto& resolver : resolvers) { + resolver->Reject(); + } RecordUma(SubCaptureTarget::Type::kCropTarget, ProduceTargetPromiseResult::kPromiseRejected); return; } element->SetRegionCaptureCropId(std::make_unique<RegionCaptureCropId>(token)); - resolver->Resolve(MakeGarbageCollected<CropTarget>(id)); + for (auto& resolver : resolvers) { + resolver->Resolve(MakeGarbageCollected<CropTarget>(id)); + } RecordUma(SubCaptureTarget::Type::kCropTarget, ProduceTargetPromiseResult::kPromiseResolved); } diff --git a/third_party/blink/renderer/modules/mediastream/media_devices.h b/third_party/blink/renderer/modules/mediastream/media_devices.h index d3168aa92..4ddc90d 100644 --- a/third_party/blink/renderer/modules/mediastream/media_devices.h +++ b/third_party/blink/renderer/modules/mediastream/media_devices.h @@ -261,12 +261,13 @@ using ElementToRestrictionTargetResolverMap = HeapHashMap<Member<Element>, - Member<ScriptPromiseResolver<RestrictionTarget>>>; + HeapVector<Member<ScriptPromiseResolver<RestrictionTarget>>>>; ElementToRestrictionTargetResolverMap restriction_target_resolvers_; using ElementToCropTargetResolverMap = - HeapHashMap<Member<Element>, Member<ScriptPromiseResolver<CropTarget>>>; + HeapHashMap<Member<Element>, + HeapVector<Member<ScriptPromiseResolver<CropTarget>>>>; // 1. When CropTarget.fromElement() is first called for an Element, // it has no CropTarget associated with it, and similarly for @@ -277,13 +278,14 @@ // implementation of the CropTarget/RestrictionTarget object to which // the Promise will be resolved. // 2. Subsequent calls to X.fromElement() which occur before the browser - // process has had time to respond, yield a copy of the original Promise - // associated with this Element. (Distinctly for either X=CropTarget - // and X=RestrictionTarget.) + // process has had time to respond, create their own Resolver/Promise + // associated with their caller's ScriptState and are queued in this + // container. (Distinctly for either X=CropTarget and + // X=RestrictionTarget.) // 3. When the browser process responds with a base::Token, we store it // on the Element itself, resolve all Promises returned for this Element, - // and eject the resolver from this container. (Note again that CropTarget - // and RestrictionTarget are handled separately here.) + // and eject the resolver list from this container. (Note again that + // CropTarget and RestrictionTarget are handled separately here.) // 4. Later calls to X.fromElement() for this given Element discover that // a token has already been assigned. They immediately return a resolved // Promise with the relevant token. diff --git a/third_party/blink/renderer/modules/mediastream/media_devices_test.cc b/third_party/blink/renderer/modules/mediastream/media_devices_test.cc index 268ed63..69337cf 100644 --- a/third_party/blink/renderer/modules/mediastream/media_devices_test.cc +++ b/third_party/blink/renderer/modules/mediastream/media_devices_test.cc @@ -61,6 +61,7 @@ #include "third_party/blink/renderer/modules/mediastream/media_device_info.h" #include "third_party/blink/renderer/modules/mediastream/media_permission_testing_platform.h" #include "third_party/blink/renderer/modules/mediastream/restriction_target.h" +#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h" #include "third_party/blink/renderer/platform/bindings/exception_state.h" #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h" @@ -1608,6 +1609,153 @@ EXPECT_EQ(first_result, second_result); } +TEST_P(ProduceSubCaptureTargetTest, + DuplicateCallBeforePromiseResolutionSameWorld) { + V8TestingScope scope; + auto* media_devices = GetMediaDevices(*GetDocument().domWindow()); + ASSERT_TRUE(media_devices); + + dispatcher_host().SetNextId(type_, + String("983bf2ff-7410-416c-808a-78421cbd8fdc")); + + SetBodyContent(R"HTML( + <div id='test-div'></div> + )HTML"); + + Document& document = GetDocument(); + Element* const div = document.getElementById(AtomicString("test-div")); + + v8::Local<v8::Promise> first_v8_promise; + v8::Local<v8::Promise> second_v8_promise; + std::optional<ScriptPromiseTester> first_tester; + std::optional<ScriptPromiseTester> second_tester; + + if (type_ == SubCaptureTarget::Type::kCropTarget) { + auto p1 = media_devices->ProduceCropTarget(scope.GetScriptState(), div, + scope.GetExceptionState()); + first_v8_promise = p1.V8Promise(); + first_tester.emplace(scope.GetScriptState(), p1); + + auto p2 = media_devices->ProduceCropTarget(scope.GetScriptState(), div, + scope.GetExceptionState()); + second_v8_promise = p2.V8Promise(); + second_tester.emplace(scope.GetScriptState(), p2); + } else { + auto p1 = media_devices->ProduceRestrictionTarget( + scope.GetScriptState(), div, scope.GetExceptionState()); + first_v8_promise = p1.V8Promise(); + first_tester.emplace(scope.GetScriptState(), p1); + + auto p2 = media_devices->ProduceRestrictionTarget( + scope.GetScriptState(), div, scope.GetExceptionState()); + second_v8_promise = p2.V8Promise(); + second_tester.emplace(scope.GetScriptState(), p2); + } + + // The two promises must be distinct objects. + EXPECT_FALSE(first_v8_promise.IsEmpty()); + EXPECT_FALSE(second_v8_promise.IsEmpty()); + EXPECT_NE(first_v8_promise, second_v8_promise); + + first_tester->WaitUntilSettled(); + second_tester->WaitUntilSettled(); + + EXPECT_TRUE(first_tester->IsFulfilled()); + EXPECT_TRUE(second_tester->IsFulfilled()); + EXPECT_FALSE(scope.GetExceptionState().HadException()); + + const String first_result = + ToSubCaptureTarget(first_tester->Value())->GetId(); + const String second_result = + ToSubCaptureTarget(second_tester->Value())->GetId(); + + EXPECT_EQ(first_result, "983bf2ff-7410-416c-808a-78421cbd8fdc"); + EXPECT_EQ(first_result, second_result); +} + +TEST_P(ProduceSubCaptureTargetTest, + DuplicateCallBeforePromiseResolutionDifferentWorlds) { + V8TestingScope scope; + auto* media_devices = GetMediaDevices(*GetDocument().domWindow()); + ASSERT_TRUE(media_devices); + + dispatcher_host().SetNextId(type_, + String("983bf2ff-7410-416c-808a-78421cbd8fdc")); + + SetBodyContent(R"HTML( + <div id='test-div'></div> + )HTML"); + + Document& document = GetDocument(); + Element* const div = document.getElementById(AtomicString("test-div")); + + constexpr int32_t kIsolatedWorldId = 1; + DOMWrapperWorld* isolated_world = DOMWrapperWorld::EnsureIsolatedWorld( + scope.GetIsolate(), kIsolatedWorldId); + scope.GetFrame().GetWindowProxy(*isolated_world); // Force initialization. + ScriptState* isolated_script_state = + ToScriptState(&scope.GetFrame(), *isolated_world); + ASSERT_TRUE(isolated_script_state); + + // First call from the isolated world. + std::optional<ScriptPromiseTester> isolated_tester; + { + ScriptState::Scope isolated_scope(isolated_script_state); + DummyExceptionStateForTesting exception_state; + if (type_ == SubCaptureTarget::Type::kCropTarget) { + auto p = media_devices->ProduceCropTarget(isolated_script_state, div, + exception_state); + EXPECT_FALSE(p.IsEmpty()); + EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), + isolated_script_state->GetContext()); + isolated_tester.emplace(isolated_script_state, p); + } else { + auto p = media_devices->ProduceRestrictionTarget(isolated_script_state, + div, exception_state); + EXPECT_FALSE(p.IsEmpty()); + EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), + isolated_script_state->GetContext()); + isolated_tester.emplace(isolated_script_state, p); + } + EXPECT_FALSE(exception_state.HadException()); + } + ASSERT_TRUE(isolated_tester); + + // Second call from the main world before the first promise is resolved. + std::optional<ScriptPromiseTester> main_tester; + if (type_ == SubCaptureTarget::Type::kCropTarget) { + auto p = media_devices->ProduceCropTarget(scope.GetScriptState(), div, + scope.GetExceptionState()); + EXPECT_FALSE(p.IsEmpty()); + EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), scope.GetContext()); + main_tester.emplace(scope.GetScriptState(), p); + } else { + auto p = media_devices->ProduceRestrictionTarget( + scope.GetScriptState(), div, scope.GetExceptionState()); + EXPECT_FALSE(p.IsEmpty()); + EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), scope.GetContext()); + main_tester.emplace(scope.GetScriptState(), p); + } + ASSERT_TRUE(main_tester); + + isolated_tester->WaitUntilSettled(); + main_tester->WaitUntilSettled(); + + EXPECT_TRUE(isolated_tester->IsFulfilled()); + EXPECT_TRUE(main_tester->IsFulfilled()); + String isolated_result; + { + ScriptState::Scope isolated_scope(isolated_script_state); + isolated_result = ToSubCaptureTarget(isolated_tester->Value())->GetId(); + } + const String main_result = ToSubCaptureTarget(main_tester->Value())->GetId(); + + EXPECT_EQ(isolated_result, "983bf2ff-7410-416c-808a-78421cbd8fdc"); + EXPECT_EQ(isolated_result, main_result); + + isolated_world->Dispose(); +} + TEST_P(ProduceSubCaptureTargetTest, CorrectTokenClassInstantiated) { V8TestingScope scope; auto* media_devices = GetMediaDevices(*GetDocument().domWindow());
Regression Test / PoC
diff --git a/third_party/blink/renderer/modules/mediastream/media_devices_test.cc b/third_party/blink/renderer/modules/mediastream/media_devices_test.cc
index 268ed63..69337cf 100644
--- a/third_party/blink/renderer/modules/mediastream/media_devices_test.cc
+++ b/third_party/blink/renderer/modules/mediastream/media_devices_test.cc
@@ -61,6 +61,7 @@
#include "third_party/blink/renderer/modules/mediastream/media_device_info.h"
#include "third_party/blink/renderer/modules/mediastream/media_permission_testing_platform.h"
#include "third_party/blink/renderer/modules/mediastream/restriction_target.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
@@ -1608,6 +1609,153 @@
EXPECT_EQ(first_result, second_result);
}
+TEST_P(ProduceSubCaptureTargetTest,
+ DuplicateCallBeforePromiseResolutionSameWorld) {
+ V8TestingScope scope;
+ auto* media_devices = GetMediaDevices(*GetDocument().domWindow());
+ ASSERT_TRUE(media_devices);
+
+ dispatcher_host().SetNextId(type_,
+ String("983bf2ff-7410-416c-808a-78421cbd8fdc"));
+
+ SetBodyContent(R"HTML(
+ <div id='test-div'></div>
+ )HTML");
+
+ Document& document = GetDocument();
+ Element* const div = document.getElementById(AtomicString("test-div"));
+
+ v8::Local<v8::Promise> first_v8_promise;
+ v8::Local<v8::Promise> second_v8_promise;
+ std::optional<ScriptPromiseTester> first_tester;
+ std::optional<ScriptPromiseTester> second_tester;
+
+ if (type_ == SubCaptureTarget::Type::kCropTarget) {
+ auto p1 = media_devices->ProduceCropTarget(scope.GetScriptState(), div,
+ scope.GetExceptionState());
+ first_v8_promise = p1.V8Promise();
+ first_tester.emplace(scope.GetScriptState(), p1);
+
+ auto p2 = media_devices->ProduceCropTarget(scope.GetScriptState(), div,
+ scope.GetExceptionState());
+ second_v8_promise = p2.V8Promise();
+ second_tester.emplace(scope.GetScriptState(), p2);
+ } else {
+ auto p1 = media_devices->ProduceRestrictionTarget(
+ scope.GetScriptState(), div, scope.GetExceptionState());
+ first_v8_promise = p1.V8Promise();
+ first_tester.emplace(scope.GetScriptState(), p1);
+
+ auto p2 = media_devices->ProduceRestrictionTarget(
+ scope.GetScriptState(), div, scope.GetExceptionState());
+ second_v8_promise = p2.V8Promise();
+ second_tester.emplace(scope.GetScriptState(), p2);
+ }
+
+ // The two promises must be distinct objects.
+ EXPECT_FALSE(first_v8_promise.IsEmpty());
+ EXPECT_FALSE(second_v8_promise.IsEmpty());
+ EXPECT_NE(first_v8_promise, second_v8_promise);
+
+ first_tester->WaitUntilSettled();
+ second_tester->WaitUntilSettled();
+
+ EXPECT_TRUE(first_tester->IsFulfilled());
+ EXPECT_TRUE(second_tester->IsFulfilled());
+ EXPECT_FALSE(scope.GetExceptionState().HadException());
+
+ const String first_result =
+ ToSubCaptureTarget(first_tester->Value())->GetId();
+ const String second_result =
+ ToSubCaptureTarget(second_tester->Value())->GetId();
+
+ EXPECT_EQ(first_result, "983bf2ff-7410-416c-808a-78421cbd8fdc");
+ EXPECT_EQ(first_result, second_result);
+}
+
+TEST_P(ProduceSubCaptureTargetTest,
+ DuplicateCallBeforePromiseResolutionDifferentWorlds) {
+ V8TestingScope scope;
+ auto* media_devices = GetMediaDevices(*GetDocument().domWindow());
+ ASSERT_TRUE(media_devices);
+
+ dispatcher_host().SetNextId(type_,
+ String("983bf2ff-7410-416c-808a-78421cbd8fdc"));
+
+ SetBodyContent(R"HTML(
+ <div id='test-div'></div>
+ )HTML");
+
+ Document& document = GetDocument();
+ Element* const div = document.getElementById(AtomicString("test-div"));
+
+ constexpr int32_t kIsolatedWorldId = 1;
+ DOMWrapperWorld* isolated_world = DOMWrapperWorld::EnsureIsolatedWorld(
+ scope.GetIsolate(), kIsolatedWorldId);
+ scope.GetFrame().GetWindowProxy(*isolated_world); // Force initialization.
+ ScriptState* isolated_script_state =
+ ToScriptState(&scope.GetFrame(), *isolated_world);
+ ASSERT_TRUE(isolated_script_state);
+
+ // First call from the isolated world.
+ std::optional<ScriptPromiseTester> isolated_tester;
+ {
+ ScriptState::Scope isolated_scope(isolated_script_state);
+ DummyExceptionStateForTesting exception_state;
+ if (type_ == SubCaptureTarget::Type::kCropTarget) {
+ auto p = media_devices->ProduceCropTarget(isolated_script_state, div,
+ exception_state);
+ EXPECT_FALSE(p.IsEmpty());
+ EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(),
+ isolated_script_state->GetContext());
+ isolated_tester.emplace(isolated_script_state, p);
+ } else {
+ auto p = media_devices->ProduceRestrictionTarget(isolated_script_state,
+ div, exception_state);
+ EXPECT_FALSE(p.IsEmpty());
+ EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(),
+ isolated_script_state->GetContext());
+ isolated_tester.emplace(isolated_script_state, p);
+ }
+ EXPECT_FALSE(exception_state.HadException());
+ }
+ ASSERT_TRUE(isolated_tester);
+
+ // Second call from the main world before the first promise is resolved.
+ std::optional<ScriptPromiseTester> main_tester;
+ if (type_ == SubCaptureTarget::Type::kCropTarget) {
+ auto p = media_devices->ProduceCropTarget(scope.GetScriptState(), div,
+ scope.GetExceptionState());
+ EXPECT_FALSE(p.IsEmpty());
+ EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), scope.GetContext());
+ main_tester.emplace(scope.GetScriptState(), p);
+ } else {
+ auto p = media_devices->ProduceRestrictionTarget(
+ scope.GetScriptState(), div, scope.GetExceptionState());
+ EXPECT_FALSE(p.IsEmpty());
+ EXPECT_EQ(p.V8Promise()->GetCreationContextChecked(), scope.GetContext());
+ main_tester.emplace(scope.GetScriptState(), p);
+ }
+ ASSERT_TRUE(main_tester);
+
+ isolated_tester->WaitUntilSettled();
+ main_tester->WaitUntilSettled();
+
+ EXPECT_TRUE(isolated_tester->IsFulfilled());
+ EXPECT_TRUE(main_tester->IsFulfilled());
+ String isolated_result;
+ {
+ ScriptState::Scope isolated_scope(isolated_script_state);
+ isolated_result = ToSubCaptureTarget(isolated_tester->Value())->GetId();
+ }
+ const String main_result = ToSubCaptureTarget(main_tester->Value())->GetId();
+
+ EXPECT_EQ(isolated_result, "983bf2ff-7410-416c-808a-78421cbd8fdc");
+ EXPECT_EQ(isolated_result, main_result);
+
+ isolated_world->Dispose();
+}
+
TEST_P(ProduceSubCaptureTargetTest, CorrectTokenClassInstantiated) {
V8TestingScope scope;
auto* media_devices = GetMediaDevices(*GetDocument().domWindow());
Original Bug Report
[Blink][Region Capture] Webpage Captures Extension Objects, Invokes Extension Functions, and Reads Private State via Promise Reuse
Report description
[Blink][Region Capture] Webpage Captures Extension Objects, Invokes Extension Functions, and Reads Private State via Promise Reuse
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 Description
CropTarget.fromElement() and RestrictionTarget.fromElement() reuse a pending ScriptPromiseResolver using only the shared DOM Element as the cache key.
When an extension content script in Chrome’s isolated V8 world makes the first call and the ordinary webpage synchronously makes a duplicate call for the same element before the browser process responds, Blink returns the original Promise created in the extension isolated world.
The webpage consequently receives direct access to the isolated world’s Promise, Function, and Object prototypes.
In the attached V4 proof of concept, the webpage:
- modifies the extension isolated world’s
Promise.prototype,Function.prototype, andObject.prototype; - installs a webpage-defined inherited getter on the isolated world’s
Object.prototype; - causes extension code to access a private isolated-world object;
- captures that private object as the getter’s
thisvalue; - reads private properties that were never exposed through the DOM or a message channel;
- invokes an existing extension-world function stored on the captured object;
- reads an extension-only global,
chrome.runtime.id, manifest metadata, and an extension URL through that function; and - mutates the private isolated-world object, with the extension observing the mutation.
Both the CropTarget and RestrictionTarget paths reproduce on Google Chrome Stable 151.0.7922.76 for Windows.
The pending resolver maps are shared on navigator.mediaDevices and are keyed by Element, but are not partitioned by ScriptState or DOMWrapperWorld. A duplicate caller therefore receives the first caller’s world-bound Promise.
Attack Preconditions
- A victim extension has a content script running on the attacker’s webpage.
- The content script calls
CropTarget.fromElement()orRestrictionTarget.fromElement()on a DOM element shared with the webpage. - The webpage makes a duplicate call for the same element while the first browser request is still pending.
The proof makes the duplicate call deterministically using a synchronous DOM event dispatched immediately after the extension’s first call. No arbitrary sleep race is required.
No experimental Blink flag, unsafe browser flag, crash, memory corruption, eval, new Function(), or 'unsafe-eval' is required.
Reproduction Steps / POC
Tested product:
Google Chrome Stable 151.0.7922.76
Windows 10 x64
Manifest V3 content script in the default isolated world
- Save the attached files in this layout:
extension/
manifest.json
content.js
page/
index.html
run_server.py
- Start the local server:
python .\run_server.py
- Open:
chrome://extensions
-
Enable Developer mode.
-
Click Load unpacked and select the
extensiondirectory containingmanifest.json. -
Open:
http://127.0.0.1:19940/
- The page automatically tests both vulnerable paths.
A vulnerable result displays:
FINAL_REPRODUCED=true
For both crop and restriction, the result includes:
constructorIsPagePromise=false
thenIsPagePromiseThen=false
promisePrototypeIsPage=false
functionPrototypeIsPage=false
objectPrototypeIsPage=false
foreignFunctionIsPageFunction=false
capturedPrivateSecret=EXT_PRIVATE_OBJECT_SECRET_v4_20af
capturedMethodIsPageFunction=false
extensionFunctionInvocationSucceeded=true
extensionRuntimeProof.globalSecret=
EXTENSION_WORLD_GLOBAL_SECRET_v4_c991
extensionRuntimeProof.hasChromeRuntime=true
extensionRuntimeProof.extensionId=<non-empty extension ID>
extensionGetterTrapResult=
PAGE_GETTER_EXECUTED_V4_<case>
extensionObservedPagePrivateMutation=
PAGE_MUTATED_PRIVATE_OBJECT_V4_<case>
reproduced=true
Top-level output:
{
"positiveCases": [
"crop",
"restriction"
],
"finalReproduced": true
}
The attached 06_stable_chrome_151_reproduction.txt contains the complete decisive output.
Impact analysis
A malicious webpage can exploit this issue when a victim extension content script uses Region/Element Capture on a page-controlled or page-visible DOM element.
After successful exploitation, the webpage crosses Chrome’s extension isolated-world boundary and gains access to JavaScript objects and intrinsic prototypes belonging to the extension world.
The attached proof demonstrates that the webpage can:
- modify the isolated world’s native
Promise,Function, andObjectprototypes; - execute a webpage-defined accessor during extension-world object access;
- capture an extension-private isolated-world object;
- read private properties that were never intentionally exposed to the webpage;
- invoke an existing extension-world function on the captured object;
- obtain
chrome.runtime.id, extension manifest metadata, an extension URL, and extension-only test secrets through that function; and - mutate the captured private object in a way observed by the extension.
This breaks the security property that content-script JavaScript state and extension API bindings are isolated from webpage JavaScript.
The demonstrated impact is cross-world object disclosure, isolated-world prototype manipulation, invocation of extension-world functions, private extension-state disclosure, and integrity impact against extension-owned JavaScript objects.
I am not claiming native memory corruption, browser-process compromise, sandbox escape, OS command execution, or arbitrary string-to-code execution in the extension world.
The cause
What version of Chrome have you found the security issue in?
151.0.7922.76+stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Privilege Escalation
How would you like to be publicly acknowledged for your report?
Zabith Mohammed (@nmzabith)