Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in DeviceBoundSessionCredentials
DescriptionInappropriate implementation in DeviceBoundSessionCredentials
ComponentDeviceBoundSessionCredentials
Bug ClassLogic Error
Tracker511776603
Fix commit159631e99b1d (chromium/src) +82/-8
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-23

Changed Functions

FunctionChangeNotes
for
net/device_bound_sessions/registration_fetcher.cc
modified
TEST_F
net/device_bound_sessions/registration_fetcher_unittest.cc
modified
if
net/device_bound_sessions/registration_fetcher_unittest.cc
modified

Files Changed

  • net/device_bound_sessions/registration_fetcher.cc
  • net/device_bound_sessions/registration_fetcher_unittest.cc
From 159631e99b1d8828210daff47f6afe5a67a0fca5 Mon Sep 17 00:00:00 2001
From: Jan Wilken Dörrie <jdoerrie@chromium.org>
Date: Wed, 20 May 2026 08:51:21 -0700
Subject: [PATCH] [dbsc] Use final URL for registration validation checks

This change updates the registration fetcher to use the final URL after
redirects for registration validation checks.

Bug: 511776603
Change-Id: Ibacb0c22deddd9a1b6104690a70d7dd06a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7852498
Commit-Queue: Jan Wilken Dörrie <jdoerrie@chromium.org>
Reviewed-by: Alex Ilin <alexilin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1633609}
---

diff --git a/net/device_bound_sessions/registration_fetcher.cc b/net/device_bound_sessions/registration_fetcher.cc
index 214e2b2f..61a16ebf 100644
--- a/net/device_bound_sessions/registration_fetcher.cc
+++ b/net/device_bound_sessions/registration_fetcher.cc
@@ -415,6 +415,8 @@
       return SessionError::kSessionProviderWellKnownHasProviderOrigin;
     }
 
+    // TODO(crbug.com/511776603): Evaluate whether to use the final redirect URL
+    // instead of the original URL here in a follow-up.
     std::string target_origin =
         url::Origin::Create(fetcher_endpoint_).Serialize();
     if (!maybe_params->relying_origins.has_value() ||
@@ -470,6 +472,8 @@
       return SessionError::kRelyingPartyWellKnownHasRelyingOrigins;
     }
 
+    // TODO(crbug.com/511776603): Evaluate whether to use the final redirect URL
+    // instead of the original URL here in a follow-up.
     if (!maybe_params->provider_origin.has_value() ||
         url::Origin::Create(provider_url_).Serialize() !=
             *maybe_params->provider_origin) {
@@ -687,11 +691,15 @@
     }
     std::unique_ptr<Session> session = std::move(*session_or_error);
 
+    // Use the final URL after redirects for validation checks to ensure we
+    // validate the origin that actually served the response.
+    GURL final_registration_url = url_fetcher_->request().url();
+
     // Re-process challenge headers now that a session exists so that cached
     // challenges work for the registration case as well.
     auto challenge_params =
         device_bound_sessions::SessionChallengeParam::CreateIfValid(
-            fetcher_endpoint_, headers);
+            final_registration_url, headers);
     for (const SessionChallengeParam& challenge_param : challenge_params) {
       if (challenge_param.session_id() == *session->id()) {
         session->set_cached_challenge(challenge_param.challenge());
@@ -728,7 +736,7 @@
         // Skip all validations if the fetcher endpoint is not a subdomain but
         // rather the top-level site (which matches the origin when including
         // the site).
-        fetcher_endpoint_.GetHost() != session->origin().host()) {
+        final_registration_url.host() != session->origin().host()) {
       GURL::Replacements replacements;
       replacements.SetPathStr("/.well-known/device-bound-sessions");
       replacements.SetHostStr(session->origin().host());
@@ -742,10 +750,10 @@
           isolation_info_.site_for_cookies());
       url_fetcher_->request().set_initiator(original_request_initiator_);
       url_fetcher_->request().set_isolation_info(isolation_info_);
-      url_fetcher_->Start(
-          base::BindOnce(&RegistrationFetcherImpl::
-                             OnSubdomainRegistrationWellKnownRequestComplete,
-                         GetWeakPtr(), std::move(session)));
+      url_fetcher_->Start(base::BindOnce(
+          &RegistrationFetcherImpl::
+              OnSubdomainRegistrationWellKnownRequestComplete,
+          GetWeakPtr(), std::move(final_registration_url), std::move(session)));
       return;
     }
 
@@ -754,13 +762,15 @@
   }
 
   void OnSubdomainRegistrationWellKnownRequestComplete(
+      GURL final_registration_url,
       std::unique_ptr<Session> session) {
     RunCallback(OnSubdomainRegistrationWellKnownRequestCompleteInternal(
-        std::move(session)));
+        std::move(final_registration_url), std::move(session)));
     // `this` may be deleted.
   }
 
   RegistrationResult OnSubdomainRegistrationWellKnownRequestCompleteInternal(
+      GURL final_registration_url,
       std::unique_ptr<Session> session) {
     HttpResponseHeaders* headers = url_fetcher_->request().response_headers();
     const int response_code = headers ? headers->response_code() : 0;
@@ -788,7 +798,7 @@
     if (!maybe_params->registering_origins.has_value() ||
         !std::ranges::contains(
             *maybe_params->registering_origins,
-            url::Origin::Create(fetcher_endpoint_).Serialize())) {
+            url::Origin::Create(final_registration_url).Serialize())) {
       return CreateErrorRegistrationResult(
           SessionError(SessionError::kSubdomainRegistrationUnauthorized));
     }
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index 41f4782..97c8355a 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -15,6 +15,7 @@
 #include "base/json/json_writer.h"
 #include "base/run_loop.h"
 #include "base/strings/string_util.h"
+#include "base/test/bind.h"
 #include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "base/test/scoped_feature_list.h"
@@ -2709,6 +2710,69 @@
   }
 }
 
+TEST_F(RegistrationTest, RegistrationRedirectToSubdomain) {
+  crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+  bool well_known_fetched = false;
+
+  // 1. Redirect request
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/") {
+          return nullptr;
+        }
+        auto response = std::make_unique<test_server::BasicHttpResponse>();
+        response->set_code(HTTP_FOUND);
+        response->AddCustomHeader(
+            "Location", server_.GetURL("subdomain.a.test", "/dbsc").spec());
+        return response;
+      }));
+
+  // 2. Return config
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/dbsc") {
+          return nullptr;
+        }
+        return ReturnResponse(HTTP_OK, kBasicValidJson, request);
+      }));
+
+  // 3. Monitor well-known requests
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/.well-known/device-bound-sessions") {
+          return nullptr;
+        }
+        well_known_fetched = true;
+        return ReturnResponse(HTTP_NOT_FOUND, "", request);
+      }));
+
+  ASSERT_TRUE(server_.Start());
+
+  GURL registration_url = server_.GetURL("a.test", "/");
+  RecordingNetLogObserver net_log_observer;
+  TestRegistrationCallback callback;
+
+  auto param = GetBasicParam(registration_url);
+  std::unique_ptr<RegistrationFetcher> fetcher =
+      RegistrationFetcher::CreateFetcher(
+          param, session_service(), unexportable_key_service(), context_.get(),
+          IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
+          /*net_log_source=*/std::nullopt,
+          /*original_request_initiator=*/std::nullopt,
+          unexportable_keys::BackgroundTaskPriority::kBestEffort);
+  fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+                                    callback.callback());
+  callback.WaitForCall();
+
+  // Verify well-known check is triggered and registration fails.
+  EXPECT_TRUE(well_known_fetched);
+  EXPECT_EQ(callback.outcome().SessionErrorForTesting()->type,
+            SessionError::kSubdomainRegistrationWellKnownUnavailable);
+}
+
 TEST_F(RegistrationTest, FederatedSuccess) {
   crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index 41f4782..97c8355a 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -15,6 +15,7 @@
 #include "base/json/json_writer.h"
 #include "base/run_loop.h"
 #include "base/strings/string_util.h"
+#include "base/test/bind.h"
 #include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "base/test/scoped_feature_list.h"
@@ -2709,6 +2710,69 @@
   }
 }
 
+TEST_F(RegistrationTest, RegistrationRedirectToSubdomain) {
+  crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+  bool well_known_fetched = false;
+
+  // 1. Redirect request
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/") {
+          return nullptr;
+        }
+        auto response = std::make_unique<test_server::BasicHttpResponse>();
+        response->set_code(HTTP_FOUND);
+        response->AddCustomHeader(
+            "Location", server_.GetURL("subdomain.a.test", "/dbsc").spec());
+        return response;
+      }));
+
+  // 2. Return config
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/dbsc") {
+          return nullptr;
+        }
+        return ReturnResponse(HTTP_OK, kBasicValidJson, request);
+      }));
+
+  // 3. Monitor well-known requests
+  server_.RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const test_server::HttpRequest& request)
+          -> std::unique_ptr<test_server::HttpResponse> {
+        if (request.relative_url != "/.well-known/device-bound-sessions") {
+          return nullptr;
+        }
+        well_known_fetched = true;
+        return ReturnResponse(HTTP_NOT_FOUND, "", request);
+      }));
+
+  ASSERT_TRUE(server_.Start());
+
+  GURL registration_url = server_.GetURL("a.test", "/");
+  RecordingNetLogObserver net_log_observer;
+  TestRegistrationCallback callback;
+
+  auto param = GetBasicParam(registration_url);
+  std::unique_ptr<RegistrationFetcher> fetcher =
+      RegistrationFetcher::CreateFetcher(
+          param, session_service(), unexportable_key_service(), context_.get(),
+          IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
+          /*net_log_source=*/std::nullopt,
+          /*original_request_initiator=*/std::nullopt,
+          unexportable_keys::BackgroundTaskPriority::kBestEffort);
+  fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+                                    callback.callback());
+  callback.WaitForCall();
+
+  // Verify well-known check is triggered and registration fails.
+  EXPECT_TRUE(well_known_fetched);
+  EXPECT_EQ(callback.outcome().SessionErrorForTesting()->type,
+            SessionError::kSubdomainRegistrationWellKnownUnavailable);
+}
+
 TEST_F(RegistrationTest, FederatedSuccess) {
   crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
Loading diff…

Original Bug Report

reported by vm...@google.com

DBSC subdomain authorization bypass via open 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Device Bound Session Credentials (DBSC) implementation incorrectly evaluates subdomain authorization when an open redirect is involved during registration. It validates the session scope against the pre-redirect URL but creates the session bound to the post-redirect URL, allowing an attacker to bypass the .well-known registration check and hijack sessions for an entire site.

Affected files:

  • net/device_bound_sessions/registration_fetcher.cc
  • net/device_bound_sessions/url_fetcher.cc
  • net/device_bound_sessions/registration_fetcher_param.cc

Estimated timestamp from git blame: 2026-02-20

Background

Device Bound Session Credentials (DBSC) allows a server to bind a session to a device’s TPM. To prevent a compromised or untrusted subdomain from registering a session that applies to the entire site (e.g., attacker.example.com creating a session for *.example.com), DBSC enforces an authorization check. If a session configuration specifies include_site: true and the registration endpoint is a subdomain, Chrome fetches /.well-known/device-bound-sessions from the apex domain to verify the subdomain is authorized.

Potential Vulnerability

A vulnerability exists in RegistrationFetcherImpl where it suffers from identity confusion between the pre-redirect and post-redirect URLs when handling HTTP redirects during the session registration fetch.

When a DBSC registration request follows an HTTP redirect, net::URLFetcher transparently updates the current URL. However, RegistrationFetcherImpl retains the original, pre-redirect URL in its fetcher_endpoint_ member variable.

When the request completes in RegistrationFetcherImpl::OnRequestComplete (net/device_bound_sessions/registration_fetcher.cc):

  1. It parses the session configuration JSON and resolves the refresh_url using the post-redirect URL (url_fetcher_->request().url()).
  2. The .well-known authorization check bypass logic evaluates fetcher_endpoint_.GetHost() != session->origin().host().
  3. Because fetcher_endpoint_ holds the pre-redirect URL, this check compares the pre-redirect host against the requested session origin. If they match, the .well-known check is completely bypassed.

Potential Exploit Scenario

An attacker can exploit this if they control a subdomain (attacker.example.com) and identify an open redirect on the apex domain (example.com/redirect).

  1. The victim visits the attacker’s subdomain: https://attacker.example.com.
  2. The attacker serves a DBSC registration header pointing to the apex open redirect: Secure-Session-Registration: (ES256);path="https://example.com/redirect?url=https%3A%2F%2Fattacker.example.com%2Fdbsc"
  3. Chrome parses this and verifies that the pre-redirect URL (https://example.com/redirect...) is same-site with the request URL.
  4. RegistrationFetcherImpl is created with fetcher_endpoint_ set to https://example.com/redirect....
  5. The request is initiated and transparently follows the 302 redirect to https://attacker.example.com/dbsc.
  6. The attacker’s endpoint responds with a 200 OK and a DBSC configuration specifying "origin": "https://example.com", "include_site": true, and a relative "refresh_url": "/refresh".
  7. Session::CreateIfValid validates the configuration. It resolves the relative refresh URL against the post-redirect URL, yielding https://attacker.example.com/refresh. It also confirms the scope origin (example.com) is same-site with the post-redirect URL.
  8. The authorization bypass check evaluates fetcher_endpoint_.GetHost() != session->origin().host(). Because fetcher_endpoint_ is example.com and the session origin is example.com, the check evaluates to false, bypassing the .well-known check.
  9. A site-wide DBSC session is installed. Subsequent requests to *.example.com will trigger DBSC, sending fresh TPM-signed JWTs to the attacker’s endpoint (https://attacker.example.com/refresh).

(Note: Our tooling does not yet have the ability to run code to confirm this exploit chain dynamically, but the static analysis strongly supports it.)

Suggested Fix

Ensure that the authorization check in RegistrationFetcherImpl::OnRequestComplete uses the final, post-redirect URL. Specifically, line 731 in registration_fetcher.cc should be updated to compare the session->origin().host() against url_fetcher_->request().url().host() instead of fetcher_endpoint_.GetHost().

Alternatively, consider whether DBSC registration fetches should follow redirects at all. If they should, fetcher_endpoint_ should be updated when a redirect is followed, or DBSC should enforce that redirects do not cross origin boundaries.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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.

View on issue tracker