CVE-2026-11244
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/webauthn/authenticator_request_dialog_controller.cc |
modified | |
GetChallengeUrlFetcherchrome/browser/webauthn/authenticator_request_dialog_controller.cc |
modified | |
GetWeakPtrchrome/browser/webauthn/authenticator_request_dialog_controller.cc |
modified | |
ChallengeUrlFetcherchrome/browser/webauthn/authenticator_request_dialog_controller.h |
modified | |
PasskeyUpgradeRequestControllerchrome/browser/webauthn/authenticator_request_dialog_controller.h |
modified | |
Profilechrome/browser/webauthn/authenticator_request_dialog_controller.h |
modified |
Files Changed
chrome/browser/BUILD.gnchrome/browser/ui/views/webauthn/sheet_view_factory.ccchrome/browser/webauthn/authenticator_request_dialog_controller.ccchrome/browser/webauthn/authenticator_request_dialog_controller.h
Patch
From 24b759e61e43eeb9f539ef32999ac69eea3823bd Mon Sep 17 00:00:00 2001 From: Ken Buchanan <[email protected]> Date: Thu, 09 Apr 2026 12:59:07 -0700 Subject: [PATCH] [WebAuthn] Remove ChallengeURL prototype The prototype is not currently on track to be turned into a shipping feature, as developer demand for it is unclear. There is a small maintenance burden from having it in the code base. This CL removes the implementation. If a decision is made later to commit to developing this feature it can be improved and re-written at that time. Fixed: 497609145 Change-Id: I1fe30d57e5d4f97d11aa6b29baa055afa29ef8ee Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7737001 Auto-Submit: Ken Buchanan <[email protected]> Reviewed-by: Rainhard Findling <[email protected]> Reviewed-by: Nina Satragno <[email protected]> Reviewed-by: Dave Tapuska <[email protected]> Commit-Queue: Ken Buchanan <[email protected]> Reviewed-by: Stephen McGruer <[email protected]> Cr-Commit-Position: refs/heads/main@{#1612451} --- diff --git a/chrome/browser/BUILD.gn b/chrome/browser/BUILD.gn index 0704bd8..7a690fe 100644 --- a/chrome/browser/BUILD.gn +++ b/chrome/browser/BUILD.gn @@ -3524,8 +3524,6 @@ "webauthn/authenticator_request_scheduler.h", "webauthn/cablev2_devices.cc", "webauthn/cablev2_devices.h", - "webauthn/challenge_url_fetcher.cc", - "webauthn/challenge_url_fetcher.h", "webauthn/change_pin_controller.cc", "webauthn/change_pin_controller.h", "webauthn/change_pin_controller_impl.cc", diff --git a/chrome/browser/ui/views/webauthn/sheet_view_factory.cc b/chrome/browser/ui/views/webauthn/sheet_view_factory.cc index 2a4881e..f033aa8 100644 --- a/chrome/browser/ui/views/webauthn/sheet_view_factory.cc +++ b/chrome/browser/ui/views/webauthn/sheet_view_factory.cc @@ -387,11 +387,6 @@ sheet_view = std::make_unique<AuthenticatorRequestSheetView>( std::make_unique<AuthenticatorGPMLockedPinSheetModel>(dialog_model)); break; - case Step::kErrorFetchingChallenge: - sheet_view = std::make_unique<AuthenticatorRequestSheetView>( - std::make_unique<AuthenticatorChallengeFetchErrorModel>( - dialog_model)); - break; case Step::kNotStarted: case Step::kPasskeyAutofill: case Step::kPasskeyUpgrade: diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc index 6a2e164..da26a12f7 100644 --- a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc +++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc @@ -49,7 +49,6 @@ #include "chrome/browser/webauthn/authenticator_reference.h" #include "chrome/browser/webauthn/authenticator_request_dialog_model.h" #include "chrome/browser/webauthn/authenticator_transport.h" -#include "chrome/browser/webauthn/challenge_url_fetcher.h" #include "chrome/browser/webauthn/change_pin_controller_impl.h" #include "chrome/browser/webauthn/credential_sorter_desktop.h" #include "chrome/browser/webauthn/gpm_enclave_transaction.h" @@ -716,7 +715,6 @@ model_->step() == Step::kKeyAlreadyRegistered || model_->step() == Step::kMissingCapability || model_->step() == Step::kErrorWindowsHelloNotEnabled || - model_->step() == Step::kErrorFetchingChallenge || model_->step() == Step::kClosed; } @@ -792,8 +790,6 @@ transport_availability_.platform_has_biometrics); constexpr bool kIsMac = BUILDFLAG(IS_MAC); - MaybeStartChallengeFetch(); - if (pending_step_) { SetCurrentStep(*pending_step_); pending_step_.reset(); @@ -1369,12 +1365,6 @@ return true; } -void AuthenticatorRequestDialogController::OnChallengeUrlFailure() { - if (!is_request_complete()) { - SetCurrentStep(Step::kErrorFetchingChallenge); - } -} - void AuthenticatorRequestDialogController::BluetoothAdapterStatusChanged( BleStatus ble_status) { transport_availability_.ble_status = ble_status; @@ -1473,8 +1463,6 @@ account_preselected_callback_.Run(*cred); model_->preselected_cred = *cred; - MaybeStartChallengeFetch(); - // `source` should not be `kPhone` here. if (source != AuthenticatorType::kEnclave) { HideDialogAndDispatchToPlatformAuthenticator(source); @@ -1709,22 +1697,6 @@ model_->set_ui_presentation(modality); } -void AuthenticatorRequestDialogController::ProvideChallengeUrl( - const GURL& url, - base::OnceCallback<void(std::optional<base::span<const uint8_t>>)> - callback) { - CHECK(url.is_valid()); - challenge_url_ = url; - challenge_callback_ = std::move(callback); - - // Conditional requests don't initiate a challenge fetch unless and until the - // user triggers it, but modal requests always perform the fetch so it can - // be started immediately. - if (IsModalRequest(ui_presentation())) { - MaybeStartChallengeFetch(); - } -} - void AuthenticatorRequestDialogController::InitializeEnclaveRequestCallback( device::FidoDiscoveryFactory* discovery_factory) { CHECK(!enclave_request_callback_); @@ -1736,49 +1708,6 @@ discovery_factory->set_enclave_ui_request_stream(std::move(event_stream)); } -void AuthenticatorRequestDialogController::MaybeStartChallengeFetch() { - if (!challenge_callback_) { - return; - } - - auto challenge_or_error = GetChallengeUrlFetcher()->GetChallenge(); - if (!challenge_or_error.has_value() && - challenge_or_error.error() == - ChallengeUrlFetcher::ChallengeNotAvailableReason::kNotRequested) { - GetChallengeUrlFetcher()->FetchUrl( - challenge_url_, - base::BindOnce( - &AuthenticatorRequestDialogController::OnChallengeFetched, - weak_factory_.GetWeakPtr())); - } -} - -void AuthenticatorRequestDialogController::OnChallengeFetched() { - auto challenge_or_error = GetChallengeUrlFetcher()->GetChallenge(); - - if (challenge_or_error.has_value()) { - std::move(challenge_callback_).Run(challenge_or_error.value()); - return; - } - - CHECK_EQ(challenge_or_error.error(), - ChallengeUrlFetcher::ChallengeNotAvailableReason:: - kErrorFetchingChallenge); - - std::move(challenge_callback_).Run(std::nullopt); -} - -ChallengeUrlFetcher* -AuthenticatorRequestDialogController::GetChallengeUrlFetcher() { - if (!challenge_url_fetcher_) { - challenge_url_fetcher_ = std::make_unique<ChallengeUrlFetcher>( - Profile::FromBrowserContext(GetRenderFrameHost()->GetBrowserContext()) - ->GetDefaultStoragePartition() - ->GetURLLoaderFactoryForBrowserProcess()); - } - return challenge_url_fetcher_.get(); -} - base::WeakPtr<AuthenticatorRequestDialogController> AuthenticatorRequestDialogController::GetWeakPtr() { return weak_factory_.GetWeakPtr(); diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.h b/chrome/browser/webauthn/authenticator_request_dialog_controller.h index 40431bcf..8ffb776 100644 --- a/chrome/browser/webauthn/authenticator_request_dialog_controller.h +++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.h @@ -31,7 +31,6 @@ #include "third_party/blink/public/mojom/credentialmanagement/credential_type_flags.mojom.h" #include "url/gurl.h" -class ChallengeUrlFetcher; class PasskeyUpgradeRequestController; class Profile; @@ -229,9 +228,6 @@ // request should never have been sent to iCloud Keychain in the first place. bool OnNoPasskeys(); - // To be called when fetching a challenge from a provided URL failed. - void OnChallengeUrlFailure(); - // To be called when the Bluetooth adapter status changes. void BluetoothAdapterStatusChanged( device::FidoRequestHandlerBase::BleStatus ble_status);
Regression Test / PoC
diff --git a/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc b/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc
deleted file mode 100644
index f4340488..0000000
--- a/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright 2024 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/webauthn/challenge_url_fetcher.h"
-
-#include <iterator>
-#include <memory>
-#include <utility>
-
-#include "base/memory/scoped_refptr.h"
-#include "base/test/bind.h"
-#include "base/test/task_environment.h"
-#include "base/test/test_future.h"
-#include "net/http/http_response_headers.h"
-#include "services/network/public/cpp/url_loader_completion_status.h"
-#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
-#include "services/network/public/mojom/fetch_api.mojom.h"
-#include "services/network/public/mojom/url_response_head.mojom.h"
-#include "services/network/test/test_url_loader_factory.h"
-#include "testing/gtest/include/gtest/gtest.h"
-#include "url/gurl.h"
-
-namespace {
-constexpr char kUrl[] = "https://example.com/challenge_endpoint";
-constexpr uint8_t kTestChallenge[] = {0, 1, 2, 3, 4, 5, 6, 7,
- 8, 9, 10, 11, 12, 13, 14, 15};
-constexpr uint8_t kSmallChallenge[] = {0, 1, 2, 3, 4, 5, 6, 7,
- 8, 9, 10, 11, 12, 13, 14};
-
-constexpr char kChallengeContentType[] = "application/x-webauthn-challenge";
-} // namespace
-
-class ChallengeUrlFetcherTest : public testing::Test {
- public:
- ChallengeUrlFetcherTest() = default;
-
- void SetUp() override {
- fetcher_ = std::make_unique<ChallengeUrlFetcher>(
- url_loader_factory_.GetSafeWeakWrapper());
- }
-
- ChallengeUrlFetcher* fetcher() { return fetcher_.get(); }
-
- network::TestURLLoaderFactory* url_loader_factory() {
- return &url_loader_factory_;
- }
-
- base::expected<std::vector<uint8_t>,
- ChallengeUrlFetcher::ChallengeNotAvailableReason>
- FetchChallengeAndWait() {
- base::test::TestFuture<void> future;
- fetcher()->FetchUrl(GURL(kUrl), future.GetCallback());
- EXPECT_TRUE(future.Wait());
- return fetcher()->GetChallenge();
- }
-
- private:
- base::test::TaskEnvironment task_env_;
- std::unique_ptr<ChallengeUrlFetcher> fetcher_;
- network::TestURLLoaderFactory url_loader_factory_;
-};
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchSuccess) {
- url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
- [this](const network::ResourceRequest& request) {
- EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
- EXPECT_EQ(request.credentials_mode,
- network::mojom::CredentialsMode::kOmit);
- EXPECT_EQ(fetcher()->GetChallenge().error(),
- ChallengeUrlFetcher::ChallengeNotAvailableReason::
- kWaitingForChallenge);
- auto head = network::mojom::URLResponseHead::New();
- head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
- head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
- kChallengeContentType);
- std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
- url_loader_factory()->AddResponse(
- GURL(kUrl), std::move(head), body,
- network::URLLoaderCompletionStatus(net::Error::OK));
- }));
-
- auto result = FetchChallengeAndWait();
- std::vector<uint8_t> expected(std::begin(kTestChallenge),
- std::end(kTestChallenge));
- ASSERT_TRUE(result.has_value());
- EXPECT_EQ(expected, result.value());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchError) {
- url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
- [this](const network::ResourceRequest& request) {
- EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
- EXPECT_EQ(request.credentials_mode,
- network::mojom::CredentialsMode::kOmit);
- EXPECT_EQ(fetcher()->GetChallenge().error(),
- ChallengeUrlFetcher::ChallengeNotAvailableReason::
- kWaitingForChallenge);
- auto head = network::mojom::URLResponseHead::New();
- head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
- head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
- kChallengeContentType);
- std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
- url_loader_factory()->AddResponse(
- GURL(kUrl), std::move(head), body,
- network::URLLoaderCompletionStatus(net::HTTP_NOT_FOUND));
- }));
-
- auto result = FetchChallengeAndWait();
- ASSERT_FALSE(result.has_value());
- EXPECT_EQ(
- ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
- result.error());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchMissingHeader) {
- url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
- [this](const network::ResourceRequest& request) {
- EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
- EXPECT_EQ(request.credentials_mode,
- network::mojom::CredentialsMode::kOmit);
- EXPECT_EQ(fetcher()->GetChallenge().error(),
- ChallengeUrlFetcher::ChallengeNotAvailableReason::
- kWaitingForChallenge);
- std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
- url_loader_factory()->AddResponse(kUrl, body);
- }));
-
- auto result = FetchChallengeAndWait();
- ASSERT_FALSE(result.has_value());
- EXPECT_EQ(
- ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
- result.error());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeTooSmall) {
- url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
- [this](const network::ResourceRequest& request) {
- EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
- EXPECT_EQ(request.credentials_mode,
- network::mojom::CredentialsMode::kOmit);
- EXPECT_EQ(fetcher()->GetChallenge().error(),
- ChallengeUrlFetcher::ChallengeNotAvailableReason::
- kWaitingForChallenge);
- auto head = network::mojom::URLResponseHead::New();
- head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
- head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
- kChallengeContentType);
- std::string body(std::begin(kSmallChallenge),
- std::end(kSmallChallenge));
- url_loader_factory()->AddResponse(
- GURL(kUrl), std::move(head), body,
- network::URLLoaderCompletionStatus(net::Error::OK));
- }));
-
- auto result = FetchChallengeAndWait();
- ASSERT_FALSE(result.has_value());
- EXPECT_EQ(
- ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
- result.error());
-}
diff --git a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
index dd0b37cd..d6a8925 100644
--- a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
+++ b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
@@ -1157,292 +1157,6 @@
EXPECT_EQ(observer_->accounts_.at(0), "0102030405060708090A0B0C0D0E0F10");
}
-class ChallengeUrlBrowserTest : public WebAuthnBrowserTest {
- public:
- static constexpr char kValidChallenge[] = "1234567890123456";
-
- class DelegateObserver
- : public ChromeAuthenticatorRequestDelegate::TestObserver {
- public:
- explicit DelegateObserver(ChallengeUrlBrowserTest* test_instance)
- : test_instance_(test_instance) {}
- virtual ~DelegateObserver() = default;
-
- void WaitForUI() {
- ui_shown_run_loop_->Run();
- ui_shown_run_loop_ = std::make_unique<base::RunLoop>();
- }
-
- // ChromeAuthenticatorRequestDelegate::TestObserver:
- void Created(ChromeAuthenticatorRequestDelegate* delegate) override {
- test_instance_->UpdateRequestDelegate(delegate);
- }
-
- void OnDestroy(ChromeAuthenticatorRequestDelegate* delegate) override {
- test_instance_->UpdateRequestDelegate(nullptr);
- }
-
- void UIShown(ChromeAuthenticatorRequestDelegate* delegate) override {
- ui_shown_run_loop_->QuitWhenIdle();
- }
-
- private:
- raw_ptr<ChallengeUrlBrowserTest> test_instance_;
- std::unique_ptr<base::RunLoop> ui_shown_run_loop_ =
- std::make_unique<base::RunLoop>();
- };
- class ModelObserver : public AuthenticatorRequestDialogModel::Observer {
- public:
- explicit ModelObserver(AuthenticatorRequestDialogModel* model)
- : model_(model) {
- model_->observers.AddObserver(this);
- }
-
- ~ModelObserver() override {
- if (model_) {
- model_->observers.RemoveObserver(this);
- model_ = nullptr;
- }
- }
-
- // Call this before the state transition you are looking to observe.
- void SetStepToObserve(AuthenticatorRequestDialogModel::Step step) {
- ASSERT_FALSE(run_loop_);
- step_ = step;
- run_loop_ = std::make_unique<base::RunLoop>();
- }
-
- // Call this to observer the next step change, whatever it might be.
- void ObserveNextStep() {
- ASSERT_FALSE(run_loop_);
- run_loop_ = std::make_unique<base::RunLoop>();
- }
-
- // This will return after a transition to the state previously specified by
- // `SetStepToObserve`. Returns immediately if the current step matches.
- void WaitForStep() {
- if (model_->step() == step_) {
- run_loop_.reset();
- return;
- }
- ASSERT_TRUE(run_loop_);
- run_loop_->Run();
- // When waiting for `kClosed` the model is deleted at this point.
- if (step_ != AuthenticatorRequestDialogModel::Step::kClosed) {
- CHECK_EQ(step_, model_->step());
- }
- Reset();
- }
-
- // AuthenticatorRequestDialogModel::Observer:
- void OnStepTransition() override {
- if (run_loop_ && step_ == model_->step()) {
- run_loop_->QuitWhenIdle();
- }
- }
-
- void OnModelDestroyed(AuthenticatorRequestDialogModel* model) override {
- model_ = nullptr;
- }
-
- void Reset() {
- step_ = AuthenticatorRequestDialogModel::Step::kNotStarted;
- run_loop_.reset();
- }
-
- private:
- raw_ptr<AuthenticatorRequestDialogModel> model_;
- AuthenticatorRequestDialogModel::Step step_ =
- AuthenticatorRequestDialogModel::Step::kNotStarted;
- std::unique_ptr<base::RunLoop> run_loop_;
- };
-
- void SetUpOnMainThread() override {
- // Handlers have to be registered before the server is started.
- https_server_.RegisterRequestHandler(
- base::BindRepeating(&ChallengeUrlBrowserTest::HandleChallengeRequest,
- base::Unretained(this)));
- WebAuthnBrowserTest::SetUpOnMainThread();
-
- auto virtual_device_factory =
- std::make_unique<device::test::VirtualFidoDeviceFactory>();
- virtual_device_factory_ = virtual_device_factory.get();
- virtual_device_factory->mutable_state()->InjectResidentKey(
- kCredentialID, "www.example.com", std::vector<uint8_t>{5, 6, 7, 8},
- "flandre", "Flandre Scarlet");
- virtual_device_factory->mutable_state()->fingerprints_enrolled = true;
- device::VirtualCtap2Device::Config config;
- config.resident_key_support = true;
- config.internal_uv_support = true;
- virtual_device_factory->SetCtap2Config(std::move(config));
- auth_env_ =
- std::make_unique<content::ScopedAuthenticatorEnvironmentForTesting>(
- std::move(virtual_device_factory));
-
- ASSERT_TRUE(ui_test_utils::NavigateToURL(
- browser(), https_server_.GetURL("www.example.com", "/title1.html")));
-
... (truncated)
Original Bug Report
SSRF and PNA Bypass in WebAuthn via challenge_url
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can exploit the WebAuthn API to perform Server-Side Request Forgery (SSRF) and bypass Private Network Access (PNA) protections. By supplying a malicious challenge_url via Mojo IPC, the browser process fetches the URL using a highly privileged URLLoaderFactory without enforcing scheme or origin restrictions.
Affected files:
chrome/browser/webauthn/challenge_url_fetcher.cccontent/browser/webauth/authenticator_common_impl.ccchrome/browser/webauthn/authenticator_request_dialog_controller.cc
Estimated timestamp from git blame: 2025-01-10
Description
There is a potential Server-Side Request Forgery (SSRF) vulnerability in the WebAuthn implementation’s handling of the challenge_url parameter. When a WebAuthn GetAssertion request is made, a compromised renderer can bypass Blink-level feature flags and supply an arbitrary challenge_url directly via Mojo IPC.
The browser process receives this request in AuthenticatorCommonImpl, which only checks if the URL is syntactically valid but fails to validate the scheme (e.g., blocking file:// or chrome://) or enforce Private Network Access (PNA) restrictions.
The request is then passed to AuthenticatorRequestDialogController::ProvideChallengeUrl, which instantiates a ChallengeUrlFetcher using Profile::GetDefaultStoragePartition()->GetURLLoaderFactoryForBrowserProcess(). As documented in content/public/browser/storage_partition.h, this factory is highly privileged and disables Opaque Response Blocking (ORB) and request_initiator_origin_lock. Furthermore, ChallengeUrlFetcher::FetchUrl constructs the network::ResourceRequest without setting a request_initiator.
Because the request uses a privileged browser-process factory and lacks an initiator, the Network Service treats it as a trusted internal fetch. This completely bypasses CORS and PNA checks, allowing the attacker to force the browser to issue blind GET requests to local or internal network resources.
Impact
- SSRF and PNA Bypass: A compromised renderer can issue GET requests to
localhost, RFC1918 addresses, or cloud metadata endpoints, bypassing network isolation boundaries. - Timing Oracle: The renderer receives a
NOT_ALLOWED_ERRORupon fetch failure. The time taken to receive this error differs based on network behavior (e.g., fast TCP connection refused vs. slower HTTP 200 OK with incorrect Content-Type), creating a reliable timing oracle for scanning internal ports and services. - Scheme Abuse: Lack of URL scheme filtering may allow the browser process to fetch
file://orchrome://resources, though reading the exact response body requires the target to serve the specific WebAuthn challenge content type.
Potential Attacker Steps
Note: These are suggested steps; our tooling has not executed a live proof-of-concept.
- An attacker compromises a renderer process (e.g., via a V8 exploit) or uses a renderer with MojoJS enabled.
- The attacker crafts a
blink::mojom::Authenticator::GetAssertionMojo message. - In the
PublicKeyCredentialRequestOptions, the attacker omits thechallengeand setschallenge_urlto a sensitive internal endpoint (e.g.,http://127.0.0.1:8080/internal-api). - The compromised renderer sends the Mojo IPC to the browser process.
- The browser process validates the Relying Party ID and immediately triggers the
ChallengeUrlFetcherto perform a GET request against the attacker-supplied internal URL. - The attacker measures the time it takes for the WebAuthn promise to reject to infer the state of the internal endpoint.
Suggested Fix
- Use a Restricted URLLoaderFactory: Do not use
GetURLLoaderFactoryForBrowserProcess()for web-influenced fetches. Instead, use aURLLoaderFactorybound to the origin of the requesting renderer frame, ensuring standard web security models apply. - Set Request Initiator: Explicitly set the
request_initiatoron thenetwork::ResourceRequestinChallengeUrlFetcher::FetchUrlto the origin of the relying party. - Strict URL Validation: In
AuthenticatorCommonImpl, rejectchallenge_urlvalues that use non-HTTP(S) schemes (e.g.,file://,chrome://,data://). - Enforce PNA: Ensure Private Network Access checks are applied to the fetch, preventing public websites from fetching challenges from local/internal network endpoints.
Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939
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.