CVE-2026-87435
Overview
Background
- Controlled Frame
- an embedding primitive (built on the WebView/guest-view machinery) that lets an Isolated Web App host untrusted web content in a separate guest render process while injecting embedder-supplied content scripts.
- `UserScriptLoader`
- the browser-side class that serializes registered content scripts into a
base::ReadOnlySharedMemoryRegionand pushes them to renderers via theUpdateUserScriptsMojo call. - `owner_host` / `WebViewRendererState`
owner_hostis the IWA origin that owns a guest, andWebViewRendererStateis the browser-side registry mapping each guest process to itsWebViewInfo, including the set ofcontent_script_idsactually registered to that guest.- `kControlledFrameEmbedder`
- the
mojom::HostID::HostTypevalue identifying scripts owned by a Controlled Frame embedder (the IWA), as opposed to an extension.
Root Cause Analysis
In UserScriptLoader::SendUpdate, the browser duplicated the single shared-memory region holding all of the embedder’s serialized content scripts (shared_memory.Duplicate()) and sent it to every guest process whose owner_host matched host_id().id. Because a single IWA origin can host multiple sibling Controlled Frame guests that share the same owner_host, a script targeted at one specific guest was broadcast, in raw serialized form, into the shared memory of all sibling guests. Although script execution was gated elsewhere, the source bytes were still resident in the unrelated sibling guest’s address space, giving that guest read access to script source it was never authorized to see (CWE-200).
The fix makes SendUpdate branch on kControlledFrameEmbedder, query WebViewRendererState::GetContentScriptIDsForProcess for the exact script IDs registered to the target process, and re-Serialize only the matching scripts into a per-process region; if the process has no registered scripts, or none match, it returns kNoActionTaken and sends nothing.
owner_host, conflating “same owner” with “authorized recipient”; the fix filters scripts per-process by the guest’s explicitly registered content_script_ids before serialization.Attack Path
- Host multiple guests under one IWA
An Isolated Web App (the embedder) creates two or more Controlled Frame guests that share the same
owner_host, one of which loads attacker-influenced content. - Register a script for one guest The embedder registers a content script intended for only one specific guest process, so the serialized region contains that script’s source code.
- Broadcast on update
UserScriptLoader::SendUpdateduplicates the full shared-memory region and callsUpdateUserScriptson every sibling guest process matchingowner_host, placing the script’s raw source into each sibling’s shared memory. - Read cross-guest source The unrelated sibling guest process, which was never registered for that script, can read the injected source bytes out of its own mapped shared memory, disclosing content-script source intended for a different guest (inferred as to the specific in-renderer read primitive, which the patch does not show).
Impact Assessment
owner_host — an information disclosure confined to guest renderer memory. The advisory classifies this as an information leak in Controlled Frame (CWE-200); it does not report exploitation in the wild, and no code execution or sandbox escape is demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
forextensions/browser/guest_view/web_view/web_view_renderer_state.cc |
modified | |
ifextensions/browser/guest_view/web_view/web_view_renderer_state.cc |
modified | |
ifextensions/browser/user_script_loader.cc |
modified | |
forextensions/browser/user_script_loader.cc |
modified |
Files Changed
extensions/browser/guest_view/web_view/web_view_renderer_state.hextensions/browser/guest_view/web_view/web_view_renderer_state.ccextensions/browser/user_script_loader_unittest.ccextensions/browser/user_script_loader.cc
Audit Directions
- Shared-memory fan-out by coarse owner keyAudit any browser-side code that serializes data once and broadcasts the same region/handle to multiple renderers keyed on a shared owner (origin,
owner_host, partition) rather than on per-recipient authorization. - Execution-gated but not disclosure-gated dataLook for paths where a security control blocks use of data (execution, rendering) but the raw data is still copied into an unauthorized process’s memory, since presence alone is an info leak.
- Guest-view multiplexingReview other
WebViewRendererState/guest-view consumers that iterate theweb_view_info_map_byowner_hostto ensure they scope per-guest resources (script IDs, partitions) rather than granting embedder-wide access.
Patch
From 5e86f334aa876188b1809b0f6a9ee78dfa6c4892 Mon Sep 17 00:00:00 2001
From: Simon Hangl <simonha@google.com>
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 <andreaorru@chromium.org>
Commit-Queue: Simon Hangl <simonha@google.com>
Reviewed-by: Kevin McNee <mcnee@chromium.org>
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.
+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
Regression Test / PoC
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
Original Bug Report
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.cccomponents/guest_view/browser/guest_view_base.ccextensions/browser/api/guest_view/web_view/web_view_internal_api.ccextensions/browser/user_script_manager.ccextensions/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
-
Origin-Scoped User Script Loader: In
extensions/browser/user_script_manager.cc:74-89, there is exactly oneEmbedderUserScriptLoaderperHostID: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 generatedHostIDis of typekControlledFrameEmbedderand 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, unifiedEmbedderUserScriptLoader. -
Broadcasting to All Owned Guests: In
UserScriptLoader::SendUpdate()(extensions/browser/user_script_loader.cc:580-603), the recipient filter only checks if the guest’sowner_hostmatches the loader’sHostIDID: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. -
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.)
- Setup: An IWA page embeds two
<controlledframe>guest views with distinct storage partitions:cf1(trusted partition) loadshttps://trusted.exampleand has a custom script added viaaddContentScriptscontaining a secret API key or token in its inline code payload.cf2(untrusted partition) loads an attacker-controlled origin.
- Process Separation: The two guest views are placed into separate guest renderer processes due to their distinct partitions.
- Data Broadcast: The browser serializes all scripts for the IWA origin into a single
ReadOnlySharedMemoryRegionand transmits it to bothcf1’s andcf2’s renderer processes. - Exfiltration: An attacker who compromises the guest renderer process of
cf2(renderer RCE) can read the memory space of their own process, locating the mappedReadOnlySharedMemoryRegionand extracting the raw script bytes containing the sensitive API key/token intended only forcf1.
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:
- Keying
EmbedderUserScriptLoaderinstances more granularly, such as combining theHostIDwith the guest’s instance/view ID or storage partition. - 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.