CVE-2026-11175
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc |
modified | |
WebContentsObserverchrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc |
modified | |
FramebustBlockedMessageDelegatechrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.h |
modified | |
TEST_Fchrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc |
modified |
Files Changed
chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.ccchrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.hchrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc
Patch
From 589cfed6298aec19f9e15c94f84285e4d1c13ecd Mon Sep 17 00:00:00 2001 From: Liam Brady <[email protected]> Date: Fri, 24 Apr 2026 17:18:30 -0700 Subject: [PATCH] Clear Android framebust message on cross-document navigations. This CL has the behavior of the Android framebust message match the behavior on Desktop by clearing the message on all cross-document main frame navigations, including reloads. We override `PrimaryPageChanged` in `FramebustBlockedMessageDelegate` (by having it inherit `WebContentsObserver`) to explicitly dismiss the message. Bug: 502368088 Change-Id: I2d1efc71ef921dfa0db74b2ff08b2894fbedccaa Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7788091 Commit-Queue: Liam Brady <[email protected]> Reviewed-by: Andrew Grieve <[email protected]> Cr-Commit-Position: refs/heads/main@{#1620573} --- diff --git a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc index 846aabda..395a4bcc 100644 --- a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc +++ b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc @@ -84,16 +84,14 @@ } FramebustBlockedMessageDelegate::~FramebustBlockedMessageDelegate() { - if (message_ != nullptr) { - messages::MessageDispatcherBridge::Get()->DismissMessage( - message_.get(), messages::DismissReason::UNKNOWN); - } + DismissMessage(); } FramebustBlockedMessageDelegate::FramebustBlockedMessageDelegate( content::WebContents* web_contents) : content::WebContentsUserData<FramebustBlockedMessageDelegate>( - *web_contents) {} + *web_contents), + content::WebContentsObserver(web_contents) {} void FramebustBlockedMessageDelegate::HandleDismissCallback( messages::DismissReason dismiss_reason) { @@ -128,6 +126,17 @@ .Run(InterventionOutcome::kDeclinedAndNavigated); } +void FramebustBlockedMessageDelegate::DismissMessage() { + if (message_ != nullptr) { + messages::MessageDispatcherBridge::Get()->DismissMessage( + message_.get(), messages::DismissReason::UNKNOWN); + } +} + +void FramebustBlockedMessageDelegate::PrimaryPageChanged(content::Page& page) { + DismissMessage(); +} + WEB_CONTENTS_USER_DATA_KEY_IMPL(FramebustBlockedMessageDelegate); } // namespace blocked_content diff --git a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.h b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.h index 576639ad..09693cb7 100644 --- a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.h +++ b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.h @@ -11,6 +11,8 @@ #include "base/memory/raw_ptr.h" #include "components/messages/android/message_enums.h" #include "components/messages/android/message_wrapper.h" +#include "content/public/browser/page.h" +#include "content/public/browser/web_contents_observer.h" #include "content/public/browser/web_contents_user_data.h" #include "url/gurl.h" @@ -26,7 +28,8 @@ // Created lazily when a framebust is first blocked, and matches the // lifetime of WebContents afterwards. class FramebustBlockedMessageDelegate - : public content::WebContentsUserData<FramebustBlockedMessageDelegate> { + : public content::WebContentsUserData<FramebustBlockedMessageDelegate>, + public content::WebContentsObserver { public: // Describes the actions the user can take regarding this intervention, they // are provided through a callback the caller can pass to the delegate's @@ -56,6 +59,10 @@ void HandleClick(); void HandleDismissCallback(messages::DismissReason dismiss_reason); void HandleOpenLink(); + void DismissMessage(); + + // content::WebContentsObserver: + void PrimaryPageChanged(content::Page& page) override; raw_ptr<HostContentSettingsMap> settings_map_ = nullptr; diff --git a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc index ae9155c..843895a 100644 --- a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc +++ b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc @@ -53,6 +53,10 @@ return framebust_blocked_message_delegate_; } + messages::MockMessageDispatcherBridge& message_dispatcher_bridge() { + return message_dispatcher_bridge_; + } + private: base::test::ScopedFeatureList feature_list_; sync_preferences::TestingPrefServiceSyncable pref_service_; @@ -195,4 +199,30 @@ CONTENT_SETTING_BLOCK); } +// Tests that the message is dismissed when a navigation happens. +TEST_F(FramebustBlockedMessageDelegateTest, ClearOnNavigation) { + EnqueueMessage(GURL("a.test")); + EXPECT_NE(nullptr, GetMessageWrapper()); + + // This will be called when the navigation happens. + EXPECT_CALL(message_dispatcher_bridge(), DismissMessage) + .WillOnce(testing::Return()); + + // Same-site cross-document navigation. + NavigateAndCommit(GURL(kPageUrl).Resolve("/new_path")); +} + +// Tests that the message is dismissed when a reload happens. +TEST_F(FramebustBlockedMessageDelegateTest, ClearOnReload) { + EnqueueMessage(GURL("a.test")); + EXPECT_NE(nullptr, GetMessageWrapper()); + + // This will be called when the reload happens. + EXPECT_CALL(message_dispatcher_bridge(), DismissMessage) + .WillOnce(testing::Return()); + + // Simulate reload by navigating to the same URL. + NavigateAndCommit(GURL(kPageUrl)); +} + } // namespace blocked_content
Regression Test / PoC
diff --git a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc
index ae9155c..843895a 100644
--- a/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc
+++ b/chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android_unittest.cc
@@ -53,6 +53,10 @@
return framebust_blocked_message_delegate_;
}
+ messages::MockMessageDispatcherBridge& message_dispatcher_bridge() {
+ return message_dispatcher_bridge_;
+ }
+
private:
base::test::ScopedFeatureList feature_list_;
sync_preferences::TestingPrefServiceSyncable pref_service_;
@@ -195,4 +199,30 @@
CONTENT_SETTING_BLOCK);
}
+// Tests that the message is dismissed when a navigation happens.
+TEST_F(FramebustBlockedMessageDelegateTest, ClearOnNavigation) {
+ EnqueueMessage(GURL("a.test"));
+ EXPECT_NE(nullptr, GetMessageWrapper());
+
+ // This will be called when the navigation happens.
+ EXPECT_CALL(message_dispatcher_bridge(), DismissMessage)
+ .WillOnce(testing::Return());
+
+ // Same-site cross-document navigation.
+ NavigateAndCommit(GURL(kPageUrl).Resolve("/new_path"));
+}
+
+// Tests that the message is dismissed when a reload happens.
+TEST_F(FramebustBlockedMessageDelegateTest, ClearOnReload) {
+ EnqueueMessage(GURL("a.test"));
+ EXPECT_NE(nullptr, GetMessageWrapper());
+
+ // This will be called when the reload happens.
+ EXPECT_CALL(message_dispatcher_bridge(), DismissMessage)
+ .WillOnce(testing::Return());
+
+ // Simulate reload by navigating to the same URL.
+ NavigateAndCommit(GURL(kPageUrl));
+}
+
} // namespace blocked_content
Original Bug Report
Potential cross-origin permission spoofing via location.reload() 302 redirect
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 without the Chrome Security team.
Overview: A logic error in the Android Messages infrastructure allows a ‘Redirect blocked’ message to persist across a cross-origin navigation if triggered by location.reload() and a 302 redirect. Because the Java ScopeChangeController skips message dismissal for reloads, an attacker can overlay their prompt on a victim’s site. If a user interacts with the stale message, persistent POPUPS=ALLOW permissions are granted to the attacker’s origin.
Affected files:
components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.javachrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc
Estimated timestamp from git blame: 2022-09-27
Summary
A potential vulnerability in Chrome for Android’s message dismissal logic allows an attacker to persist a NAVIGATION-scoped message (like the framebust “Redirect blocked” message) across a cross-origin navigation. By exploiting a bypass in the dismissal mechanism during reloads, an attacker can trick a user into granting persistent permissions (specifically POPUPS=ALLOW) to the attacker’s origin while the browser omnibox displays a legitimate victim origin.
Technical Details
The root cause lies in how Android messages handle navigation scopes. In components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java, the NavigationWebContentsScopeObserver monitors navigations to dismiss messages scoped to a single navigation. However, it explicitly skips dismissal if the navigation is flagged as a reload:
// components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
@Override
public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigationHandle) {
// ...
if (navigationHandle.isSameDocument()
|| !navigationHandle.hasCommitted()
|| navigationHandle.isReload()) { // <--- The bypass
return;
}
// ...
destroy();
}
If an attacker triggers the framebust intervention on their site (attacker.example), FramebustBlockedMessageDelegate::ShowMessage is invoked in C++ (chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc). This method captures attacker.example in the url_ member variable and enqueues a NAVIGATION-scoped message.
If the JavaScript on attacker.example then executes location.reload(), Blink initiates a navigation with a RELOAD type. If the attacker’s server responds to this reload request with an HTTP 302 redirect to victim.example, the C++ NavigationRequest updates the destination URL but preserves the RELOAD navigation type.
When this navigation commits, the omnibox updates to victim.example. However, when the Java ScopeChangeController evaluates navigationHandle.isReload(), it returns true. The method returns early, failing to call destroy() and leaving the message visible on the screen over the victim’s site.
Because the message is not dismissed, the C++ delegate is never notified via HandleDismissCallback, meaning url_ is never cleared and retains the value attacker.example. If the user taps “Always allow redirects” on the persistent message (believing it belongs to victim.example), FramebustBlockedMessageDelegate::HandleClick executes:
// chrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cc
void FramebustBlockedMessageDelegate::HandleClick() {
// ...
settings_map_->SetNarrowestContentSetting(
url_, url_, ContentSettingsType::POPUPS, CONTENT_SETTING_ALLOW);
// ...
}
This silently grants the POPUPS permission (which also permits redirects) to attacker.example.
Potential Exploit Scenario
(Note: These are suggested steps; our tooling cannot execute active PoCs)
- A user navigates to
https://attacker.example/a.html. - The page contains a cross-origin iframe that attempts a top-level navigation to
evil.examplewithout a user gesture, triggering the framebust intervention. - Chrome displays the “Redirect blocked” Android message bubble. The delegate records
attacker.exampleinternally. - JavaScript on
a.htmlexecuteslocation.reload(). - The attacker’s server receives the reload request and responds with
HTTP 302 Found, redirecting tohttps://victim.example/(e.g., a trusted banking or social media site). - The browser commits the navigation. The omnibox shows
https://victim.example/. - Due to the
isReload()bypass, the “Redirect blocked” message persists over the victim’s page. - The user, assuming the prompt is legitimate and associated with the trusted site, taps “Always allow redirects”.
- Chrome grants persistent
POPUPSpermission toattacker.example.
Suggested Fix
In ScopeChangeController.java, the check for navigationHandle.isReload() should be strengthened to ensure that the reload did not result in a cross-origin navigation due to a server-side redirect.
For example, the observer could track the last committed origin and only skip dismissal if isReload() is true AND the origin hasn’t changed. Alternatively, the C++ NavigationRequest logic could clear the RELOAD type if a redirect crosses origin boundaries, though this might have broader implications for other browser features relying on navigation types.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.