Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Navigation
DescriptionMissing authorization in Navigation
ComponentNavigation
Sub-componentInternals>Sandbox>SiteIsolation, UI>Browser>Navigation
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker495529018
Fix commit8f9573ac59e3 (chromium/src) +187/-1
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-23 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-09-22
Patch gap34 days — fix public in source before the advisory

Background

`BrowsingContextState`
The browser-side object holding a frame’s FrameReplicationState, shared between a FrameTreeNode’s current document and any speculative RenderFrameHostImpl that will replace it.
`FrameReplicationState`
Replicated per-frame security state (insecure-request policy, insecure-navigations set, sticky user activation, ad-frame status) that the browser mirrors out to a frame’s proxies in other processes.
Speculative `RenderFrameHostImpl`
A not-yet-committed RenderFrameHostImpl created for a pending cross-origin navigation, whose lifecycle_state() is not kActive while the still-current document remains live.
`LifecycleStateImpl`
The lifecycle enum (kActive, kPendingCommit, kPrerendering, kSpeculative, back-forward-cache/pending-deletion states) that identifies whether a RenderFrameHostImpl is the frame’s live document.

Root Cause Analysis

The replication-update IPC handlers EnforceInsecureRequestPolicy, EnforceInsecureNavigationsSet, and HadStickyUserActivationBeforeNavigationChanged in render_frame_host_impl.cc wrote directly into the BrowsingContextState shared by a speculative RenderFrameHostImpl and the FrameTreeNode’s current document, without verifying that the receiving RenderFrameHostImpl was actually the live document. Because a speculative RenderFrameHostImpl shares that state with the still-current cross-origin document, a message received on the speculative host would overwrite the current document’s FrameReplicationState and be broadcast to its proxies, violating the invariant that only the current (active) document may mutate its own replicated security state.

The fix adds a gate, CanApplyFrameReplicationUpdate, that accepts updates only when the RenderFrameHostImpl is kActive, kPendingCommit, or kPrerendering, and rejects (evicting from bfcache where applicable) updates arriving on speculative, pending-deletion, or back-forward-cached hosts. This works because it ensures replicated state is written only by a host that legitimately owns the frame’s current or about-to-commit document, so a speculative renderer can no longer forge state for the live cross-origin document.

Key insight
The core mistake was applying renderer-supplied FrameReplicationState updates without confirming the sender is the frame’s current document, letting a speculative cross-origin RenderFrameHostImpl clobber shared state; the fix gates every such update on lifecycle_state() via CanApplyFrameReplicationUpdate.

Attack Path

  1. Start a cross-origin navigation A page with an attacker-influenced frame triggers a navigation that causes the browser to create a speculative RenderFrameHostImpl while the current cross-origin document stays live.
  2. Send a replication update from the speculative renderer The speculative renderer process sends EnforceInsecureRequestPolicy, EnforceInsecureNavigationsSet, or HadStickyUserActivationBeforeNavigationChanged before its navigation commits.
  3. Overwrite the current document's state The unguarded handler writes the update into the BrowsingContextState shared with the current document, overwriting its FrameReplicationState. (inferred)
  4. Propagate to proxies The corrupted replicated state (e.g. insecure-request policy or sticky user activation) is broadcast to the frame’s proxies in other processes, altering security decisions for the still-current cross-origin document. (inferred)

Impact Assessment

An attacker controlling a speculative renderer can forge replicated security state (insecure-request policy, insecure-navigations set, or sticky user activation) for a still-current cross-origin document and have it propagated to that frame’s proxies, per the advisory a missing-authorization logic error in Navigation. The precondition is an in-flight cross-origin navigation that yields a speculative RenderFrameHostImpl sharing BrowsingContextState with the live document. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4. The advisory does not report exploitation in the wild or state a severity here.

Changed Functions

FunctionChangeNotes
RemoteFrameHostInterceptor
content/browser/security_exploit_browsertest.cc
modified

Files Changed

  • content/browser/back_forward_cache/back_forward_cache_can_store_document_result.cc
  • content/public/browser/back_forward_cache.h
  • content/browser/devtools/protocol/page_handler.cc
  • tools/metrics/histograms/metadata/navigation/enums.xml
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/back_forward_cache_internal_browsertest.cc
  • base/tracing/protos/chrome_track_event.proto
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Lifecycle-unaware IPC handlers
    Audit other RenderFrameHostImpl mojo handlers that mutate BrowsingContextState/FrameReplicationState without checking lifecycle_state(), since any writable-by-speculative-host path shares the same missing-authorization flaw.
  • Shared state between speculative and current documents
    Look for state objects co-owned by a FrameTreeNode’s current and speculative RenderFrameHostImpl where a not-yet-committed renderer can write and have changes broadcast to proxies.
  • Proxy-broadcast side effects
    Review handlers whose writes replicate outward to cross-process proxies, and confirm each rejects updates from pending-deletion or back-forward-cached hosts rather than silently applying them.
From 8f9573ac59e3298bbeeabbcad8fd30e4997ade22 Mon Sep 17 00:00:00 2001
From: Arthur Sonzogni <[email protected]>
Date: Wed, 19 Aug 2026 04:10:34 -0700
Subject: [PATCH] Ignore FrameReplicationState updates from speculative RFHs

EnforceInsecureRequestPolicy, EnforceInsecureNavigationsSet and
HadStickyUserActivationBeforeNavigationChanged write directly to the
BrowsingContextState that a speculative RenderFrameHost shares with the
FrameTreeNode's current document. This means that updates received on a
speculative RenderFrameHost would overwrite the FrameReplicationState of
the still-current cross-origin document and be broadcast to its proxies.

Ignore these updates when the receiving RenderFrameHost is speculative,
pending deletion, or in the back-forward cache. kPendingCommit is
intentionally still accepted, since the renderer sends these messages
while committing the new document and before the browser has processed
DidCommitNavigation.

Bug: 495529018
Change-Id: If7d0ec22c48ce9f16aadad906cd601190c44f0fd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8116306
Reviewed-by: Robert Kaplow <[email protected]>
Commit-Queue: Arthur Sonzogni <[email protected]>
Auto-Submit: Arthur Sonzogni <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1682187}
---
diff --git a/base/tracing/protos/chrome_track_event.proto b/base/tracing/protos/chrome_track_event.proto
index 5120d18..015f59a 100644
--- a/base/tracing/protos/chrome_track_event.proto
+++ b/base/tracing/protos/chrome_track_event.proto
@@ -731,6 +731,10 @@
     SHARED_WORKER_WITH_NO_ACTIVE_CLIENT = 69;
     WEB_LOCKS_CONTENTION = 70;
     FORWARD_CACHE_DISABLED = 71;
+    RFH_ENFORCE_INSECURE_REQUEST_POLICY = 72;
+    RFH_ENFORCE_INSECURE_NAVIGATIONS_SET = 73;
+    RFH_HAD_STICKY_USER_ACTIVATION_BEFORE_NAVIGATION_CHANGED = 74;
+    RFH_UPDATE_IS_AD_FRAME = 75;
   }
 
   optional BackForwardCacheNotRestoredReason
diff --git a/content/browser/back_forward_cache/back_forward_cache_can_store_document_result.cc b/content/browser/back_forward_cache/back_forward_cache_can_store_document_result.cc
index ff6ca290..d6c90b9b 100644
--- a/content/browser/back_forward_cache/back_forward_cache_can_store_document_result.cc
+++ b/content/browser/back_forward_cache/back_forward_cache_can_store_document_result.cc
@@ -210,6 +210,15 @@
       return ProtoEnum::WEB_LOCKS_CONTENTION;
     case Reason::kForwardCacheDisabled:
       return ProtoEnum::FORWARD_CACHE_DISABLED;
+    case Reason::kRfhEnforceInsecureNavigationsSet:
+      return ProtoEnum::RFH_ENFORCE_INSECURE_NAVIGATIONS_SET;
+    case Reason::kRfhEnforceInsecureRequestPolicy:
+      return ProtoEnum::RFH_ENFORCE_INSECURE_REQUEST_POLICY;
+    case Reason::kRfhHadStickyUserActivationBeforeNavigationChanged:
+      return ProtoEnum::
+          RFH_HAD_STICKY_USER_ACTIVATION_BEFORE_NAVIGATION_CHANGED;
+    case Reason::kRfhUpdateIsAdFrame:
+      return ProtoEnum::RFH_UPDATE_IS_AD_FRAME;
   }
   NOTREACHED();
 }
@@ -476,6 +485,14 @@
       return "Pages with web locks in bfcache encountered a lock contention";
     case Reason::kForwardCacheDisabled:
       return "Forward cache is disabled";
+    case Reason::kRfhEnforceInsecureNavigationsSet:
+      return "Rfh: EnforceInsecureNavigationsSet";
+    case Reason::kRfhEnforceInsecureRequestPolicy:
+      return "Rfh: EnforceInsecureRequestPolicy";
+    case Reason::kRfhHadStickyUserActivationBeforeNavigationChanged:
+      return "Rfh: HadStickyUserActivationBeforeNavigationChanged";
+    case Reason::kRfhUpdateIsAdFrame:
+      return "Rfh: UpdateIsAdFrame";
   }
 }
 
@@ -603,6 +620,10 @@
     case Reason::kWebViewSafeBrowsingAllowlistChanged:
     case Reason::kWebViewDocumentStartJavascriptChanged:
     case Reason::kForwardCacheDisabled:
+    case Reason::kRfhEnforceInsecureNavigationsSet:
+    case Reason::kRfhEnforceInsecureRequestPolicy:
+    case Reason::kRfhHadStickyUserActivationBeforeNavigationChanged:
+    case Reason::kRfhUpdateIsAdFrame:
     case Reason::kUnknown:
       return "masked";
   }
diff --git a/content/browser/devtools/protocol/page_handler.cc b/content/browser/devtools/protocol/page_handler.cc
index 0805eafa..431a0e3f 100644
--- a/content/browser/devtools/protocol/page_handler.cc
+++ b/content/browser/devtools/protocol/page_handler.cc
@@ -2144,6 +2144,10 @@
       // into sub reasons.
       NOTREACHED();
     case Reason::kUnknown:
+    case Reason::kRfhEnforceInsecureNavigationsSet:
+    case Reason::kRfhEnforceInsecureRequestPolicy:
+    case Reason::kRfhHadStickyUserActivationBeforeNavigationChanged:
+    case Reason::kRfhUpdateIsAdFrame:
       return Page::BackForwardCacheNotRestoredReasonEnum::Unknown;
     case Reason::kCacheControlNoStoreDeviceBoundSessionTerminated:
       return Page::BackForwardCacheNotRestoredReasonEnum::
@@ -2470,6 +2474,10 @@
     case Reason::kWebLocksContention:
       return Page::BackForwardCacheNotRestoredReasonTypeEnum::PageSupportNeeded;
     case Reason::kNetworkRequestDatapipeDrainedAsBytesConsumer:
+    case Reason::kRfhEnforceInsecureNavigationsSet:
+    case Reason::kRfhEnforceInsecureRequestPolicy:
+    case Reason::kRfhHadStickyUserActivationBeforeNavigationChanged:
+    case Reason::kRfhUpdateIsAdFrame:
     case Reason::kUnknown:
       return Page::BackForwardCacheNotRestoredReasonTypeEnum::SupportPending;
     case Reason::kBlocklistedFeatures:
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 9cd2140..6a456ca3 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -534,6 +534,39 @@
   NOTREACHED();
 }
 
+bool CanApplyFrameReplicationUpdate(
+    RenderFrameHostImpl* rfh,
+    BackForwardCacheMetrics::NotRestoredReason eviction_reason) {
+  // These updates apply to the BrowsingContextState that this RenderFrameHost
+  // shares with the FrameTreeNode's current document, so only accept them once
+  // this RenderFrameHost has been (or is about to be) swapped in (kActive,
+  // kPendingCommit, or kPrerendering):
+  // - kPendingCommit: Accepted because the renderer sends updates (e.g. ad
+  //   tagging) while committing the new document and before the browser has
+  //   processed the corresponding DidCommitNavigation message.
+  //   TODO(crbug.com/547754865): Consider sending replication state at
+  //   DidCommitNavigation time so updates during kPendingCommit can be avoided.
+  // - kPrerendering: Accepted because prerendered pages run in a completely
+  //   isolated FrameTree that has never seen any active RenderFrameHost before
+  //   activation. Their updates only mutate their own isolated
+  //   BrowsingContextState, and prerendered pages actively load and can
+  //   legitimately update state (e.g. CSP headers, ad tags).
+  //   TODO(crbug.com/547754865): Investigate if any replication updates in
+  //   prerendering should be deferred until activation.
+  if (rfh->lifecycle_state() ==
+          RenderFrameHostImpl::LifecycleStateImpl::kActive ||
+      rfh->lifecycle_state() ==
+          RenderFrameHostImpl::LifecycleStateImpl::kPendingCommit ||
+      rfh->lifecycle_state() ==
+          RenderFrameHostImpl::LifecycleStateImpl::kPrerendering) {
+    return true;
+  }
+  if (rfh->IsInBackForwardCache()) {
+    rfh->EvictFromBackForwardCacheWithReason(eviction_reason);
+  }
+  return false;
+}
+
 // Ensure that we reset nav_entry_id_ in DidCommitProvisionalLoad if any of
 // the validations fail and lead to an early return.  Call disable() once we
 // know the commit will be successful.  Resetting nav_entry_id_ avoids acting on
@@ -8552,11 +8585,21 @@
 
 void RenderFrameHostImpl::EnforceInsecureRequestPolicy(
     blink::mojom::InsecureRequestPolicy policy) {
+  if (!CanApplyFrameReplicationUpdate(
+          this, BackForwardCacheMetrics::NotRestoredReason::
+                    kRfhEnforceInsecureRequestPolicy)) {
+    return;
+  }
   browsing_context_state_->SetInsecureRequestPolicy(policy);
 }
 
 void RenderFrameHostImpl::EnforceInsecureNavigationsSet(
     const std::vector<uint32_t>& set) {
+  if (!CanApplyFrameReplicationUpdate(
+          this, BackForwardCacheMetrics::NotRestoredReason::
+                    kRfhEnforceInsecureNavigationsSet)) {
+    return;
+  }
   browsing_context_state_->SetInsecureNavigationsSet(set);
 }
 
@@ -9829,6 +9872,11 @@
 
 void RenderFrameHostImpl::HadStickyUserActivationBeforeNavigationChanged(
     bool value) {
+  if (!CanApplyFrameReplicationUpdate(
+          this, BackForwardCacheMetrics::NotRestoredReason::
+                    kRfhHadStickyUserActivationBeforeNavigationChanged)) {
+    return;
+  }
   browsing_context_state_->OnSetHadStickyUserActivationBeforeNavigation(value);
 }
 
@@ -19088,6 +19136,11 @@
 }
 
 void RenderFrameHostImpl::UpdateIsAdFrame(bool is_ad_frame) {
+  if (!CanApplyFrameReplicationUpdate(
+          this,
+          BackForwardCacheMetrics::NotRestoredReason::kRfhUpdateIsAdFrame)) {
+    return;
+  }
   browsing_context_state_->SetIsAdFrame(is_ad_frame);
 }
 
diff --git a/content/public/browser/back_forward_cache.h b/content/public/browser/back_forward_cache.h
index 6874be0..59e199b 100644
--- a/content/public/browser/back_forward_cache.h
+++ b/content/public/browser/back_forward_cache.h
@@ -123,7 +123,11 @@
     kSharedWorkerWithNoActiveClient = 73,
     kWebLocksContention = 74,
     kForwardCacheDisabled = 75,
-    kMaxValue = kForwardCacheDisabled,
+    kRfhEnforceInsecureNavigationsSet = 76,
+    kRfhEnforceInsecureRequestPolicy = 77,
+    kRfhHadStickyUserActivationBeforeNavigationChanged = 78,
+    kRfhUpdateIsAdFrame = 79,
+    kMaxValue = kRfhUpdateIsAdFrame,
   };
   // LINT.ThenChange(//tools/metrics/histograms/metadata/navigation/enums.xml:BackForwardCacheNotRestoredReason)
 
diff --git a/tools/metrics/histograms/metadata/navigation/enums.xml b/tools/metrics/histograms/metadata/navigation/enums.xml
index e8ef015..f898105 100644
--- a/tools/metrics/histograms/metadata/navigation/enums.xml
+++ b/tools/metrics/histograms/metadata/navigation/enums.xml
@@ -144,6 +144,10 @@
   <int value="74"
       label="Pages with web locks in bfcache encountered a lock contention"/>
   <int value="75" label="Forward cache is disabled"/>
+  <int value="76" label="Rfh EnforceInsecureNavigationsSet"/>
+  <int value="77" label="Rfh EnforceInsecureRequestPolicy"/>
+  <int value="78" label="Rfh HadStickyUserActivationBeforeNavigationChanged"/>
+  <int value="79" label="Rfh UpdateIsAdFrame"/>
 </enum>
 
 <!-- LINT.ThenChange(//content/public/browser/back_forward_cache.h:NotRestoredReason) -->
diff --git a/content/browser/back_forward_cache_internal_browsertest.cc b/content/browser/back_forward_cache_internal_browsertest.cc
index 96975c30..e77c142 100644
--- a/content/browser/back_forward_cache_internal_browsertest.cc
+++ b/content/browser/back_forward_cache_internal_browsertest.cc
@@ -4931,4 +4931,32 @@
   }
 }
 
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+                       BFCachedFrameCannotUpdateFrameReplicationState) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+  // 1) Navigate to A.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  RenderFrameHostImpl* rfh_a = current_frame_host();
+  RenderFrameDeletedObserver delete_observer_rfh_a(rfh_a);
+
+  // 2) Navigate to B. Page A should go to BFCache.
+  EXPECT_TRUE(NavigateToURL(shell(), url_b));
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // 3) Simulate a compromised renderer sending state updates from the cached
+  // RenderFrameHost. This should trigger eviction.
+  static_cast<blink::mojom::LocalFrameHost*>(rfh_a)
+      ->EnforceInsecureRequestPolicy(
+          blink::mojom::InsecureRequestPolicy::kBlockAllMixedContent);
+
+  // 4) Go back to A. It should not be restored.
+  ASSERT_TRUE(HistoryGoBack(web_contents()));
+  ExpectNotRestored({NotRestoredReason::kRfhEnforceInsecureRequestPolicy}, {},
+                    {}, {}, {}, FROM_HERE);
+}
+
 }  // namespace content
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 0c00613..54b113f 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -134,6 +134,7 @@
 #include "third_party/blink/public/mojom/file/file_utilities.mojom.h"
 #include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/frame/frame.mojom.h"
+#include "third_party/blink/public/mojom/frame/frame_replication_state.mojom.h"
 #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
 #include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
@@ -2003,6 +2004,69 @@
             kill_waiter.Wait());
 }
 
+// Regression test for crbug.com/495529018.
+// Verify that a speculative RenderFrameHost cannot update FrameReplicationState
+// in the BrowsingContextState that it shares with the FrameTreeNode's current
+// cross-origin document.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       SpeculativeFrameCannotUpdateFrameReplicationState) {
+  IsolateAllSitesForTesting(base::CommandLine::ForCurrentProcess());
+
+  GURL main_url(embedded_test_server()->GetURL(
+      "a.com", "/cross_site_iframe_factory.html?a(b)"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* child =
+      web_contents->GetPrimaryFrameTree().root()->child_at(0);
+  RenderFrameHostImpl* current_rfh = child->current_frame_host();
+
+  // Start a cross-process navigation in the subframe so that a speculative
+  // RenderFrameHost is created. The speculative RenderFrameHost shares the
+  // FrameTreeNode's BrowsingContextState with the current document.
+  GURL cross_site_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+  TestNavigationManager nav_manager(web_contents, cross_site_url);
+  EXPECT_TRUE(BeginNavigateToURLFromRenderer(child, cross_site_url));
+  nav_manager.WaitForSpeculativeRenderFrameHostCreation();
+
+  RenderFrameHostImpl* speculative_rfh =
+      child->render_manager()->speculative_frame_host();
+  ASSERT_TRUE(speculative_rfh);
+  ASSERT_EQ(RenderFrameHostImpl::LifecycleStateImpl::kSpeculative,
+            speculative_rfh->lifecycle_state());
+  EXPECT_NE(speculative_rfh->GetProcess(), current_rfh->GetProcess());
+  ASSERT_EQ(speculative_rfh->browsing_context_state().get(),
+            current_rfh->browsing_context_state().get());
+
+  const blink::mojom::FrameReplicationState& replication_state =
+      child->current_replication_state();
+  ASSERT_EQ(blink::mojom::InsecureRequestPolicy::kLeaveInsecureRequestsAlone,
+            replication_state.insecure_request_policy);
+  ASSERT_TRUE(replication_state.insecure_navigations_set.empty());
+  ASSERT_FALSE(replication_state.has_received_user_gesture_before_nav);
+  bool initial_is_ad_frame = replication_state.is_ad_frame;
+
+  // Simulate a compromised renderer sending state updates from the speculative
+  // RenderFrameHost before the navigation has committed. These should be
+  // ignored and must not modify the replication state of the still-current
+  // cross-origin document.
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->EnforceInsecureRequestPolicy(
+          blink::mojom::InsecureRequestPolicy::kBlockAllMixedContent);
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->EnforceInsecureNavigationsSet({1u});
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->HadStickyUserActivationBeforeNavigationChanged(true);
+  speculative_rfh->UpdateIsAdFrame(true);
+
+  EXPECT_EQ(blink::mojom::InsecureRequestPolicy::kLeaveInsecureRequestsAlone,
+            replication_state.insecure_request_policy);
+  EXPECT_TRUE(replication_state.insecure_navigations_set.empty());
+  EXPECT_FALSE(replication_state.has_received_user_gesture_before_nav);
+  EXPECT_EQ(initial_is_ad_frame, replication_state.is_ad_frame);
+}
+
 class RemoteFrameHostInterceptor
     : public blink::mojom::RemoteFrameHostInterceptorForTesting {
  public:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/back_forward_cache_internal_browsertest.cc b/content/browser/back_forward_cache_internal_browsertest.cc
index 96975c30..e77c142 100644
--- a/content/browser/back_forward_cache_internal_browsertest.cc
+++ b/content/browser/back_forward_cache_internal_browsertest.cc
@@ -4931,4 +4931,32 @@
   }
 }
 
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+                       BFCachedFrameCannotUpdateFrameReplicationState) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+  // 1) Navigate to A.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  RenderFrameHostImpl* rfh_a = current_frame_host();
+  RenderFrameDeletedObserver delete_observer_rfh_a(rfh_a);
+
+  // 2) Navigate to B. Page A should go to BFCache.
+  EXPECT_TRUE(NavigateToURL(shell(), url_b));
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // 3) Simulate a compromised renderer sending state updates from the cached
+  // RenderFrameHost. This should trigger eviction.
+  static_cast<blink::mojom::LocalFrameHost*>(rfh_a)
+      ->EnforceInsecureRequestPolicy(
+          blink::mojom::InsecureRequestPolicy::kBlockAllMixedContent);
+
+  // 4) Go back to A. It should not be restored.
+  ASSERT_TRUE(HistoryGoBack(web_contents()));
+  ExpectNotRestored({NotRestoredReason::kRfhEnforceInsecureRequestPolicy}, {},
+                    {}, {}, {}, FROM_HERE);
+}
+
 }  // namespace content
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 0c00613..54b113f 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -134,6 +134,7 @@
 #include "third_party/blink/public/mojom/file/file_utilities.mojom.h"
 #include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/frame/frame.mojom.h"
+#include "third_party/blink/public/mojom/frame/frame_replication_state.mojom.h"
 #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
 #include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
@@ -2003,6 +2004,69 @@
             kill_waiter.Wait());
 }
 
+// Regression test for crbug.com/495529018.
+// Verify that a speculative RenderFrameHost cannot update FrameReplicationState
+// in the BrowsingContextState that it shares with the FrameTreeNode's current
+// cross-origin document.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       SpeculativeFrameCannotUpdateFrameReplicationState) {
+  IsolateAllSitesForTesting(base::CommandLine::ForCurrentProcess());
+
+  GURL main_url(embedded_test_server()->GetURL(
+      "a.com", "/cross_site_iframe_factory.html?a(b)"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* child =
+      web_contents->GetPrimaryFrameTree().root()->child_at(0);
+  RenderFrameHostImpl* current_rfh = child->current_frame_host();
+
+  // Start a cross-process navigation in the subframe so that a speculative
+  // RenderFrameHost is created. The speculative RenderFrameHost shares the
+  // FrameTreeNode's BrowsingContextState with the current document.
+  GURL cross_site_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+  TestNavigationManager nav_manager(web_contents, cross_site_url);
+  EXPECT_TRUE(BeginNavigateToURLFromRenderer(child, cross_site_url));
+  nav_manager.WaitForSpeculativeRenderFrameHostCreation();
+
+  RenderFrameHostImpl* speculative_rfh =
+      child->render_manager()->speculative_frame_host();
+  ASSERT_TRUE(speculative_rfh);
+  ASSERT_EQ(RenderFrameHostImpl::LifecycleStateImpl::kSpeculative,
+            speculative_rfh->lifecycle_state());
+  EXPECT_NE(speculative_rfh->GetProcess(), current_rfh->GetProcess());
+  ASSERT_EQ(speculative_rfh->browsing_context_state().get(),
+            current_rfh->browsing_context_state().get());
+
+  const blink::mojom::FrameReplicationState& replication_state =
+      child->current_replication_state();
+  ASSERT_EQ(blink::mojom::InsecureRequestPolicy::kLeaveInsecureRequestsAlone,
+            replication_state.insecure_request_policy);
+  ASSERT_TRUE(replication_state.insecure_navigations_set.empty());
+  ASSERT_FALSE(replication_state.has_received_user_gesture_before_nav);
+  bool initial_is_ad_frame = replication_state.is_ad_frame;
+
+  // Simulate a compromised renderer sending state updates from the speculative
+  // RenderFrameHost before the navigation has committed. These should be
+  // ignored and must not modify the replication state of the still-current
+  // cross-origin document.
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->EnforceInsecureRequestPolicy(
+          blink::mojom::InsecureRequestPolicy::kBlockAllMixedContent);
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->EnforceInsecureNavigationsSet({1u});
+  static_cast<blink::mojom::LocalFrameHost*>(speculative_rfh)
+      ->HadStickyUserActivationBeforeNavigationChanged(true);
+  speculative_rfh->UpdateIsAdFrame(true);
+
+  EXPECT_EQ(blink::mojom::InsecureRequestPolicy::kLeaveInsecureRequestsAlone,
+            replication_state.insecure_request_policy);
+  EXPECT_TRUE(replication_state.insecure_navigations_set.empty());
+  EXPECT_FALSE(replication_state.has_received_user_gesture_before_nav);
+  EXPECT_EQ(initial_is_ad_frame, replication_state.is_ad_frame);
+}
+
 class RemoteFrameHostInterceptor
     : public blink::mojom::RemoteFrameHostInterceptorForTesting {
  public:
Loading diff…

Original Bug Report

reported by [email protected]

Cross-process security state manipulation via shared BrowsingContextState

Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.

Overview: Several Mojo methods in RenderFrameHostImpl lack lifecycle checks, allowing a compromised renderer to maliciously modify the shared BrowsingContextState. An attacker can invoke these methods on a speculative frame to alter the security state (e.g., user activation, mixed content policies) of an active, cross-origin frame in the same BrowsingInstance.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc

Estimated timestamp from git blame: 2022-07-22

Description

A potential vulnerability exists in content/browser/renderer_host/render_frame_host_impl.cc where several Mojo methods handling frame security state lack proper lifecycle checks. Specifically, the following methods in the blink::mojom::LocalFrameHost interface delegate directly to browsing_context_state_ without verifying if the caller is the active RenderFrameHost:

  • EnforceInsecureRequestPolicy
  • EnforceInsecureNavigationsSet
  • HadStickyUserActivationBeforeNavigationChanged

Additionally, the C++ method UpdateIsAdFrame is similarly affected.

When a cross-site navigation occurs within the same BrowsingInstance (e.g., navigating an iframe), the browser creates a speculative RenderFrameHostImpl that shares the same BrowsingContextState object as the currently active RenderFrameHostImpl. Because there are no lifecycle checks (such as IsInactiveAndDisallowActivation()) in the aforementioned methods, a compromised renderer hosting the speculative frame can send Mojo messages to modify the shared BrowsingContextState.

These changes update the FrameReplicationState and are broadcast to all proxy hosts in other processes, effectively altering the security state of the active, cross-origin victim document.

Impact

By manipulating the shared state from a speculative frame, an attacker can:

  1. Spoof User Activation: Set the had_sticky_user_activation_before_nav flag. If the victim frame subsequently performs a same-origin navigation, it will inherit this spoofed activation, allowing the attacker to bypass the Autoplay Policy, Vibration API restrictions, and Virtual Keyboard API checks without any actual user interaction.
  2. Downgrade Security Policies: Alter the insecure_request_policy to disable upgrade-insecure-requests, potentially allowing mixed content to load in the victim frame.
  3. Manipulate Ad State: Maliciously tag the active frame as an ad frame, potentially subjecting it to heavy ad interventions or throttling.

Suggested Attacker Steps

Note: These are potential steps, as this analysis was performed by an LLM agent without the ability to run a live Proof of Concept.

  1. An attacker gains arbitrary code execution in a sandboxed renderer process (e.g., via a V8 exploit) hosting attacker.com.
  2. The attacker embeds a cross-origin iframe pointing to victim.com.
  3. The attacker triggers a navigation of the victim.com iframe to a secondary attacker-controlled URL, attacker.com/2.
  4. The browser creates a speculative RenderFrameHostImpl for attacker.com/2. This speculative frame shares the BrowsingContextState of the active victim.com frame.
  5. Before the navigation commits, the compromised renderer for attacker.com/2 sends a HadStickyUserActivationBeforeNavigationChanged(true) message over the LocalFrameHost Mojo interface.
  6. The browser processes this message on the speculative RenderFrameHostImpl and updates the shared BrowsingContextState, spoofing sticky user activation for the active victim.com frame.
  7. The attacker cancels the navigation. The victim.com frame can now perform actions requiring user activation (like autoplaying media) upon its next same-site navigation.

Add standard lifecycle checks to the affected methods in RenderFrameHostImpl to ensure they cannot be invoked by inactive or speculative frames. For example:

void RenderFrameHostImpl::HadStickyUserActivationBeforeNavigationChanged(
    bool value) {
  if (IsInactiveAndDisallowActivation(
          DisallowActivationReasonId::kHadStickyUserActivationBeforeNavigationChanged)) {
    return;
  }
  browsing_context_state_->OnSetHadStickyUserActivationBeforeNavigation(value);
}

Similar checks should be added to EnforceInsecureRequestPolicy, EnforceInsecureNavigationsSet, and UpdateIsAdFrame.

Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f


Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s 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