Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebView
DescriptionInappropriate implementation in WebView
ComponentWebView
Bug ClassLogic Error
Tracker513458233
Fix commit1a216fb07cd8 (chromium/src) +106/-46
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-16

Changed Functions

FunctionChangeNotes
for
components/js_injection/renderer/js_communication.cc
modified
if
components/js_injection/renderer/js_communication.cc
modified

Files Changed

  • android_webview/javatests/src/org/chromium/android_webview/test/JsJavaInteractionTest.java
  • components/js_injection/renderer/js_communication.cc
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 {
Loading diff…

Regression Test / PoC

shipped with the fix
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";
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • components/js_injection/renderer/js_binding.cc
  • gin/wrappable.cc
  • components/js_injection/browser/js_to_browser_messaging.cc
  • components/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:

  1. A malicious page in the main world poisons its Object.prototype by defining a getter for a common property (e.g., Symbol.toPrimitive, then, or toString).
  2. 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).
  3. 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.
  4. 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.

View on issue tracker