CVE-2026-12448
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/js_injection/renderer/js_communication.cc |
modified | |
ifcomponents/js_injection/renderer/js_communication.cc |
modified |
Files Changed
android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.javacomponents/js_injection/renderer/js_communication.cc
Patch
From 1a216fb07cd8c6f16ad017d1fef40a48374c3ceb Mon Sep 17 00:00:00 2001
From: Peter Pakkenberg <pbirk@google.com>
Date: Wed, 10 Jun 2026 01:30:06 -0700
Subject: [PATCH] Prevent shared context scope for non-main worlds
The optimization to use a single shared context scope when installing js
communication objects only works if all objects use the same world. This
CL updates the installation of JS objects to ensure that a new context
is entered for each world.
Fixed: 513458233
Change-Id: Ia679efc52e608426935064eced7f3cc3a06763af
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7852497
Reviewed-by: Nate Fischer <ntfschr@chromium.org>
Commit-Queue: Peter Pakkenberg <pbirk@chromium.org>
Auto-Submit: Peter Pakkenberg <pbirk@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1644514}
---
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
index cfa85c2..8f57f0b 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
@@ -42,6 +42,7 @@
import org.chromium.net.test.util.TestWebServer;
import java.nio.charset.StandardCharsets;
+import java.util.Locale;
import java.util.Random;
import java.util.concurrent.LinkedBlockingQueue;
@@ -1617,6 +1618,43 @@
@Test
@SmallTest
@Feature({"AndroidWebView", "JsJavaInteraction"})
+ public void testBridgePrototypeChainEndsInIsolatedWorld() throws Throwable {
+ String isolatedWorld = "testWorld";
+ registerWorld(mAwContents, isolatedWorld);
+
+ addWebMessageListenerOnUiThreadInIsolatedWorld(
+ mAwContents, JS_OBJECT_NAME, new String[] {"*"}, mListener, isolatedWorld);
+
+ final String script =
+ String.format(
+ Locale.ROOT,
+ """
+ let p = %s;
+ while (Object.getPrototypeOf(p)) {
+ p = Object.getPrototypeOf(p);
+ }
+ %s.postMessage(p === Object.prototype ? 'true' : 'false');
+ """,
+ JS_OBJECT_NAME,
+ JS_OBJECT_NAME);
+
+ addJavaScriptOnEventOnUiThread(
+ mAwContents,
+ script,
+ DocumentInjectionTime.DOCUMENT_START,
+ new String[] {"*"},
+ isolatedWorld);
+
+ loadUrlFromPath(POST_MESSAGE_SIMPLE_HTML);
+
+ TestWebMessageListener.Data data = mListener.waitForOnPostMessage();
+ Assert.assertEquals("true", data.getAsString());
+ Assert.assertTrue(mListener.hasNoMoreOnPostMessage());
+ }
+
+ @Test
+ @SmallTest
+ @Feature({"AndroidWebView", "JsJavaInteraction"})
public void testAddJavaScriptOnEvent_unregisteredWorldThrowsException() throws Throwable {
String unregisteredWorld = "unregisteredWorld";
diff --git a/components/js_injection/renderer/js_communication.cc b/components/js_injection/renderer/js_communication.cc
index 2a9500a..f0357ffa 100644
--- a/components/js_injection/renderer/js_communication.cc
+++ b/components/js_injection/renderer/js_communication.cc
@@ -4,6 +4,8 @@
#include "components/js_injection/renderer/js_communication.h"
+#include <algorithm>
+
#include "base/feature_list.h"
#include "components/js_injection/common/interfaces.mojom-shared.h"
#include "components/js_injection/renderer/js_binding.h"
@@ -95,9 +97,10 @@
mojo::PendingAssociatedRemote<mojom::JsObjectsClient> client) {
JsObjectMap js_objects;
for (auto& js_object : js_object_ptrs) {
+ int32_t world_id = js_object->js_world;
std::u16string name = js_object->js_object_name;
- js_objects.insert({{name, js_object->js_world},
- std::make_unique<JsObjectInfo>(std::move(js_object))});
+ js_objects[world_id][name] =
+ std::make_unique<JsObjectInfo>(std::move(js_object));
}
js_objects_.swap(js_objects);
client_remote_.reset();
@@ -133,63 +136,75 @@
// so we can't delete it here).
weak_ptr_factory_for_bindings_.InvalidateWeakPtrs();
- // As an optimization, we may set up the v8 scopes here for all the JS
- // binding installations.
+ // We can set up a single isolate and handle scope as an optimization.
v8::Isolate* isolate = nullptr;
- v8::Local<v8::Context> context;
std::optional<v8::HandleScope> handle_scope;
- std::optional<v8::Context::Scope> context_scope;
+ v8::Local<v8::Context> main_world_context;
blink::WebLocalFrame* web_frame = render_frame()->GetWebFrame();
if (base::FeatureList::IsEnabled(kLazyBindJsInjection)) {
isolate = web_frame->GetAgentGroupScheduler()->Isolate();
handle_scope.emplace(isolate);
- context = web_frame->MainWorldScriptContext();
- if (context.IsEmpty()) {
+ main_world_context = web_frame->MainWorldScriptContext();
+ if (main_world_context.IsEmpty()) {
+ // If we don't have a main world script context, we should not proceed
+ // with installation at all.
return;
}
-
- context_scope.emplace(context);
}
url::Origin frame_origin =
url::Origin(render_frame()->GetWebFrame()->GetSecurityOrigin());
std::vector<cppgc::WeakPersistent<JsBinding>> js_bindings;
- js_bindings.reserve(js_objects_.size());
+ size_t binding_count = std::ranges::fold_left(
+ js_objects_, 0, [](size_t acc, const auto& world_entries) {
+ return acc + world_entries.second.size();
+ });
+ js_bindings.reserve(binding_count);
- for (const auto& js_object : js_objects_) {
- if (!js_object.second->origin_matcher().Matches(frame_origin)) {
- js_object.second->SetBinding(nullptr);
- continue;
- }
- cppgc::WeakPersistent<JsBinding> js_binding;
- if (js_object.second->world_id() == content::ISOLATED_WORLD_ID_GLOBAL) {
- js_binding =
- JsBinding::Install(render_frame(), js_object.first.first,
- weak_ptr_factory_for_bindings_.GetWeakPtr(),
- isolate, context, js_object.second->world_id());
- } else {
- js_binding = JsBinding::Install(
- render_frame(), js_object.first.first,
- weak_ptr_factory_for_bindings_.GetWeakPtr(), isolate,
- web_frame->GetScriptContextFromWorldId(isolate,
- js_object.second->world_id()),
- js_object.second->world_id());
- }
- if (js_binding) {
- if (base::FeatureList::IsEnabled(kLazyBindJsInjection)) {
- js_object.second->SetBinding(js_binding);
+ for (const auto& [world_id, world_objects] : js_objects_) {
+ // Set up a context and context scope for all object installations in this
+ // world as an optimization.
+ v8::Local<v8::Context> current_world_context;
+ std::optional<v8::Context::Scope> context_scope;
+ if (base::FeatureList::IsEnabled(kLazyBindJsInjection)) {
+ if (world_id == content::ISOLATED_WORLD_ID_GLOBAL) {
+ current_world_context = main_world_context;
} else {
- mojom::JsToBrowserMessaging* js_to_java_messaging = GetJsToJavaMessage(
- js_object.first.first, js_object.second->world_id());
- if (js_to_java_messaging) {
- mojo::PendingAssociatedRemote<mojom::BrowserToJsMessaging> remote;
- js_binding->Bind(remote.InitWithNewEndpointAndPassReceiver());
- js_to_java_messaging->SetBrowserToJsMessaging(std::move(remote));
- }
+ current_world_context =
+ web_frame->GetScriptContextFromWorldId(isolate, world_id);
}
- js_bindings.push_back(std::move(js_binding));
+ if (current_world_context.IsEmpty()) {
+ continue;
+ }
+ context_scope.emplace(current_world_context);
+ }
+
+ for (const auto& [js_object_name, js_object_info] : world_objects) {
+ if (!js_object_info->origin_matcher().Matches(frame_origin)) {
+ js_object_info->SetBinding(nullptr);
+ continue;
+ }
+ cppgc::WeakPersistent<JsBinding> js_binding =
+ JsBinding::Install(render_frame(), js_object_name,
+ weak_ptr_factory_for_bindings_.GetWeakPtr(),
+ isolate, current_world_context, world_id);
+ if (js_binding) {
+ if (base::FeatureList::IsEnabled(kLazyBindJsInjection)) {
+ js_object_info->SetBinding(js_binding);
+ } else {
Regression Test / PoC
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
index cfa85c2..8f57f0b 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
@@ -42,6 +42,7 @@
import org.chromium.net.test.util.TestWebServer;
import java.nio.charset.StandardCharsets;
+import java.util.Locale;
import java.util.Random;
import java.util.concurrent.LinkedBlockingQueue;
@@ -1617,6 +1618,43 @@
@Test
@SmallTest
@Feature({"AndroidWebView", "JsJavaInteraction"})
+ public void testBridgePrototypeChainEndsInIsolatedWorld() throws Throwable {
+ String isolatedWorld = "testWorld";
+ registerWorld(mAwContents, isolatedWorld);
+
+ addWebMessageListenerOnUiThreadInIsolatedWorld(
+ mAwContents, JS_OBJECT_NAME, new String[] {"*"}, mListener, isolatedWorld);
+
+ final String script =
+ String.format(
+ Locale.ROOT,
+ """
+ let p = %s;
+ while (Object.getPrototypeOf(p)) {
+ p = Object.getPrototypeOf(p);
+ }
+ %s.postMessage(p === Object.prototype ? 'true' : 'false');
+ """,
+ JS_OBJECT_NAME,
+ JS_OBJECT_NAME);
+
+ addJavaScriptOnEventOnUiThread(
+ mAwContents,
+ script,
+ DocumentInjectionTime.DOCUMENT_START,
+ new String[] {"*"},
+ isolatedWorld);
+
+ loadUrlFromPath(POST_MESSAGE_SIMPLE_HTML);
+
+ TestWebMessageListener.Data data = mListener.waitForOnPostMessage();
+ Assert.assertEquals("true", data.getAsString());
+ Assert.assertTrue(mListener.hasNoMoreOnPostMessage());
+ }
+
+ @Test
+ @SmallTest
+ @Feature({"AndroidWebView", "JsJavaInteraction"})
public void testAddJavaScriptOnEvent_unregisteredWorldThrowsException() throws Throwable {
String unregisteredWorld = "unregisteredWorld";
Original Bug Report
Potential Isolated-World WebMessageListener Bypass in Android WebView
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic error in Android WebView’s JS injection component allows untrusted web pages to capture privileged WebMessageListener bridges intended for isolated worlds. When the kLazyBindJsInjection optimization is enabled, bridge objects are instantiated in the main-world V8 context instead of their target isolated-world context. This leads to a cross-world prototype leakage that enables message spoofing and eavesdropping.
Affected files:
components/js_injection/renderer/js_communication.cccomponents/js_injection/renderer/js_binding.ccgin/wrappable.cccomponents/js_injection/browser/js_to_browser_messaging.cccomponents/js_injection/browser/js_communication_host.cc
Estimated timestamp from git blame: 2026-01-09
Summary
There is a potential world-isolation bypass in Android WebView’s WebMessageListener feature. When the kLazyBindJsInjection feature is enabled (default-on), the renderer-side initialization improperly manages V8 context scopes. This results in JavaScript bridge objects intended for isolated worlds being instantiated within the main-world context. Consequently, these bridges inherit from the main world’s Object.prototype, which can be poisoned by an attacker to capture the bridge instance and interact with the app’s Java backend.
Technical Details
Root Cause Analysis
In components/js_injection/renderer/js_communication.cc, the method DidClearWindowObject() handles the installation of JavaScript bridges. If the optimization kLazyBindJsInjection is enabled, it enters a V8 context scope for the main world as a performance optimization:
// components/js_injection/renderer/js_communication.cc
if (base::FeatureList::IsEnabled(kLazyBindJsInjection)) {
isolate = web_frame->GetAgentGroupScheduler()->Isolate();
handle_scope.emplace(isolate);
context = web_frame->MainWorldScriptContext(); // Enters MAIN world
if (context.IsEmpty()) { return; }
context_scope.emplace(context);
}
It then iterates through registered objects and calls JsBinding::Install. However, JsBinding::Install contains logic that skips entering the correct target context if an isolate is already provided:
// components/js_injection/renderer/js_binding.cc
if (!isolate) {
// ... enters context ...
}
// ...
if (!js_binding->GetWrapper(isolate).ToLocal(&wrapper)) { return nullptr; }
Because the code in js_communication.cc provided the isolate, JsBinding::Install remains in the main-world context scope. When gin::WrappableBase::GetWrapper() eventually calls templ->NewInstance(isolate->GetCurrentContext()), V8 creates the bridge instance in the main-world context.
Although the bridge is subsequently attached to the isolated world’s global object, it inherits its prototype chain from the main world’s Object.prototype rather than the isolated world’s.
Potential Attack Scenario
An attacker can potentially trigger this vulnerability using the following steps:
- A malicious page in the main world poisons its
Object.prototypeby defining a getter for a common property (e.g.,Symbol.toPrimitive,then, ortoString). - A trusted script in an isolated world (e.g., an app-injected script) performs an operation on the bridge that triggers a prototype lookup (e.g.,
'' + bridge). - V8 traverses the prototype chain to the main-world
Object.prototype, executing the attacker’s getter. The attacker’s code can then save the bridge instance to a global variable in the main world. - The attacker uses the captured bridge to call
postMessage(), spoofing communications to the Java backend, or registers a listener to eavesdrop on replies meant for the isolated world.
Impact
Successful exploitation would allow an untrusted web page to bypass isolated-world protections in Android WebView. This enables the attacker to communicate directly with privileged Java APIs intended only for trusted scripts and intercept sensitive data returned by those APIs.
Suggested Fix
Ensure that JsBinding::Install always enters the specific context scope intended for the bridge, regardless of whether an isolate is provided. Alternatively, JsCommunication::DidClearWindowObject should avoid entering a global context scope that doesn’t match the target world of the bindings it is installing.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.