CVE-2026-11272
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
DummyDelegateios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm |
modified | |
ReadingListDistillerPageTestios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm |
modified | |
TEST_Fios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm |
modified |
Files Changed
ios/chrome/browser/reading_list/model/BUILD.gnios/chrome/browser/reading_list/model/reading_list_distiller_page.hios/chrome/browser/reading_list/model/reading_list_distiller_page.mmios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm
Patch
From 0ca2a611e20033ffe072ab9234b1e21ad7129e8d Mon Sep 17 00:00:00 2001 From: Olivier Robin <[email protected]> Date: Tue, 14 Apr 2026 05:26:38 -0700 Subject: [PATCH] Fix Reading List distillation AMP handling - Fix a bug the recognition of AMP pages - Make the handling of the AMP pages more robust to prevent the loading of not HTTP/HTTPS pages Bug: 501747321 Change-Id: Ic8bdd06f0d6c12f07edcd8f6ff4904c757ff5a8f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7754681 Commit-Queue: Olivier Robin <[email protected]> Reviewed-by: Quentin Pubert <[email protected]> Auto-Submit: Olivier Robin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1614383} --- diff --git a/ios/chrome/browser/reading_list/model/BUILD.gn b/ios/chrome/browser/reading_list/model/BUILD.gn index 00cba5a6..ca2a81d 100644 --- a/ios/chrome/browser/reading_list/model/BUILD.gn +++ b/ios/chrome/browser/reading_list/model/BUILD.gn @@ -124,6 +124,7 @@ "offline_page_tab_helper_unittest.mm", "offline_url_utils_unittest.mm", "reading_list_browser_agent_unittest.mm", + "reading_list_distiller_page_unittest.mm", "reading_list_web_state_observer_unittest.mm", "url_downloader_unittest.mm", ] @@ -133,6 +134,7 @@ ":test_support", "//base", "//base/test:test_support", + "//components/favicon/core/test:test_support", "//components/favicon/ios", "//components/prefs:test_support", "//components/reading_list/core", @@ -148,6 +150,7 @@ "//ios/chrome/test:test_support", "//ios/web", "//ios/web/public", + "//ios/web/public/security", "//ios/web/public/test", "//ios/web/public/test/fakes", "//net", diff --git a/ios/chrome/browser/reading_list/model/reading_list_distiller_page.h b/ios/chrome/browser/reading_list/model/reading_list_distiller_page.h index 8e4ff59..9c30439 100644 --- a/ios/chrome/browser/reading_list/model/reading_list_distiller_page.h +++ b/ios/chrome/browser/reading_list/model/reading_list_distiller_page.h @@ -8,6 +8,7 @@ #include <memory> #include <string> +#import "base/gtest_prod_util.h" #import "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" #include "components/dom_distiller/ios/distiller_page_ios.h" @@ -72,6 +73,10 @@ web::PageLoadCompletionStatus load_completion_status) override; private: + FRIEND_TEST_ALL_PREFIXES(ReadingListDistillerPageTest, IsGoogleCachedAMPPage); + FRIEND_TEST_ALL_PREFIXES(ReadingListDistillerPageTest, + OnHandleGoogleCachedAMPPageResult); + // Returns whether there is the loading has no error and if the distillation // can continue. bool IsLoadingSuccess(web::PageLoadCompletionStatus load_completion_status); diff --git a/ios/chrome/browser/reading_list/model/reading_list_distiller_page.mm b/ios/chrome/browser/reading_list/model/reading_list_distiller_page.mm index 49bdda6..32ab0f1 100644 --- a/ios/chrome/browser/reading_list/model/reading_list_distiller_page.mm +++ b/ios/chrome/browser/reading_list/model/reading_list_distiller_page.mm @@ -242,7 +242,7 @@ if (!google_util::IsGoogleDomainUrl( url, google_util::DISALLOW_SUBDOMAIN, google_util::DISALLOW_NON_STANDARD_PORTS) || - !url.GetPath().compare(0, 4, "amp/")) { + url.GetPath().compare(0, 5, "/amp/") != 0) { return false; } const web::SSLStatus& ssl_status = CurrentWebState() @@ -279,7 +279,7 @@ NSError* error) { if (!error && value->is_string()) { GURL new_gurl(value->GetString()); - if (new_gurl.is_valid()) { + if (new_gurl.is_valid() && new_gurl.SchemeIsHTTPOrHTTPS()) { FetchFavicon(new_gurl); web::NavigationManager::WebLoadParams params(new_gurl); CurrentWebState()->GetNavigationManager()->LoadURLWithParams(params); diff --git a/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm b/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm new file mode 100644 index 0000000..dab0af5 --- /dev/null +++ b/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm @@ -0,0 +1,320 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "ios/chrome/browser/reading_list/model/reading_list_distiller_page.h" + +#import "base/test/task_environment.h" +#import "components/favicon/core/test/mock_favicon_service.h" +#import "components/favicon/ios/web_favicon_driver.h" +#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h" +#import "ios/web/public/navigation/navigation_item.h" +#import "ios/web/public/security/ssl_status.h" +#import "ios/web/public/test/fakes/fake_navigation_manager.h" +#import "ios/web/public/test/fakes/fake_web_state.h" +#import "net/cert/x509_certificate.h" +#import "net/cert/x509_util.h" +#import "testing/gtest/include/gtest/gtest.h" +#import "testing/platform_test.h" +#import "url/gurl.h" + +namespace reading_list { + +namespace { + +class DummyDelegate : public ReadingListDistillerPageDelegate { + public: + void DistilledPageRedirectedToURL(const GURL& original_url, + const GURL& final_url) override {} + void DistilledPageHasMimeType(const GURL& original_url, + const std::string& mime_type) override {} +}; + +scoped_refptr<net::X509Certificate> CreateDummyCert() { + std::vector<uint8_t> cert_der = + net::x509_util::CreateUnusableCert("CN=Error"); + return net::X509Certificate::CreateFromBytes(cert_der); +} + +} // namespace + +class ReadingListDistillerPageTest : public PlatformTest { + protected: + ReadingListDistillerPageTest() + : delegate_(), profile_(TestProfileIOS::Builder().Build()) {} + + base::test::TaskEnvironment task_environment_; + DummyDelegate delegate_; + std::unique_ptr<TestProfileIOS> profile_; +}; + +TEST_F(ReadingListDistillerPageTest, IsGoogleCachedAMPPage) { + ReadingListDistillerPage distiller_page(GURL(), profile_.get(), nullptr, + &delegate_); + + scoped_refptr<net::X509Certificate> cert = CreateDummyCert(); + + // Case 1: Valid Google AMP URL + { + auto fake_web_state = std::make_unique<web::FakeWebState>(); + auto fake_navigation_manager = + std::make_unique<web::FakeNavigationManager>(); + + GURL url("https://www.google.com/amp/s/example.com"); + fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED); + web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0); + fake_navigation_manager->SetLastCommittedItem(item); + + web::SSLStatus& ssl_status = item->GetSSL(); + ssl_status.certificate = cert; + ssl_status.cert_status = 0; + + fake_web_state->SetNavigationManager(std::move(fake_navigation_manager)); + fake_web_state->SetCurrentURL(url); + + distiller_page.AttachWebState(std::move(fake_web_state)); + EXPECT_TRUE(distiller_page.IsGoogleCachedAMPPage()); + distiller_page.DetachWebState(); + } + + // Case 2: Valid Google Non-AMP URL + { + auto fake_web_state = std::make_unique<web::FakeWebState>(); + auto fake_navigation_manager = + std::make_unique<web::FakeNavigationManager>(); + + GURL url("https://www.google.com/search"); + fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED); + web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0); + fake_navigation_manager->SetLastCommittedItem(item); + + web::SSLStatus& ssl_status = item->GetSSL(); + ssl_status.certificate = cert; + ssl_status.cert_status = 0; + + fake_web_state->SetNavigationManager(std::move(fake_navigation_manager)); + fake_web_state->SetCurrentURL(url); + + distiller_page.AttachWebState(std::move(fake_web_state)); + EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage()); + distiller_page.DetachWebState(); + } + + // Case 3: Non-Google URL with /amp/
Regression Test / PoC
diff --git a/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm b/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm
new file mode 100644
index 0000000..dab0af5
--- /dev/null
+++ b/ios/chrome/browser/reading_list/model/reading_list_distiller_page_unittest.mm
@@ -0,0 +1,320 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/reading_list/model/reading_list_distiller_page.h"
+
+#import "base/test/task_environment.h"
+#import "components/favicon/core/test/mock_favicon_service.h"
+#import "components/favicon/ios/web_favicon_driver.h"
+#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
+#import "ios/web/public/navigation/navigation_item.h"
+#import "ios/web/public/security/ssl_status.h"
+#import "ios/web/public/test/fakes/fake_navigation_manager.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "net/cert/x509_certificate.h"
+#import "net/cert/x509_util.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+#import "url/gurl.h"
+
+namespace reading_list {
+
+namespace {
+
+class DummyDelegate : public ReadingListDistillerPageDelegate {
+ public:
+ void DistilledPageRedirectedToURL(const GURL& original_url,
+ const GURL& final_url) override {}
+ void DistilledPageHasMimeType(const GURL& original_url,
+ const std::string& mime_type) override {}
+};
+
+scoped_refptr<net::X509Certificate> CreateDummyCert() {
+ std::vector<uint8_t> cert_der =
+ net::x509_util::CreateUnusableCert("CN=Error");
+ return net::X509Certificate::CreateFromBytes(cert_der);
+}
+
+} // namespace
+
+class ReadingListDistillerPageTest : public PlatformTest {
+ protected:
+ ReadingListDistillerPageTest()
+ : delegate_(), profile_(TestProfileIOS::Builder().Build()) {}
+
+ base::test::TaskEnvironment task_environment_;
+ DummyDelegate delegate_;
+ std::unique_ptr<TestProfileIOS> profile_;
+};
+
+TEST_F(ReadingListDistillerPageTest, IsGoogleCachedAMPPage) {
+ ReadingListDistillerPage distiller_page(GURL(), profile_.get(), nullptr,
+ &delegate_);
+
+ scoped_refptr<net::X509Certificate> cert = CreateDummyCert();
+
+ // Case 1: Valid Google AMP URL
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("https://www.google.com/amp/s/example.com");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = cert;
+ ssl_status.cert_status = 0;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_TRUE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+
+ // Case 2: Valid Google Non-AMP URL
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("https://www.google.com/search");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = cert;
+ ssl_status.cert_status = 0;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+
+ // Case 3: Non-Google URL with /amp/
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("https://example.com/amp/s/example.com");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = cert;
+ ssl_status.cert_status = 0;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+
+ // Case 4: HTTP Google AMP URL
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("http://www.google.com/amp/s/example.com");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = cert;
+ ssl_status.cert_status = 0;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+
+ // Case 5: Google AMP URL with invalid cert (null)
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("https://www.google.com/amp/s/example.com");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = nullptr;
+ ssl_status.cert_status = 0;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+
+ // Case 6: Google AMP URL with cert error
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+
+ GURL url("https://www.google.com/amp/s/example.com");
+ fake_navigation_manager->AddItem(url, ui::PAGE_TRANSITION_TYPED);
+ web::NavigationItem* item = fake_navigation_manager->GetItemAtIndex(0);
+ fake_navigation_manager->SetLastCommittedItem(item);
+
+ web::SSLStatus& ssl_status = item->GetSSL();
+ ssl_status.certificate = cert;
+ ssl_status.cert_status = net::CERT_STATUS_DATE_INVALID;
+
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+ fake_web_state->SetCurrentURL(url);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+ EXPECT_FALSE(distiller_page.IsGoogleCachedAMPPage());
+ distiller_page.DetachWebState();
+ }
+}
+
+TEST_F(ReadingListDistillerPageTest, OnHandleGoogleCachedAMPPageResult) {
+ ReadingListDistillerPage distiller_page(GURL(), profile_.get(), nullptr,
+ &delegate_);
+
+ favicon::MockFaviconService mock_favicon_service;
+
+ // Case 1: Valid HTTPS URL
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ fake_web_state->SetBrowserState(profile_.get());
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+ auto* raw_nav_manager = fake_navigation_manager.get();
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+
+ favicon::WebFaviconDriver::CreateForWebState(fake_web_state.get(),
+ &mock_favicon_service);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+
+ base::Value value("https://example.com");
+ distiller_page.OnHandleGoogleCachedAMPPageResult(&value, nil);
+
+ EXPECT_TRUE(raw_nav_manager->LoadURLWithParamsWasCalled());
+ auto params = raw_nav_manager->GetLastLoadURLWithParams();
+ EXPECT_TRUE(params.has_value());
+ EXPECT_EQ(params->url, GURL("https://example.com"));
+
+ distiller_page.DetachWebState();
+ }
+
+ // Case 2: Valid HTTP URL
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ fake_web_state->SetBrowserState(profile_.get());
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+ auto* raw_nav_manager = fake_navigation_manager.get();
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+
+ favicon::WebFaviconDriver::CreateForWebState(fake_web_state.get(),
+ &mock_favicon_service);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+
+ base::Value value("http://example.com");
+ distiller_page.OnHandleGoogleCachedAMPPageResult(&value, nil);
+
+ EXPECT_TRUE(raw_nav_manager->LoadURLWithParamsWasCalled());
+ auto params = raw_nav_manager->GetLastLoadURLWithParams();
+ EXPECT_TRUE(params.has_value());
+ EXPECT_EQ(params->url, GURL("http://example.com"));
+
+ distiller_page.DetachWebState();
+ }
+
+ // Case 3: Invalid URL (file://)
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ fake_web_state->SetBrowserState(profile_.get());
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+ auto* raw_nav_manager = fake_navigation_manager.get();
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+
+ favicon::WebFaviconDriver::CreateForWebState(fake_web_state.get(),
+ &mock_favicon_service);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+
+ base::Value value("file:///etc/passwd");
+ distiller_page.OnHandleGoogleCachedAMPPageResult(&value, nil);
+
+ EXPECT_FALSE(raw_nav_manager->LoadURLWithParamsWasCalled());
+
+ distiller_page.DetachWebState();
+ }
+
+ // Case 4: Invalid string
+ {
+ auto fake_web_state = std::make_unique<web::FakeWebState>();
+ fake_web_state->SetBrowserState(profile_.get());
+ auto fake_navigation_manager =
+ std::make_unique<web::FakeNavigationManager>();
+ auto* raw_nav_manager = fake_navigation_manager.get();
+ fake_web_state->SetNavigationManager(std::move(fake_navigation_manager));
+
+ favicon::WebFaviconDriver::CreateForWebState(fake_web_state.get(),
+ &mock_favicon_service);
+
+ distiller_page.AttachWebState(std::move(fake_web_state));
+
+ base::Value value("not a url");
+ distiller_page.OnHandleGoogleCachedAMPPageResult(&value, nil);
+
+ EXPECT_FALSE(raw_nav_manager->LoadURLWithParamsWasCalled());
+
+ distiller_page.DetachWebState();
+ }
... (truncated)
Original Bug Report
Potential Privilege Escalation via Reading List Distiller on iOS
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: Logic bugs in the iOS Reading List distiller allow navigation to arbitrary schemes (including chrome://). A broken path check in IsGoogleCachedAMPPage combined with missing scheme validation allows any reflected HTML on google.com to trigger a WebUI navigation, bypassing the hasOpener security guard.
Affected files:
ios/chrome/browser/reading_list/model/reading_list_distiller_page.mmios/web/web_state/ui/crw_web_request_controller.mmios/chrome/browser/reading_list/model/favicon_web_state_dispatcher_impl.mm
Estimated timestamp from git blame: 2025-10-02
Description
A potential privilege escalation vulnerability exists in the iOS Reading List distillation process. Due to three interconnected logic bugs, an attacker who can reflect a specific HTML tag on www.google.com (or another valid Google domain) can force a hidden background WebState to navigate to and ‘distill’ internal chrome:// WebUI pages.
Bug 1: Broken Path Validation in IsGoogleCachedAMPPage
In ios/chrome/browser/reading_list/model/reading_list_distiller_page.mm, the function IsGoogleCachedAMPPage is designed to detect Google-cached AMP pages to apply a workaround. However, the path validation is broken:
if (!google_util::IsGoogleDomainUrl(...) ||
!url.GetPath().compare(0, 4, "amp/")) {
return false;
}
Because GURL::GetPath() always returns a string starting with a leading slash (e.g., /amp/), comparing it against amp/ at index 0 always returns non-zero. The logical NOT (!) operator then converts this to false. Consequently, the if condition fails to trigger the early return, and the function incorrectly returns true for any HTTPS page on a Google domain (like www.google.com), not just AMP pages.
Bug 2: Missing Scheme Validation for Script-Extracted URLs
When IsGoogleCachedAMPPage returns true, the distiller executes kGetIframeURLJavaScript to find a <link rel="amphtml"> tag or an <iframe> and extracts its URL. The result is handled in OnHandleGoogleCachedAMPPageResult:
GURL new_gurl(value->GetString());
if (new_gurl.is_valid()) {
FetchFavicon(new_gurl);
web::NavigationManager::WebLoadParams params(new_gurl);
CurrentWebState()->GetNavigationManager()->LoadURLWithParams(params);
The code relies solely on is_valid() and fails to validate the URL scheme (e.g., ensuring it is http or https). Because LoadURLWithParams treats this as a browser-initiated navigation, it is permitted to target privileged schemes like chrome://.
Bug 3: WebUI Guard Bypass
In ios/web/web_state/ui/crw_web_request_controller.mm, a security check exists to prevent non-user-initiated DOM navigations to WebUI pages:
if (!(item->GetTransitionType() & ui::PAGE_TRANSITION_TYPED ...) &&
self.hasOpener) {
The background WebState used for Reading List distillation is created without an opener. Therefore, self.hasOpener evaluates to false. This completely bypasses the security restriction, allowing the navigation to the chrome:// URL to proceed and instantiate the WebUI.
Potential Exploitation Steps
Note: These are theoretical steps based on source code analysis. A working proof-of-concept has not been executed.
- An attacker identifies a reflection endpoint on
www.google.com(e.g., a legacy API or gadget) that reflects user input into an<iframe>srcattribute or a<link rel="amphtml">hrefwithout strict URL validation. - The attacker constructs a URL to this endpoint, injecting
chrome://version(or another sensitive WebUI URL) as the reflected value. - The attacker tricks a victim into adding this
www.google.comURL to their Chrome iOS Reading List. - The
ReadingListDownloadServiceinitiates distillation in a background WebState without an opener. - The background WebState navigates to the attacker’s
www.google.comURL. IsGoogleCachedAMPPageis called. Due to the broken path check, it returnstrue.kGetIframeURLJavaScriptexecutes, extractschrome://versionfrom the reflected HTML, and returns it to the C++ code.OnHandleGoogleCachedAMPPageResultnavigates the WebState tochrome://version.CRWWebRequestControllerallows the navigation becausehasOpeneris false.- The background WebState loads the
chrome://versionpage, and the distiller extracts its content, saving it to the user’s local Reading List where the attacker may attempt to retrieve it.
Suggested Fixes
- Fix Path Validation: In
IsGoogleCachedAMPPage, correct the path comparison logic to properly account for the leading slash. For example, usebase::StartsWith(url.path_piece(), "/amp/", base::CompareCase::SENSITIVE). - Enforce Scheme Allowlist: In
OnHandleGoogleCachedAMPPageResult, add strict scheme validation to ensure the extracted URL useshttporhttpsbefore callingLoadURLWithParams. - Re-evaluate WebUI Guard: Consider strengthening the
hasOpenercheck inCRWWebRequestControlleror ensuring that background WebStates used for distillation are explicitly prevented from accessing WebUI, regardless of their opener status.
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.