Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in ControlledFrame
DescriptionInformation leak in ControlledFrame
ComponentControlledFrame
Bug ClassLogic Error
Tracker533070113
Fix commit5e86f334aa87 (chromium/src) +105/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
for
extensions/browser/guest_view/web_view/web_view_renderer_state.cc
modified
if
extensions/browser/guest_view/web_view/web_view_renderer_state.cc
modified
if
extensions/browser/user_script_loader.cc
modified
for
extensions/browser/user_script_loader.cc
modified

Files Changed

  • extensions/browser/guest_view/web_view/web_view_renderer_state.cc
  • extensions/browser/guest_view/web_view/web_view_renderer_state.h
  • extensions/browser/user_script_loader.cc
  • extensions/browser/user_script_loader_unittest.cc
From 5e86f334aa876188b1809b0f6a9ee78dfa6c4892 Mon Sep 17 00:00:00 2001
From: Simon Hangl <[email protected]>
Date: Tue, 04 Aug 2026 07:03:05 -0700
Subject: [PATCH] [Controlled Frame] Fix cross-guest content script source leak

A vulnerability existed in Controlled Frame where content script source
code intended for a specific guest process was serialized and broadcast
to all sibling guest processes that shared the same owner_host (IWA
origin). Although script execution was properly blocked, the raw source
code was still present in the sibling guest's memory, leading to a
CWE-200 Information Exposure leak.

This CL fixes the issue by updating `UserScriptLoader::SendUpdate` to
filter scripts during serialization. For `kControlledFrameEmbedder`
host types, it now queries `WebViewRendererState` for the explicit
script IDs registered to the target guest process. If the process has
no registered scripts, or the script IDs do not match, they are stripped
from the shared memory region before it is sent across the IPC.

Added `EmbedderScriptsNotSentToUnrelatedGuestProcess` to
`UserScriptLoaderUnitTest` to mock the renderer Mojo interface and
mechanically guarantee that the browser correctly drops unrelated
scripts.

Bug: 533070113
Change-Id: I366ac8a721d5449fe48ad0beaf85a1fb14418acb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8155940
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Simon Hangl <[email protected]>
Reviewed-by: Kevin McNee <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1673303}
---

diff --git a/extensions/browser/guest_view/web_view/web_view_renderer_state.cc b/extensions/browser/guest_view/web_view/web_view_renderer_state.cc
index f78ff2d..5111519 100644
--- a/extensions/browser/guest_view/web_view/web_view_renderer_state.cc
+++ b/extensions/browser/guest_view/web_view/web_view_renderer_state.cc
@@ -126,6 +126,24 @@
   return false;
 }
 
+std::optional<std::set<std::string>>
+WebViewRendererState::GetContentScriptIDsForProcess(
+    content::ChildProcessId guest_process_id) const {
+  base::AutoLock auto_lock(web_view_info_map_lock_);
+
+  std::optional<std::set<std::string>> script_ids;
+  for (const auto& info : web_view_info_map_) {
+    if (info.first.child_id == guest_process_id) {
+      if (!script_ids) {
+        script_ids.emplace();
+      }
+      script_ids->insert(info.second.content_script_ids.begin(),
+                         info.second.content_script_ids.end());
+    }
+  }
+  return script_ids;
+}
+
 void WebViewRendererState::AddContentScriptIDs(
     int embedder_process_id,
     int view_instance_id,
diff --git a/extensions/browser/guest_view/web_view/web_view_renderer_state.h b/extensions/browser/guest_view/web_view/web_view_renderer_state.h
index f1fc86a..c61e22c 100644
--- a/extensions/browser/guest_view/web_view/web_view_renderer_state.h
+++ b/extensions/browser/guest_view/web_view/web_view_renderer_state.h
@@ -14,6 +14,7 @@
 #define EXTENSIONS_BROWSER_GUEST_VIEW_WEB_VIEW_WEB_VIEW_RENDERER_STATE_H_
 
 #include <map>
+#include <optional>
 #include <set>
 #include <string>
 #include <utility>
@@ -66,6 +67,10 @@
   // found, otherwise returns false.
   bool GetPartitionID(int guest_process_id, std::string* partition_id) const;
 
+  // Returns the content script IDs for the given guest process.
+  std::optional<std::set<std::string>> GetContentScriptIDsForProcess(
+      content::ChildProcessId guest_process_id) const;
+
   // Returns true if the renderer with process ID `render_process_id` is a
   // WebView guest process.
   bool IsGuest(int render_process_id) const;
diff --git a/extensions/browser/user_script_loader.cc b/extensions/browser/user_script_loader.cc
index f5d8358..8d75262 100644
--- a/extensions/browser/user_script_loader.cc
+++ b/extensions/browser/user_script_loader.cc
@@ -550,11 +550,8 @@
       NOTREACHED();
   }
 
-  base::ReadOnlySharedMemoryRegion region_for_process =
-      shared_memory.Duplicate();
-  if (!region_for_process.IsValid()) {
-    return SendUpdateResult::kNoActionTaken;
-  }
+  base::ReadOnlySharedMemoryRegion region_for_process;
+  bool use_custom_region = false;
 
 #if BUILDFLAG(ENABLE_GUEST_VIEW)
   // If the process only hosts guest frames, then those guest frames share the
@@ -601,11 +598,49 @@
         if (owner_host != host_id().id) {
           return SendUpdateResult::kNoActionTaken;
         }
+
+        if (host_id().type ==
+            mojom::HostID::HostType::kControlledFrameEmbedder) {
+          use_custom_region = true;
+          std::optional<std::set<std::string>> script_ids =
+              WebViewRendererState::GetInstance()
+                  ->GetContentScriptIDsForProcess(process->GetID());
+          if (!script_ids || script_ids->empty()) {
+            return SendUpdateResult::kNoActionTaken;
+          }
+          if (loaded_scripts_) {
+            UserScriptList filtered_scripts;
+            for (const std::unique_ptr<UserScript>& script : *loaded_scripts_) {
+              if (script_ids->count(script->id())) {
+                std::unique_ptr<UserScript> filtered_script =
+                    UserScript::CopyMetadataFrom(*script);
+                for (size_t i = 0; i < script->js_scripts().size(); ++i) {
+                  filtered_script->js_scripts()[i]->set_content(
+                      std::string(script->js_scripts()[i]->GetContent()));
+                }
+                for (size_t i = 0; i < script->css_scripts().size(); ++i) {
+                  filtered_script->css_scripts()[i]->set_content(
+                      std::string(script->css_scripts()[i]->GetContent()));
+                }
+                filtered_scripts.push_back(std::move(filtered_script));
+              }
+            }
+            region_for_process = Serialize(filtered_scripts);
+          }
+        }
         break;
     }
   }
 #endif
 
+  if (!use_custom_region) {
+    region_for_process = shared_memory.Duplicate();
+  }
+
+  if (!region_for_process.IsValid()) {
+    return SendUpdateResult::kNoActionTaken;
+  }
+
   renderer->UpdateUserScripts(std::move(region_for_process),
                               mojom::HostID::New(host_id().type, host_id().id));
   return SendUpdateResult::kRendererHasBeenNotified;
diff --git a/extensions/browser/user_script_loader_unittest.cc b/extensions/browser/user_script_loader_unittest.cc
index 3cc7818..e9e179a 100644
--- a/extensions/browser/user_script_loader_unittest.cc
+++ b/extensions/browser/user_script_loader_unittest.cc
@@ -161,10 +161,12 @@
     return process;
   }
 
-  void LoadScriptsAndWait(UserScriptLoader* loader,
-                          content::RenderProcessHost* embedder_process) {
+  void LoadScriptsAndWait(
+      UserScriptLoader* loader,
+      content::RenderProcessHost* embedder_process,
+      std::string script_id = UserScript::GenerateUserScriptID()) {
     auto script = std::make_unique<UserScript>();
-    script->set_id(UserScript::GenerateUserScriptID());
+    script->set_id(script_id);
     script->set_host_id(loader->host_id());
     auto content = UserScript::Content::CreateInlineCode(
         GURL("https://embedder.example/inline.js"));
@@ -202,8 +204,10 @@
   ASSERT_TRUE(helper()->IsProcessInitializedForTesting(guest_process.get()));
 
   const std::string owner_host = "isolated-app://embedder.example";
+  const std::string script_id = UserScript::GenerateUserScriptID();
   WebViewRendererState::WebViewInfo web_view_info;
   web_view_info.owner_host = owner_host;
+  web_view_info.content_script_ids.insert(script_id);
   const int dummy_routing_id = 1;
   WebViewRendererState::GetInstance()->AddGuestForTesting(
       guest_process->GetDeprecatedID(), dummy_routing_id, web_view_info);
@@ -218,7 +222,7 @@
       browser_context(),
       mojom::HostID(mojom::HostID::HostType::kControlledFrameEmbedder,
                     owner_host));
-  LoadScriptsAndWait(&loader, guest_process.get());
+  LoadScriptsAndWait(&loader, guest_process.get(), script_id);
 
   EXPECT_TRUE(loader.initial_load_complete());
   EXPECT_TRUE(helper()->ProcessReceivedUpdateUserScripts(guest_process.get()));
@@ -254,6 +258,40 @@
   }
 }
 
+// Test that an embedder content script is NOT sent to a guest renderer
+// if that guest's ID is not associated with the script.
+// This prevents cross-guest script leaks in Controlled Frame.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/extensions/browser/user_script_loader_unittest.cc b/extensions/browser/user_script_loader_unittest.cc
index 3cc7818..e9e179a 100644
--- a/extensions/browser/user_script_loader_unittest.cc
+++ b/extensions/browser/user_script_loader_unittest.cc
@@ -161,10 +161,12 @@
     return process;
   }
 
-  void LoadScriptsAndWait(UserScriptLoader* loader,
-                          content::RenderProcessHost* embedder_process) {
+  void LoadScriptsAndWait(
+      UserScriptLoader* loader,
+      content::RenderProcessHost* embedder_process,
+      std::string script_id = UserScript::GenerateUserScriptID()) {
     auto script = std::make_unique<UserScript>();
-    script->set_id(UserScript::GenerateUserScriptID());
+    script->set_id(script_id);
     script->set_host_id(loader->host_id());
     auto content = UserScript::Content::CreateInlineCode(
         GURL("https://embedder.example/inline.js"));
@@ -202,8 +204,10 @@
   ASSERT_TRUE(helper()->IsProcessInitializedForTesting(guest_process.get()));
 
   const std::string owner_host = "isolated-app://embedder.example";
+  const std::string script_id = UserScript::GenerateUserScriptID();
   WebViewRendererState::WebViewInfo web_view_info;
   web_view_info.owner_host = owner_host;
+  web_view_info.content_script_ids.insert(script_id);
   const int dummy_routing_id = 1;
   WebViewRendererState::GetInstance()->AddGuestForTesting(
       guest_process->GetDeprecatedID(), dummy_routing_id, web_view_info);
@@ -218,7 +222,7 @@
       browser_context(),
       mojom::HostID(mojom::HostID::HostType::kControlledFrameEmbedder,
                     owner_host));
-  LoadScriptsAndWait(&loader, guest_process.get());
+  LoadScriptsAndWait(&loader, guest_process.get(), script_id);
 
   EXPECT_TRUE(loader.initial_load_complete());
   EXPECT_TRUE(helper()->ProcessReceivedUpdateUserScripts(guest_process.get()));
@@ -254,6 +258,40 @@
   }
 }
 
+// Test that an embedder content script is NOT sent to a guest renderer
+// if that guest's ID is not associated with the script.
+// This prevents cross-guest script leaks in Controlled Frame.
+TEST_F(UserScriptLoaderUnitTest,
+       EmbedderScriptsNotSentToUnrelatedGuestProcess) {
+  std::unique_ptr<content::MockRenderProcessHost> guest_process =
+      CreateAndInitializeProcess(/*is_for_guests_only=*/true);
+  ASSERT_TRUE(helper()->IsProcessInitializedForTesting(guest_process.get()));
+
+  const std::string owner_host = "isolated-app://embedder.example";
+  // Deliberately do NOT add the script_id to web_view_info.content_script_ids.
+  // This simulates a sibling guest process that shouldn't receive the script.
+  WebViewRendererState::WebViewInfo web_view_info;
+  web_view_info.owner_host = owner_host;
+  const int dummy_routing_id = 1;
+  WebViewRendererState::GetInstance()->AddGuestForTesting(
+      guest_process->GetDeprecatedID(), dummy_routing_id, web_view_info);
+  base::ScopedClosureRunner cleanup_guest(base::BindOnce(
+      [](int process_id, int routing_id) {
+        WebViewRendererState::GetInstance()->RemoveGuestForTesting(process_id,
+                                                                   routing_id);
+      },
+      guest_process->GetDeprecatedID(), dummy_routing_id));
+
+  EmbedderUserScriptLoader loader(
+      browser_context(),
+      mojom::HostID(mojom::HostID::HostType::kControlledFrameEmbedder,
+                    owner_host));
+  LoadScriptsAndWait(&loader, guest_process.get());
+
+  EXPECT_TRUE(loader.initial_load_complete());
+  EXPECT_FALSE(helper()->ProcessReceivedUpdateUserScripts(guest_process.get()));
+}
+
 }  // namespace
 
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by [email protected]

Potential IWA <controlledframe> content script source leak to sibling guest processes

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: An information disclosure vulnerability potentially exists in the Isolated Web App (IWA) <controlledframe> implementation where custom content script source code can leak to sibling guest processes. Because content script serialization and updates are origin-scoped rather than guest-instance-scoped, all scripts registered by an IWA are bundled into a single shared memory region and sent to every guest process owned by that IWA. A compromised guest process can map and read this shared memory region to retrieve sensitive script bytes intended for other frames.

Affected files:

  • extensions/browser/user_script_loader.cc
  • components/guest_view/browser/guest_view_base.cc
  • extensions/browser/api/guest_view/web_view/web_view_internal_api.cc
  • extensions/browser/user_script_manager.cc
  • extensions/browser/guest_view/web_view/web_view_content_script_manager.cc

Estimated timestamp from git blame: 2024-01-22

Summary of the Potential Issue

An architectural information disclosure vulnerability potentially exists in the Isolated Web App (IWA) <controlledframe> implementation. The issue allows custom content script source code (including inline script bytes) to leak across sibling guest processes.

Because content script serialization and distribution are scoped to the IWA’s origin (HostID of type kControlledFrameEmbedder) rather than to specific guest instances, all scripts registered by an IWA across different <controlledframe> elements are bundled into a single shared memory region. This region is then sent to every guest process owned by that IWA. A compromised guest process can read this shared memory mapping and retrieve sensitive script bytes intended only for other frames.

Root Cause Analysis

  1. Origin-Scoped User Script Loader: In extensions/browser/user_script_manager.cc:74-89, there is exactly one EmbedderUserScriptLoader per HostID:

    EmbedderUserScriptLoader* UserScriptManager::GetUserScriptLoaderForEmbedder(
        const mojom::HostID& host_id) {
      auto it = embedder_script_loaders_.find(host_id);
      if (it != embedder_script_loaders_.end()) return it->second.get();
      ...
      return CreateEmbedderUserScriptLoader(host_id);
    }
    

    For any <controlledframe> element embedded inside an IWA, the generated HostID is of type kControlledFrameEmbedder and its ID is the serialized IWA origin (e.g., "isolated-app://iwa-A"):

    // extensions/browser/api/guest_view/web_view/web_view_internal_api.cc:119-125
    if (embedder_rfh->GetWebExposedIsolationLevel() >=
        content::WebExposedIsolationLevel::kIsolatedApplication) {
      const std::string origin =
          embedder_rfh->GetMainFrame()->GetLastCommittedOrigin().Serialize();
      return extensions::mojom::HostID(
          extensions::mojom::HostID::HostType::kControlledFrameEmbedder, origin);
    }
    

    As a result, all <controlledframe> elements within the same IWA share a single, unified EmbedderUserScriptLoader.

  2. Broadcasting to All Owned Guests: In UserScriptLoader::SendUpdate() (extensions/browser/user_script_loader.cc:580-603), the recipient filter only checks if the guest’s owner_host matches the loader’s HostID ID:

    if (owner_host != host_id().id) {
      return SendUpdateResult::kNoActionTaken;
    }
    

    Because all guest views embedded in the same IWA share the same owner_host (the IWA’s serialized origin), this check passes for all guest processes owned by the IWA. Consequently, the entire serialized shared memory region—containing all scripts registered by any guest frame—is transmitted to every guest process.

  3. Renderer Memory Mapping: Upon receiving the update, the guest renderer process maps the shared memory region via UserScriptSet::UpdateUserScripts():

    shared_memory_mapping_ = shared_memory.Map();
    

    While the in-process execution gate (UserScriptInjector::CanExecuteOnFrame) correctly prevents the execution of unauthorized scripts, the script contents remain resident in the guest process’s address space.

Suggested Potential Exploitation Steps

(Note: These are potential steps, as our analysis is static and our tooling agent does not have the ability to execute code or run a live proof of concept.)

  1. Setup: An IWA page embeds two <controlledframe> guest views with distinct storage partitions:
    • cf1 (trusted partition) loads https://trusted.example and has a custom script added via addContentScripts containing a secret API key or token in its inline code payload.
    • cf2 (untrusted partition) loads an attacker-controlled origin.
  2. Process Separation: The two guest views are placed into separate guest renderer processes due to their distinct partitions.
  3. Data Broadcast: The browser serializes all scripts for the IWA origin into a single ReadOnlySharedMemoryRegion and transmits it to both cf1’s and cf2’s renderer processes.
  4. Exfiltration: An attacker who compromises the guest renderer process of cf2 (renderer RCE) can read the memory space of their own process, locating the mapped ReadOnlySharedMemoryRegion and extracting the raw script bytes containing the sensitive API key/token intended only for cf1.

Impact

Cross-instance information disclosure from a compromised guest renderer. This breaks the isolation boundary between <controlledframe> storage partitions when custom content scripts are used, potentially leaking sensitive credentials or developer-authored logic across frames.

Suggested Fix

To resolve this issue, the browser should ensure that the shared memory region delivered to a guest process only contains scripts that the specific guest process is authorized to execute.

This could be achieved by:

  1. Keying EmbedderUserScriptLoader instances more granularly, such as combining the HostID with the guest’s instance/view ID or storage partition.
  2. Or filtering the scripts during the serialization or delivery phase so that a distinct shared memory region is built and sent for each unique set of authorized scripts per guest process.

Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85


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