Chrome · Core
CVE-2026-78968
Logic Error in Core
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_proxy_host.cc |
modified |
Files Changed
content/browser/bad_message.hcontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_frame_host_impl.hcontent/browser/renderer_host/render_frame_proxy_host.cc
Patch
From 7e2abdeea2f28abae974d06547f044c39236079a Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Wed, 15 Jul 2026 16:14:32 -0700
Subject: [PATCH] [agy][content] Verify sender gesture for delegated capabilities
Add browser-side validation in RouteMessageEvent to ensure the sender
frame had user activation when a postMessage delegates a capability.
To support asynchronous consumption of user activation (where WPT tests
synchronously consume the gesture inside DOMWindow::postMessage in the
renderer prior to message routing, sending an asynchronous IPC to
consume it in the browser), RenderFrameHostImpl tracks the timestamp
when its transient user activation was last consumed. RouteMessageEvent
allows delegation if the sender frame has active user activation or has
consumed it within a short grace period.
Fixed: 518125889
Test: SecurityExploitBrowserTest.PostMessageDelegatedCapabilityWithoutUserActivation
Change-Id: Id36fdbbcf637676abfcecfba1b31067cfeba1e8f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7994936
Reviewed-by: Caitlin Fischer <caitlinfischer@google.com>
Reviewed-by: Avi Drissman <avi@chromium.org>
Commit-Queue: Tzarial <zork@chromium.org>
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662927}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index c943914..4d456ba 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -384,6 +384,7 @@
BIBI_BIND_GAMEPAD_MONITOR_BLOCKED_BY_PERMISSIONS_POLICY = 356,
BIBI_BIND_GAMEPAD_HAPTICS_MANAGER_BLOCKED_BY_PERMISSIONS_POLICY = 357,
RFH_CREATE_NEW_WINDOW_INVALID_PIP_OPTIONS = 358,
+ RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY = 359,
// Please add new elements here. The naming convention is abbreviated class
// name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 268b4e8..e913d65 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -7667,10 +7667,13 @@
user_activation_state_.Clear();
history_user_activation_state_.Clear();
GetMainFrame()->honor_sticky_activation_for_history_intervention_ = true;
+ last_user_activation_consumed_time_ = base::TimeTicks();
}
void RenderFrameHostImpl::ConsumeTransientUserActivation() {
- user_activation_state_.ConsumeIfActive();
+ if (user_activation_state_.ConsumeIfActive()) {
+ last_user_activation_consumed_time_ = base::TimeTicks::Now();
+ }
}
void RenderFrameHostImpl::ActivateUserActivation(
@@ -11391,6 +11394,9 @@
void RenderFrameHostImpl::ReceivedDelegatedCapability(
blink::mojom::DelegatedCapability delegated_capability) {
+ if (lifecycle_state() != LifecycleStateImpl::kActive) {
+ return;
+ }
// Every delegated capability that is checked or consumed on the browser side
// needs to be (a) activated here and (b) consumed when RFH handles the
// capability.
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index 846a852..11f5d8fc 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -3145,6 +3145,9 @@
// Returns the sticky bit of the User Activation v2 state of this document.
bool HasStickyUserActivation() const;
bool IsActiveUserActivation() const;
+ base::TimeTicks last_user_activation_consumed_time() const {
+ return last_user_activation_consumed_time_;
+ }
void ClearUserActivation();
void ConsumeTransientUserActivation();
void ActivateUserActivation(
@@ -5470,6 +5473,9 @@
// details on how this state is maintained.
blink::UserActivationState user_activation_state_;
+ // The timestamp when the transient user activation was last consumed.
+ base::TimeTicks last_user_activation_consumed_time_;
+
// Similar to `user_activation_state_`, but specifically for use with
// web-exposed history manipulation (e.g., cancelling a history navigation via
// the Navigation API). Activated when `user_activation_state_` is activated,
diff --git a/content/browser/renderer_host/render_frame_proxy_host.cc b/content/browser/renderer_host/render_frame_proxy_host.cc
index 3d13490..feb76d9 100644
--- a/content/browser/renderer_host/render_frame_proxy_host.cc
+++ b/content/browser/renderer_host/render_frame_proxy_host.cc
@@ -16,6 +16,7 @@
#include "base/metrics/histogram_functions.h"
#include "base/no_destructor.h"
#include "base/strings/utf_string_conversions.h"
+#include "base/time/time.h"
#include "base/timer/elapsed_timer.h"
#include "base/trace_event/typed_macros.h"
#include "base/types/optional_util.h"
@@ -503,6 +504,12 @@
blink::TransferableMessage message) {
RenderFrameHostImpl* target_rfh = frame_tree_node()->current_frame_host();
+ RenderFrameHostImpl* source_rfh = nullptr;
+ if (source_frame_token) {
+ source_rfh = RenderFrameHostImpl::FromFrameToken(
+ GetProcess()->GetID(), source_frame_token.value());
+ }
+
// Give the embedder a chance to override the target for this postMessage.
RenderFrameHost* override_target =
GetContentClient()->browser()->GetPostMessageTargetOverride(
@@ -544,6 +551,43 @@
return;
}
+ if (message.delegated_capability !=
+ blink::mojom::DelegatedCapability::kNone) {
+ if (!source_frame_token) {
+ bad_message::ReceivedBadMessage(
+ GetProcess(),
+ bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY);
+ return;
+ }
+ if (!source_rfh) {
+ // If the source frame was detached while the message was in flight,
+ // silently drop the message. Do not kill the renderer process because
+ // this is a benign race condition.
+ return;
+ }
+
+ // Check if the source frame currently has transient user activation, or if
+ // it consumed transient user activation recently. Legitimate delegation
+ // consumes user activation in the renderer process synchronously, which
+ // asynchronously propagates to the browser via an IPC on the LocalFrameHost
+ // Mojo interface. To prevent race conditions where the consumption IPC is
+ // processed before RouteMessageEvent, we allow delegation if transient user
+ // activation was active or consumed recently.
+ static constexpr base::TimeDelta kMaxDelegationDelay = base::Seconds(1);
+ bool has_activation =
+ source_rfh->IsActiveUserActivation() ||
+ (!source_rfh->last_user_activation_consumed_time().is_null() &&
+ base::TimeTicks::Now() -
+ source_rfh->last_user_activation_consumed_time() <
+ kMaxDelegationDelay);
+ if (!has_activation) {
+ bad_message::ReceivedBadMessage(
+ GetProcess(),
+ bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY);
+ return;
+ }
+ }
+
SiteInstanceGroup* target_group = target_rfh->GetSiteInstance()->group();
bool is_embedder_to_guest_communication = false;
@@ -563,21 +607,17 @@
}
bool is_guest_to_embedder_communication = false;
- if (source_frame_token) {
- RenderFrameHostImpl* source_rfh = RenderFrameHostImpl::FromFrameToken(
- GetProcess()->GetID(), source_frame_token.value());
- if (source_rfh) {
- RenderFrameHostImpl* source_outermost_rfh =
- source_rfh->GetOutermostMainFrame();
- RenderFrameHostImpl* source_embedder_rfh =
- source_outermost_rfh->GetParentOrOuterDocumentOrEmbedder();
- // Note that this is not checking that the source and target are related,
- // but that the target is related to the embedder.
- if (source_embedder_rfh &&
- target_group->IsRelatedSiteInstanceGroup(
- source_embedder_rfh->GetSiteInstance()->group())) {
- is_guest_to_embedder_communication = true;
- }
+ if (source_rfh) {
+ RenderFrameHostImpl* source_outermost_rfh =
+ source_rfh->GetOutermostMainFrame();
+ RenderFrameHostImpl* source_embedder_rfh =
+ source_outermost_rfh->GetParentOrOuterDocumentOrEmbedder();
+ // Note that this is not checking that the source and target are related,
+ // but that the target is related to the embedder.
+ if (source_embedder_rfh &&
+ target_group->IsRelatedSiteInstanceGroup(
+ source_embedder_rfh->GetSiteInstance()->group())) {
+ is_guest_to_embedder_communication = true;
}
}
@@ -602,116 +642,110 @@
// If there is a |source_frame_token|, translate it to the frame token of the
// equivalent RenderFrameProxyHost in the target process.
std::optional<blink::RemoteFrameToken> translated_source_token;
- if (source_frame_token) {
- RenderFrameHostImpl* source_rfh = RenderFrameHostImpl::FromFrameToken(
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index e71bf32..0fd93412 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1800,6 +1800,48 @@
swapped_impl_;
};
+class CapabilityDelegationRemoteFrameHostInterceptor
+ : public blink::mojom::RemoteFrameHostInterceptorForTesting {
+ public:
+ explicit CapabilityDelegationRemoteFrameHostInterceptor(
+ RenderFrameProxyHost* render_frame_proxy_host)
+ : swapped_impl_(
+ render_frame_proxy_host->frame_host_receiver_for_testing(),
+ this) {}
+
+ ~CapabilityDelegationRemoteFrameHostInterceptor() override = default;
+
+ RemoteFrameHost* GetForwardingInterface() override {
+ return swapped_impl_.old_impl();
+ }
+
+ void RouteMessageEvent(
+ const std::optional<blink::LocalFrameToken>& source_frame_token,
+ const url::Origin& source_origin,
+ const std::optional<url::Origin>& target_origin,
+ blink::TransferableMessage message) override {
+ // Inject the delegated capability into the message before forwarding.
+ message.delegated_capability =
+ blink::mojom::DelegatedCapability::kFullscreenRequest;
+ GetForwardingInterface()->RouteMessageEvent(
+ std::move(source_frame_token), source_origin, std::move(target_origin),
+ std::move(message));
+ }
+
+ void OpenURL(blink::mojom::OpenURLParamsPtr params) override {
+ intercepted_params_ = std::move(params);
+ }
+
+ blink::mojom::OpenURLParamsPtr GetInterceptedParams() {
+ return std::move(intercepted_params_);
+ }
+
+ private:
+ blink::mojom::OpenURLParamsPtr intercepted_params_;
+ mojo::test::ScopedSwapImplForTesting<blink::mojom::RemoteFrameHost>
+ swapped_impl_;
+};
+
// Test verifying that a compromised renderer can't lie about the source_origin
// passed along with the RouteMessageEvent() mojo message. See also
// https://crbug.com/915721.
@@ -2044,6 +2086,113 @@
kill_waiter.Wait());
}
+// Test verifying that a compromised renderer cannot delegate a capability
+// (e.g. fullscreen) without transient user activation.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ PostMessageDelegatedCapabilityWithoutUserActivation) {
+ // Explicitly isolating b.com helps ensure that this test is applicable on
+ // platforms without site-per-process.
+ IsolateOrigin("b.com");
+
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+ RenderFrameHostImpl* main_frame = root->current_frame_host();
+
+ // Create cross-site child frame.
+ GURL child_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+ {
+ std::string js_str = base::StringPrintf(
+ "var frame = document.createElement('iframe'); "
+ "frame.src = '%s'; "
+ "document.body.appendChild(frame);",
+ child_url.spec().c_str());
+ EXPECT_TRUE(ExecJs(main_frame, js_str));
+ ASSERT_TRUE(WaitForLoadStop(web_contents));
+ }
+
+ // main frame and subframe should be in separate processes.
+ FrameTreeNode* subframe_node = root->child_at(0);
+ RenderFrameHostImpl* subframe = subframe_node->current_frame_host();
+ EXPECT_NE(main_frame->GetProcess(), subframe->GetProcess());
+
+ // Retrieve the RenderFrameProxyHost representing the main frame for the
+ // subframe's process.
+ RenderFrameProxyHost* main_frame_proxy_host =
+ main_frame->browsing_context_state()->GetRenderFrameProxyHost(
+ subframe->GetSiteInstance()->group());
+
+ // Intercept the RouteMessageEvent IPC message that will come from
+ // the subframe process.
+ CapabilityDelegationRemoteFrameHostInterceptor mojo_interceptor(
+ main_frame_proxy_host);
+
+ // Post a message from the subframe to the cross-site parent and intercept the
+ // associated IPC message, changing it to include a delegated capability.
+ // Since there is no active or recently consumed user activation on the
+ // subframe, this should result in a renderer kill.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+ EXPECT_TRUE(ExecJs(subframe, "parent.postMessage('blah', '*')",
+ EXECUTE_SCRIPT_NO_USER_GESTURE));
+ EXPECT_EQ(bad_message::RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY,
+ kill_waiter.Wait());
+}
+
+// Test verifying that a legitimate renderer with user activation CAN delegate a
+// capability (e.g. fullscreen) to another frame.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ PostMessageDelegatedCapabilityWithUserActivation) {
+ // Explicitly isolating b.com helps ensure that this test is applicable on
+ // platforms without site-per-process.
+ IsolateOrigin("b.com");
+
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+ RenderFrameHostImpl* main_frame = root->current_frame_host();
+
+ // Create cross-site child frame.
+ GURL child_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+ {
+ std::string js_str = base::StringPrintf(
+ "var frame = document.createElement('iframe'); "
+ "frame.src = '%s'; "
+ "document.body.appendChild(frame);",
+ child_url.spec().c_str());
+ EXPECT_TRUE(ExecJs(main_frame, js_str));
+ ASSERT_TRUE(WaitForLoadStop(web_contents));
+ }
+
+ // main frame and subframe should be in separate processes.
+ FrameTreeNode* subframe_node = root->child_at(0);
+ RenderFrameHostImpl* subframe = subframe_node->current_frame_host();
+ EXPECT_NE(main_frame->GetProcess(), subframe->GetProcess());
+
+ // Retrieve the RenderFrameProxyHost representing the main frame for the
+ // subframe's process.
+ RenderFrameProxyHost* main_frame_proxy_host =
+ main_frame->browsing_context_state()->GetRenderFrameProxyHost(
+ subframe->GetSiteInstance()->group());
+
+ // Intercept the RouteMessageEvent IPC message that will come from
+ // the subframe process.
+ CapabilityDelegationRemoteFrameHostInterceptor mojo_interceptor(
+ main_frame_proxy_host);
+
+ // Post a message from the subframe to the cross-site parent and intercept the
+ // associated IPC message, changing it to include a delegated capability.
+ // This time, we execute the script WITH user gesture (default ExecJs option).
+ // The browser should allow this and NOT terminate the subframe process.
+ EXPECT_TRUE(ExecJs(subframe, "parent.postMessage('blah', '*')"));
+
+ // Verify that the subframe process is still alive.
+ EXPECT_TRUE(subframe->IsRenderFrameLive());
+}
+
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
InvalidRemoteNavigationInitiator) {
// Explicitly isolating a.com helps ensure that this test is applicable on
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page