Medium watch CVSS 3.1 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Payments
DescriptionRace condition in Payments
ComponentPayments
Bug ClassRace
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.
Tracker505991181
Fix commit01866da89405 (chromium/src) +80/-1240
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-24 (per vendor advisory)
Fix landed2026-07-24 (public git)
Disclosed2026-08-25
Patch gap32 days — fix public in source before the advisory

Background

`PaymentManager` / `payment_app.mojom`
the renderer-reachable Mojo interface that a service-worker-backed payment handler page uses to manage its payment app. Per the changed-file list, this patch edits payment_app.mojom, payment_manager.cc and payment_manager.h to remove the SetPaymentInstrument endpoint; its callback type PaymentManager::SetPaymentInstrumentCallback disappears from the tests.
`PaymentAppDatabase`
the browser-process (UI thread) store that saves payment app and instrument records as service-worker registration user data, using StoreRegistrationUserData and GetRegistrationUserDataByKeyPrefix.
Multi-hop async chains
the removed WritePaymentInstrument and FetchAndUpdatePaymentAppInfo flows each made several asynchronous hops (icon/manifest fetch → FindReadyRegistrationForScope → user-data read/write), bound with weak_ptr_factory_.GetWeakPtr().
`PaymentAppInfoFetcher::SelfDeleteFetcher`
a heap object allocated with new that owns its own lifetime while it collects window-client frames from GetWindowClientFrameRoutingIds and downloads manifest and icon data.

Root Cause Analysis

This patch does not add a lock, sequence check or state re-validation. It fixes the “race condition in Payments” by deleting the code path entirely: the SetPaymentInstrument Mojo method, its backing PaymentAppDatabase::WritePaymentInstrument chain, the FetchAndUpdatePaymentAppInfo chain, and the PaymentAppInfoFetcher (and, per the removed include, PaymentInstrumentIconFetcher) helpers. The removed code shows why these paths were race-prone. Each flow captured a scope when the request arrived, then after one or two asynchronous fetches re-resolved the registration with FindReadyRegistrationForScope(scope, ...) and only then wrote user data. The registration that was written to could therefore differ from, or outlive, the state at request time. In addition, DidGetPaymentAppInfoToUpdatePaymentAppInfo performed a non-atomic read-modify-write: it parsed the stored StoredPaymentAppProto, merged fetched fields and called StoreRegistrationUserData, so concurrent invocations could interleave. The diff does not show which interleaving the bug report used. The SelfDeleteFetcher lifetime and the PaymentManager side are truncated or absent, so the exact racing objects are (inferred). Removing the renderer-callable entry point and all its async continuations eliminates every such interleaving, and the tests now install apps through the synchronous-from-the-caller SetPaymentAppInfoForRegisteredServiceWorker path instead.
Key insight
A deprecated, renderer-triggerable Mojo method started long asynchronous fetch→lookup→write chains that re-resolved browser-side state by scope at completion time, which opened a race window. The fix removes the endpoint and all of its continuations rather than trying to harden them.

Attack Path

  1. Reach the endpoint a page controlling a payment-handler service worker calls PaymentManager::SetPaymentInstrument over Mojo. The diff confirms this method existed, because the tests called manager->SetPaymentInstrument(...).
  2. Start an async chain the call reaches PaymentAppDatabase::WritePaymentInstrument, which either starts PaymentInstrumentIconFetcher::Start using the scope’s window-client frames or goes straight to FindReadyRegistrationForScope. The related FetchAndUpdatePaymentAppInfo path similarly starts PaymentAppInfoFetcher::Start.
  3. Change state during the window while the fetch is pending, the renderer issues more calls or changes the service worker registration, frames or manager state. Which concrete state change triggered the bug is not shown in the diff (inferred).
  4. Stale completion the continuation (DidFetchedPaymentInstrumentIcon, DidFindRegistrationToWritePaymentInstrument, DidGetPaymentAppInfoToUpdatePaymentAppInfo) runs against whatever registration or state exists at that moment and writes or merges user data. The resulting effect, whether inconsistent payment-app records or a lifetime bug in the fetcher, is (inferred).

Impact Assessment

The vendor rates this a medium-severity race condition in the Payments component of the browser process, reachable from web content that registers a payment-handler service worker. The diff shows only that a racy asynchronous write path was removed. It does not show memory corruption or any specific corrupted state, so any claim of use-after-free or privilege escalation is (inferred). The diff contains nothing about in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
content/browser/payments/payment_app_database.cc
modified
for
content/browser/payments/payment_app_database.cc
modified
ServiceWorkerRegistration
content/browser/payments/payment_app_database.h
modified
PaymentAppDatabase
content/browser/payments/payment_app_database.h
modified
CONTENT_EXPORT
content/browser/payments/payment_app_database.h
modified

Files Changed

  • content/browser/BUILD.gn
  • content/browser/payments/payment_app_content_unittest_base.cc
  • content/browser/payments/payment_app_content_unittest_base.h
  • content/browser/payments/payment_app_database.cc
  • content/browser/payments/payment_app_database.h
  • content/browser/payments/payment_app_provider_impl_unittest.cc
  • content/browser/payments/payment_manager.cc
  • content/browser/payments/payment_manager.h
  • content/browser/payments/payment_manager_unittest.cc
  • third_party/blink/public/mojom/payments/payment_app.mojom

Audit Directions

  • Late re-resolution by scope
    in the remaining PaymentAppDatabase flows (SetPaymentAppInfoForRegisteredServiceWorker, ClearPaymentInstruments, SetPaymentAppUserHint, and the read paths), look for chains that capture a scope or registration_id, cross an asynchronous fetch, and then act on whatever registration exists at completion without re-checking origin or identity.
  • Read-modify-write on registration user data
    any GetRegistrationUserData* → mutate proto → StoreRegistrationUserData sequence without serialization can lose updates or combine state from different requests. Check the other payment and background-* databases (background_fetch, content_index, periodic_background_sync) for the same pattern.
  • Self-deleting fetchers bound to frames
    other SelfDeleteFetcher-style helpers that capture GetWindowClientFrameRoutingIds and outlive the frames or the PaymentManager that started them should be checked for lifetime races (frame deletion, profile shutdown, service worker unregistration during the fetch).
diff --git a/content/browser/payments/payment_app_database.cc b/content/browser/payments/payment_app_database.cc
index f6104b74..db0921d9 100644
--- a/content/browser/payments/payment_app_database.cc
+++ b/content/browser/payments/payment_app_database.cc
@@ -219,170 +219,6 @@
           weak_ptr_factory_.GetWeakPtr(), instrument_key, std::move(callback)));
 }
 
-void PaymentAppDatabase::WritePaymentInstrument(
-    const GURL& scope,
-    const std::string& instrument_key,
-    PaymentInstrumentPtr instrument,
-    WritePaymentInstrumentCallback callback) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  // TODO(crbug.com/40177656): Update this when PaymentManager
-  // implements StorageKey.
-  if (instrument->icons.size() > 0) {
-    std::vector<blink::Manifest::ImageResource> icons(instrument->icons);
-    PaymentInstrumentIconFetcher::Start(
-        scope,
-        service_worker_context_->GetWindowClientFrameRoutingIds(
-            blink::StorageKey::CreateFirstParty(url::Origin::Create(scope))),
-        icons,
-        base::BindOnce(&PaymentAppDatabase::DidFetchedPaymentInstrumentIcon,
-                       weak_ptr_factory_.GetWeakPtr(), scope, instrument_key,
-                       std::move(instrument), std::move(callback)));
-  } else {
-    service_worker_context_->FindReadyRegistrationForScope(
-        scope, blink::StorageKey::CreateFirstParty(url::Origin::Create(scope)),
-        base::BindOnce(
-            &PaymentAppDatabase::DidFindRegistrationToWritePaymentInstrument,
-            weak_ptr_factory_.GetWeakPtr(), instrument_key,
-            std::move(instrument), std::string(), std::move(callback)));
-  }
-}
-
-void PaymentAppDatabase::DidFetchedPaymentInstrumentIcon(
-    const GURL& scope,
-    const std::string& instrument_key,
-    payments::mojom::PaymentInstrumentPtr instrument,
-    WritePaymentInstrumentCallback callback,
-    const std::string& icon) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  if (icon.empty()) {
-    std::move(callback).Run(PaymentHandlerStatus::FETCH_INSTRUMENT_ICON_FAILED);
-    return;
-  }
-
-  // TODO(crbug.com/40177656): Update this when PaymentManager
-  // implements StorageKey.
-  service_worker_context_->FindReadyRegistrationForScope(
-      scope, blink::StorageKey::CreateFirstParty(url::Origin::Create(scope)),
-      base::BindOnce(
-          &PaymentAppDatabase::DidFindRegistrationToWritePaymentInstrument,
-          weak_ptr_factory_.GetWeakPtr(), instrument_key, std::move(instrument),
-          icon, std::move(callback)));
-}
-
-void PaymentAppDatabase::FetchAndUpdatePaymentAppInfo(
-    const GURL& context,
-    const GURL& scope,
-    FetchAndUpdatePaymentAppInfoCallback callback) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  PaymentAppInfoFetcher::Start(
-      context, service_worker_context_,
-      base::BindOnce(&PaymentAppDatabase::FetchPaymentAppInfoCallback,
-                     weak_ptr_factory_.GetWeakPtr(), scope,
-                     std::move(callback)));
-}
-
-void PaymentAppDatabase::FetchPaymentAppInfoCallback(
-    const GURL& scope,
-    FetchAndUpdatePaymentAppInfoCallback callback,
-    std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  // TODO(crbug.com/40177656): Update this when PaymentManager
-  // implements StorageKey.
-  service_worker_context_->FindReadyRegistrationForScope(
-      scope, blink::StorageKey::CreateFirstParty(url::Origin::Create(scope)),
-      base::BindOnce(
-          &PaymentAppDatabase::DidFindRegistrationToUpdatePaymentAppInfo,
-          weak_ptr_factory_.GetWeakPtr(), std::move(callback),
-          std::move(app_info)));
-}
-
-void PaymentAppDatabase::DidFindRegistrationToUpdatePaymentAppInfo(
-    FetchAndUpdatePaymentAppInfoCallback callback,
-    std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info,
-    blink::ServiceWorkerStatusCode status,
-    scoped_refptr<ServiceWorkerRegistration> registration) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  if (status != blink::ServiceWorkerStatusCode::kOk) {
-    std::move(callback).Run(PaymentHandlerStatus::NO_ACTIVE_WORKER);
-    return;
-  }
-
-  service_worker_context_->GetRegistrationUserDataByKeyPrefix(
-      registration->id(), CreatePaymentAppKey(registration->scope().spec()),
-      base::BindOnce(
-          &PaymentAppDatabase::DidGetPaymentAppInfoToUpdatePaymentAppInfo,
-          weak_ptr_factory_.GetWeakPtr(), std::move(callback),
-          std::move(app_info), registration));
-}
-
-void PaymentAppDatabase::DidGetPaymentAppInfoToUpdatePaymentAppInfo(
-    FetchAndUpdatePaymentAppInfoCallback callback,
-    std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info,
-    scoped_refptr<ServiceWorkerRegistration> registration,
-    const std::vector<std::string>& data,
-    blink::ServiceWorkerStatusCode status) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  if (status != blink::ServiceWorkerStatusCode::kOk) {
-    std::move(callback).Run(PaymentHandlerStatus::NO_ACTIVE_WORKER);
-    return;
-  }
-
-  DCHECK_LE(data.size(), 1U);
-  StoredPaymentAppProto payment_app_proto;
-  if (data.size() == 1U) {
-    payment_app_proto.ParseFromString(data[0]);
-  }
-  payment_app_proto.set_registration_id(registration->id());
-  payment_app_proto.set_scope(registration->scope().spec());
-  // Do not override name and icon if they are invalid.
-  if (!app_info->name.empty()) {
-    payment_app_proto.set_name(app_info->name);
-  }
-  if (!app_info->icon.empty()) {
-    payment_app_proto.set_icon(app_info->icon);
-  }
-  payment_app_proto.set_prefer_related_applications(
-      app_info->prefer_related_applications);
-  for (const auto& related_app : app_info->related_applications) {
-    StoredRelatedApplicationProto* related_app_proto =
-        payment_app_proto.add_related_applications();
-    related_app_proto->set_platform(related_app.platform);
-    related_app_proto->set_id(related_app.id);
-  }
-
-  std::string serialized_payment_app;
-  bool success = payment_app_proto.SerializeToString(&serialized_payment_app);
-  DCHECK(success);
-
-  service_worker_context_->StoreRegistrationUserData(
-      registration->id(), registration->key(),
-      {{CreatePaymentAppKey(registration->scope().spec()),
-        serialized_payment_app}},
-      base::BindOnce(&PaymentAppDatabase::DidUpdatePaymentApp,
-                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
-                     app_info->name.empty() || app_info->icon.empty()));
-}
-
-void PaymentAppDatabase::DidUpdatePaymentApp(
-    FetchAndUpdatePaymentAppInfoCallback callback,
-    bool fetch_app_info_failed,
-    blink::ServiceWorkerStatusCode status) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  PaymentHandlerStatus handler_status =
-      fetch_app_info_failed
-          ? PaymentHandlerStatus::FETCH_PAYMENT_APP_INFO_FAILED
-          : PaymentHandlerStatus::SUCCESS;
-  handler_status = status == blink::ServiceWorkerStatusCode::kOk
-                       ? handler_status
-                       : PaymentHandlerStatus::STORAGE_OPERATION_FAILED;
-  return std::move(callback).Run(handler_status);
-}
-
 void PaymentAppDatabase::ClearPaymentInstruments(
     const GURL& scope,
     ClearPaymentInstrumentsCallback callback) {
@@ -893,68 +729,6 @@
   std::move(callback).Run(PaymentHandlerStatus::SUCCESS);
 }
 
-void PaymentAppDatabase::DidFindRegistrationToWritePaymentInstrument(
-    const std::string& instrument_key,
-    PaymentInstrumentPtr instrument,
-    const std::string& decoded_instrument_icon,
-    WritePaymentInstrumentCallback callback,
-    blink::ServiceWorkerStatusCode status,
-    scoped_refptr<ServiceWorkerRegistration> registration) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  if (status != blink::ServiceWorkerStatusCode::kOk) {
-    std::move(callback).Run(PaymentHandlerStatus::NO_ACTIVE_WORKER);
-    return;
-  }
-
-  StoredPaymentInstrumentProto instrument_proto;
-  instrument_proto.set_registration_id(registration->id());
-  instrument_proto.set_decoded_instrument_icon(decoded_instrument_icon);
-  instrument_proto.set_instrument_key(instrument_key);
-  instrument_proto.set_name(instrument->name);
-  instrument_proto.set_method(instrument->method);
-  for (const auto& icon : instrument->icons) {
-    StoredPaymentInstrumentImageObject* image_object_proto =
-        instrument_proto.add_icons();
-    image_object_proto->set_src(icon.src.spec());
-    image_object_proto->set_type(base::UTF16ToUTF8(icon.type));
-    for (const auto& size : icon.sizes) {
-      ImageSizeProto* size_proto = image_object_proto->add_sizes();
-      size_proto->set_width(size.width());
-      size_proto->set_height(size.height());
-    }
-  }
-
-  std::string serialized_instrument;
-  bool success = instrument_proto.SerializeToString(&serialized_instrument);
-  DCHECK(success);
-
-  StoredPaymentInstrumentKeyInfoProto key_info_proto;
-  key_info_proto.set_key(instrument_key);
-  key_info_proto.set_insertion_order(base::Time::Now().ToInternalValue());
-
-  std::string serialized_key_info;
-  success = key_info_proto.SerializeToString(&serialized_key_info);
-  DCHECK(success);
-
-  service_worker_context_->StoreRegistrationUserData(
-      registration->id(), registration->key(),
-      {{CreatePaymentInstrumentKey(instrument_key), serialized_instrument},
-       {CreatePaymentInstrumentKeyInfoKey(instrument_key),
-        serialized_key_info}},
-      base::BindOnce(&PaymentAppDatabase::DidWritePaymentInstrument,
-                     weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
-}
-
-void PaymentAppDatabase::DidWritePaymentInstrument(
-    WritePaymentInstrumentCallback callback,
-    blink::ServiceWorkerStatusCode status) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  return std::move(callback).Run(
-      status == blink::ServiceWorkerStatusCode::kOk
-          ? PaymentHandlerStatus::SUCCESS
-          : PaymentHandlerStatus::STORAGE_OPERATION_FAILED);
-}
-
 void PaymentAppDatabase::DidFindRegistrationToClearPaymentInstruments(
     const GURL& scope,
     ClearPaymentInstrumentsCallback callback,
diff --git a/content/browser/payments/payment_app_database.h b/content/browser/payments/payment_app_database.h
index d176f56..3989166 100644
--- a/content/browser/payments/payment_app_database.h
+++ b/content/browser/payments/payment_app_database.h
@@ -10,10 +10,9 @@
 #include <vector>
 
 #include "base/memory/weak_ptr.h"
-#include "content/browser/payments/payment_app_info_fetcher.h"
-#include "content/browser/payments/payment_instrument_icon_fetcher.h"
 #include "content/browser/service_worker/service_worker_context_wrapper.h"
 #include "content/browser/service_worker/service_worker_registration.h"
+#include "content/common/content_export.h"
 #include "content/public/browser/stored_payment_app.h"
 #include "third_party/blink/public/common/service_worker/service_worker_status_code.h"
 #include "third_party/blink/public/mojom/payments/payment_app.mojom.h"
@@ -22,7 +21,7 @@
 
 class ServiceWorkerRegistration;
 
-class PaymentAppDatabase {
+class CONTENT_EXPORT PaymentAppDatabase {
  public:
   using PaymentApps = std::map<int64_t, std::unique_ptr<StoredPaymentApp>>;
   using ReadAllPaymentAppsCallback = base::OnceCallback<void(PaymentApps)>;
@@ -37,10 +36,6 @@
                               payments::mojom::PaymentHandlerStatus)>;
   using HasPaymentInstrumentCallback =
       base::OnceCallback<void(payments::mojom::PaymentHandlerStatus)>;
-  using WritePaymentInstrumentCallback =
-      base::OnceCallback<void(payments::mojom::PaymentHandlerStatus)>;
-  using FetchAndUpdatePaymentAppInfoCallback =
-      base::OnceCallback<void(payments::mojom::PaymentHandlerStatus)>;
   using ClearPaymentInstrumentsCallback =
       base::OnceCallback<void(payments::mojom::PaymentHandlerStatus)>;
   using SetPaymentAppInfoCallback =
@@ -69,14 +64,6 @@
   void HasPaymentInstrument(const GURL& scope,
                             const std::string& instrument_key,
                             HasPaymentInstrumentCallback callback);
-  void WritePaymentInstrument(const GURL& scope,
-                              const std::string& instrument_key,
-                              payments::mojom::PaymentInstrumentPtr instrument,
-                              WritePaymentInstrumentCallback callback);
-  void FetchAndUpdatePaymentAppInfo(
-      const GURL& context,
-      const GURL& scope,
-      FetchAndUpdatePaymentAppInfoCallback callback);
   void ClearPaymentInstruments(const GURL& scope,
                                ClearPaymentInstrumentsCallback callback);
   void SetPaymentAppUserHint(const GURL& scope, const std::string& user_hint);
@@ -148,45 +135,6 @@
                                const std::vector<std::string>& data,
                                blink::ServiceWorkerStatusCode status);
 
-  // WritePaymentInstrument callbacks
-  void DidFindRegistrationToWritePaymentInstrument(
-      const std::string& instrument_key,
-      payments::mojom::PaymentInstrumentPtr instrument,
-      const std::string& decoded_instrument_icon,
-      WritePaymentInstrumentCallback callback,
-      blink::ServiceWorkerStatusCode status,
-      scoped_refptr<ServiceWorkerRegistration> registration);
-  void DidWritePaymentInstrument(WritePaymentInstrumentCallback callback,
-                                 blink::ServiceWorkerStatusCode status);
-
-  // FetchAndUpdatePaymentAppInfo callbacks.
-  void FetchPaymentAppInfoCallback(
-      const GURL& scope,
-      FetchAndUpdatePaymentAppInfoCallback callback,
-      std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info);
-  void DidFindRegistrationToUpdatePaymentAppInfo(
-      FetchAndUpdatePaymentAppInfoCallback callback,
-      std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info,
-      blink::ServiceWorkerStatusCode status,
-      scoped_refptr<ServiceWorkerRegistration> registration);
-  void DidGetPaymentAppInfoToUpdatePaymentAppInfo(
-      FetchAndUpdatePaymentAppInfoCallback callback,
-      std::unique_ptr<PaymentAppInfoFetcher::PaymentAppInfo> app_info,
-      scoped_refptr<ServiceWorkerRegistration> registration,
-      const std::vector<std::string>& data,
-      blink::ServiceWorkerStatusCode status);
-  void DidUpdatePaymentApp(FetchAndUpdatePaymentAppInfoCallback callback,
-                           bool fetch_app_info_failed,
-                           blink::ServiceWorkerStatusCode status);
-
-  // PaymentInstrumentIconFetcherCallback.
-  void DidFetchedPaymentInstrumentIcon(
-      const GURL& scope,
-      const std::string& instrument_key,
-      payments::mojom::PaymentInstrumentPtr instrument,
-      WritePaymentInstrumentCallback callback,
-      const std::string& icon);
-
   // ClearPaymentInstruments callbacks
   void DidFindRegistrationToClearPaymentInstruments(
       const GURL& scope,
diff --git a/content/browser/payments/payment_app_info_fetcher.cc b/content/browser/payments/payment_app_info_fetcher.cc
deleted file mode 100644
index e9136dbb..0000000
--- a/content/browser/payments/payment_app_info_fetcher.cc
+++ /dev/null
@@ -1,316 +0,0 @@
-// Copyright 2017 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "content/browser/payments/payment_app_info_fetcher.h"
-
-#include <limits>
-#include <string_view>
-#include <utility>
-
-#include "base/base64.h"
-#include "base/functional/bind.h"
-#include "base/functional/callback_helpers.h"
-#include "base/task/sequenced_task_runner.h"
-#include "components/payments/content/icon/icon_size.h"
-#include "content/browser/renderer_host/render_frame_host_impl.h"
-#include "content/browser/service_worker/service_worker_context_wrapper.h"
-#include "content/browser/web_contents/web_contents_impl.h"
-#include "content/public/browser/browser_task_traits.h"
-#include "content/public/browser/browser_thread.h"
-#include "content/public/browser/global_routing_id.h"
-#include "content/public/browser/manifest_icon_downloader.h"
-#include "content/public/browser/page.h"
-#include "third_party/blink/public/common/manifest/manifest_icon_selector.h"
-#include "third_party/blink/public/common/manifest/manifest_util.h"
-#include "third_party/blink/public/common/storage_key/storage_key.h"
-#include "third_party/blink/public/mojom/devtools/console_message.mojom.h"
-#include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
-#include "ui/gfx/codec/png_codec.h"
-#include "url/origin.h"
-
-namespace content {
-
-PaymentAppInfoFetcher::PaymentAppInfo::PaymentAppInfo() {}
-
-PaymentAppInfoFetcher::PaymentAppInfo::~PaymentAppInfo() {}
-
-void PaymentAppInfoFetcher::Start(
-    const GURL& context_url,
-    scoped_refptr<ServiceWorkerContextWrapper> service_worker_context,
-    PaymentAppInfoFetchCallback callback) {
-  DCHECK_CURRENTLY_ON(BrowserThread::UI);
-
-  std::unique_ptr<std::vector<GlobalRenderFrameHostId>> frame_routing_ids =
-      service_worker_context->GetWindowClientFrameRoutingIds(
-          blink::StorageKey::CreateFirstParty(
-              url::Origin::Create(context_url)));
-
-  SelfDeleteFetcher* fetcher = new SelfDeleteFetcher(std::move(callback));
-  fetcher->Start(context_url, std::move(frame_routing_ids));
-}
-
-PaymentAppInfoFetcher::SelfDeleteFetcher::SelfDeleteFetcher(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/payments/payment_app_content_unittest_base.cc b/content/browser/payments/payment_app_content_unittest_base.cc
index ea6497a7..51cb04c 100644
--- a/content/browser/payments/payment_app_content_unittest_base.cc
+++ b/content/browser/payments/payment_app_content_unittest_base.cc
@@ -62,9 +62,7 @@
     : public EmbeddedWorkerTestHelper {
  public:
   PaymentAppForWorkerTestHelper()
-      : EmbeddedWorkerTestHelper(base::FilePath()),
-        last_sw_registration_id_(
-            blink::mojom::kInvalidServiceWorkerRegistrationId) {}
+      : EmbeddedWorkerTestHelper(base::FilePath()) {}
 
   PaymentAppForWorkerTestHelper(const PaymentAppForWorkerTestHelper&) = delete;
   PaymentAppForWorkerTestHelper& operator=(
@@ -72,6 +70,12 @@
 
   ~PaymentAppForWorkerTestHelper() override {}
 
+  void set_last_sw_registration_id(int64_t id) {
+    last_sw_registration_id_ = id;
+  }
+
+  void set_last_sw_scope(const GURL& scope) { last_sw_scope_ = scope; }
+
   class EmbeddedWorkerInstanceClient : public FakeEmbeddedWorkerInstanceClient {
    public:
     explicit EmbeddedWorkerInstanceClient(
@@ -164,7 +168,10 @@
     return std::make_unique<ServiceWorker>(this);
   }
 
-  int64_t last_sw_registration_id_;
+  // The registration ID and scope of the most recent service worker to be
+  // installed or started.
+  int64_t last_sw_registration_id_ =
+      blink::mojom::kInvalidServiceWorkerRegistrationId;
   GURL last_sw_scope_;
 
   // Variables to delay payment request response.
@@ -195,13 +202,12 @@
   return worker_helper_->browser_context();
 }
 
-PaymentManager*
-PaymentAppContentUnitTestBase::CreateUninitializedPaymentManager(
+int64_t PaymentAppContentUnitTestBase::RegisterAndActivateServiceWorker(
     const GURL& scope_url,
     const GURL& sw_script_url) {
   // Register service worker for payment manager.
   bool called = false;
-  int64_t registration_id;
+  int64_t registration_id = blink::mojom::kInvalidServiceWorkerRegistrationId;
   blink::mojom::ServiceWorkerRegistrationOptions registration_opt;
   registration_opt.scope = scope_url;
   const blink::StorageKey key =
@@ -219,6 +225,8 @@
 
   base::RunLoop().RunUntilIdle();
   EXPECT_TRUE(called);
+  worker_helper_->set_last_sw_registration_id(registration_id);
+  worker_helper_->set_last_sw_scope(scope_url);
 
   // Ensure the worker used for installation has stopped.
   called = false;
@@ -233,6 +241,15 @@
   base::RunLoop().RunUntilIdle();
   EXPECT_TRUE(called);
 
+  return registration_id;
+}
+
+PaymentManager*
+PaymentAppContentUnitTestBase::CreateUninitializedPaymentManager(
+    const GURL& scope_url,
+    const GURL& sw_script_url) {
+  RegisterAndActivateServiceWorker(scope_url, sw_script_url);
+
   // This function should eventually return created payment manager
   // but there is no way to get last created payment manager from
   // payment_app_context()->payment_managers_ because its type is std::map
diff --git a/content/browser/payments/payment_app_content_unittest_base.h b/content/browser/payments/payment_app_content_unittest_base.h
index 9b831a6a..79117b97 100644
--- a/content/browser/payments/payment_app_content_unittest_base.h
+++ b/content/browser/payments/payment_app_content_unittest_base.h
@@ -36,6 +36,8 @@
   ~PaymentAppContentUnitTestBase() override;
 
   BrowserContext* browser_context();
+  int64_t RegisterAndActivateServiceWorker(const GURL& scope_url,
+                                           const GURL& sw_script_url);
   PaymentManager* CreateUninitializedPaymentManager(const GURL& scope_url,
                                                     const GURL& sw_script_url);
   PaymentManager* CreatePaymentManager(const GURL& scope_url,
@@ -51,11 +53,12 @@
   void RespondPendingPaymentRequest(
       payments::mojom::PaymentHandlerResponsePtr response);
 
+  PaymentAppContextImpl* payment_app_context();
+
  private:
   class PaymentAppForWorkerTestHelper;
 
   StoragePartitionImpl* storage_partition();
-  PaymentAppContextImpl* payment_app_context();
 
   std::unique_ptr<BrowserTaskEnvironment> task_environment_;
   std::unique_ptr<PaymentAppForWorkerTestHelper> worker_helper_;
diff --git a/content/browser/payments/payment_app_provider_impl_unittest.cc b/content/browser/payments/payment_app_provider_impl_unittest.cc
index 02c3bbb..e6b7b5bb 100644
--- a/content/browser/payments/payment_app_provider_impl_unittest.cc
+++ b/content/browser/payments/payment_app_provider_impl_unittest.cc
@@ -14,6 +14,7 @@
 #include "base/test/run_until.h"
 #include "content/browser/payments/installed_payment_apps_finder_impl.h"
 #include "content/browser/payments/payment_app_content_unittest_base.h"
+#include "content/browser/payments/payment_app_installer.h"
 #include "content/browser/payments/payment_handler_web_contents_observer.h"
 #include "content/public/browser/payment_app_provider.h"
 #include "content/public/browser/web_contents.h"
@@ -42,11 +43,6 @@
 using ::payments::mojom::PaymentInstrument;
 using ::payments::mojom::PaymentInstrumentPtr;
 
-void SetPaymentInstrumentCallback(PaymentHandlerStatus* out_status,
-                                  PaymentHandlerStatus status) {
-  *out_status = status;
-}
-
 void GetAllPaymentAppsCallback(
     InstalledPaymentAppsFinder::PaymentApps* out_apps,
     InstalledPaymentAppsFinder::PaymentApps apps) {
@@ -108,15 +104,25 @@
 
   ~PaymentAppProviderTest() override {}
 
-  void SetPaymentInstrument(
-      PaymentManager* manager,
-      const std::string& instrument_key,
-      PaymentInstrumentPtr instrument,
-      PaymentManager::SetPaymentInstrumentCallback callback) {
-    ASSERT_NE(nullptr, manager);
-    manager->SetPaymentInstrument(instrument_key, std::move(instrument),
-                                  std::move(callback));
-    base::RunLoop().RunUntilIdle();
+  void InstallPaymentApp(const GURL& scope,
+                         const GURL& sw_url,
+                         const std::string& method = "fake-method") {
+    int64_t registration_id = RegisterAndActivateServiceWorker(scope, sw_url);
+    base::RunLoop run_loop;
+    payment_app_context()
+        ->payment_app_database()
+        ->SetPaymentAppInfoForRegisteredServiceWorker(
+            registration_id, scope.spec(), "Test App", /*icon=*/"", method,
+            SupportedDelegations(),
+            base::BindOnce(
+                [](base::OnceClosure quit,
+                   payments::mojom::PaymentHandlerStatus status) {
+                  EXPECT_EQ(payments::mojom::PaymentHandlerStatus::SUCCESS,
+                            status);
+                  std::move(quit).Run();
+                },
+                run_loop.QuitClosure()));
+    run_loop.Run();
   }
 
   void GetAllPaymentApps(
@@ -195,13 +201,8 @@
 };
 
 TEST_F(PaymentAppProviderTest, AbortPaymentTest) {
-  PaymentManager* manager = CreatePaymentManager(
-      GURL("https://example.test"), GURL("https://example.test/script.js"));
-
-  PaymentHandlerStatus status;
-  SetPaymentInstrument(manager, "payment_instrument_key",
-                       payments::mojom::PaymentInstrument::New(),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
+  InstallPaymentApp(GURL("https://example.test"),
+                    GURL("https://example.test/script.js"));
 
   InstalledPaymentAppsFinder::PaymentApps apps;
   GetAllPaymentApps(base::BindOnce(&GetAllPaymentAppsCallback, &apps));
@@ -209,7 +210,8 @@
 
   bool payment_aborted = false;
   base::RunLoop loop;
-  AbortPayment(last_sw_registration_id(), url::Origin::Create(apps[0]->scope),
+  AbortPayment(last_sw_registration_id(),
+               url::Origin::Create(apps[last_sw_registration_id()]->scope),
                "id",
                base::BindOnce(&CaptureAbortResult, loop.QuitClosure(),
                               &payment_aborted));
@@ -218,13 +220,8 @@
 }
 
 TEST_F(PaymentAppProviderTest, CanMakePaymentTest) {
-  PaymentManager* manager = CreatePaymentManager(
-      GURL("https://example.test"), GURL("https://example.test/script.js"));
-
-  PaymentHandlerStatus status;
-  SetPaymentInstrument(manager, "payment_instrument_key",
-                       payments::mojom::PaymentInstrument::New(),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
+  InstallPaymentApp(GURL("https://example.test"),
+                    GURL("https://example.test/script.js"));
 
   InstalledPaymentAppsFinder::PaymentApps apps;
   GetAllPaymentApps(base::BindOnce(&GetAllPaymentAppsCallback, &apps));
@@ -249,28 +246,16 @@
 }
 
 TEST_F(PaymentAppProviderTest, InvokePaymentAppTest) {
-  PaymentManager* manager1 =
-      CreatePaymentManager(GURL("https://hellopay.test/a/"),
-                           GURL("https://hellopay.test/a/script.js"));
-  PaymentManager* manager2 = CreatePaymentManager(
-      GURL("https://bobpay.test/b/"), GURL("https://bobpay.test/b/script.js"));
-
-  PaymentHandlerStatus status;
-  SetPaymentInstrument(manager1, "test_key1",
-                       payments::mojom::PaymentInstrument::New(),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
-  SetPaymentInstrument(manager2, "test_key2",
-                       payments::mojom::PaymentInstrument::New(),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
-  SetPaymentInstrument(manager2, "test_key3",
-                       payments::mojom::PaymentInstrument::New(),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
+  InstallPaymentApp(GURL("https://hellopay.test/a/"),
+                    GURL("https://hellopay.test/a/script.js"));
+  InstallPaymentApp(GURL("https://bobpay.test/b/"),
+                    GURL("https://bobpay.test/b/script.js"));
+  int64_t bobpay_registration_id = last_sw_registration_id();
 
   InstalledPaymentAppsFinder::PaymentApps apps;
   GetAllPaymentApps(base::BindOnce(&GetAllPaymentAppsCallback, &apps));
   ASSERT_EQ(2U, apps.size());
 
-  int64_t bobpay_registration_id = last_sw_registration_id();
   EXPECT_EQ(apps[bobpay_registration_id]->scope.spec(),
             "https://bobpay.test/b/");
 
@@ -288,95 +273,48 @@
 }
 
 TEST_F(PaymentAppProviderTest, GetAllPaymentAppsTest) {
-  PaymentManager* manager1 =
-      CreatePaymentManager(GURL("https://hellopay.test/a/"),
-                           GURL("https://hellopay.test/a/script.js"));
+  InstallPaymentApp(GURL("https://hellopay.test/a/"),
+                    GURL("https://hellopay.test/a/script.js"), "hellopay");
   int64_t hellopay_registration_id = last_sw_registration_id();
-
-  PaymentManager* manager2 = CreatePaymentManager(
-      GURL("https://bobpay.test/b/"), GURL("https://bobpay.test/b/script.js"));
+  InstallPaymentApp(GURL("https://bobpay.test/b/"),
+                    GURL("https://bobpay.test/b/script.js"), "bobpay");
   int64_t bobpay_registration_id = last_sw_registration_id();
 
-  PaymentHandlerStatus status;
-  PaymentInstrumentPtr instrument_1 = PaymentInstrument::New();
-  instrument_1->method = "hellopay";
-  SetPaymentInstrument(manager1, "test_key1", std::move(instrument_1),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
-
-  PaymentInstrumentPtr instrument_2 = PaymentInstrument::New();
-  instrument_2->method = "hellopay";
-  SetPaymentInstrument(manager2, "test_key2", std::move(instrument_2),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
-
-  PaymentInstrumentPtr instrument_3 = PaymentInstrument::New();
-  instrument_3->method = "bobpay";
-  SetPaymentInstrument(manager2, "test_key3", std::move(instrument_3),
-                       base::BindOnce(&SetPaymentInstrumentCallback, &status));
-
   InstalledPaymentAppsFinder::PaymentApps apps;
   GetAllPaymentApps(base::BindOnce(&GetAllPaymentAppsCallback, &apps));
 
   ASSERT_EQ(2U, apps.size());
   ASSERT_EQ(1U, apps[hellopay_registration_id]->enabled_methods.size());
-  ASSERT_EQ(2U, apps[bobpay_registration_id]->enabled_methods.size());
+  ASSERT_EQ(1U, apps[bobpay_registration_id]->enabled_methods.size());
 }
 
 TEST_F(PaymentAppProviderTest, GetAllPaymentAppsFromTheSameOriginTest) {
-  PaymentManager* manager1 = CreatePaymentManager(
-      GURL("https://bobpay.test/a/"), GURL("https://bobpay.test/a/script.js"));
+  InstallPaymentApp(GURL("https://bobpay.test/a/"),
+                    GURL("https://bobpay.test/a/script.js"), "hellopay");
   int64_t bobpay_a_registration_id = last_sw_registration_id();
-
-  PaymentManager* manager2 = CreatePaymentManager(
-      GURL("https://bobpay.test/b/"), GURL("https://bobpay.test/b/script.js"));
+  InstallPaymentApp(GURL("https://bobpay.test/b/"),
+                    GURL("https://bobpay.test/b/script.js"), "bobpay");
   int64_t bobpay_b_registration_id = last_sw_registration_id();
 
-  PaymentHandlerStatus status;
-  PaymentInstrumentPtr instrument_1 = PaymentInstrument::New();
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

TOCTOU in PaymentAppInfoFetcher allows 1-bit cross-origin oracle

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: A potential Time-Of-Check to Time-Of-Use vulnerability exists in PaymentAppInfoFetcher. A compromised renderer can delay a web app manifest response and navigate the frame, causing a subsequent icon download to execute in the context of the newly committed cross-origin frame. This allows an attacker to leak cross-origin state via a 1-bit oracle.

Affected files:

  • content/browser/payments/payment_app_info_fetcher.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/manifest/manifest_icon_downloader.cc
  • content/public/browser/manifest_icon_downloader.h

Estimated timestamp from git blame: 2024-07-19

Summary

A potential Time-Of-Check to Time-Of-Use (TOCTOU) vulnerability exists in the PaymentAppInfoFetcher component of the Payment Request API. A compromised renderer can exploit this to trigger a credentialed icon fetch using the context of a cross-origin site (e.g., a site the user has navigated to in the same tab). This results in a 1-bit cross-origin oracle that can leak information like login status or resource existence on the victim site.

Root Cause

The vulnerability resides in PaymentAppInfoFetcher::SelfDeleteFetcher, which handles fetching payment app information, including the manifest and icon.

  1. In SelfDeleteFetcher::Start(), the code verifies that the provided context_url is same-origin with the WebContents’s current primary main frame (web_contents_->GetLastCommittedURL()). After this check passes, it stores a WeakPtr to the WebContents and initiates an asynchronous IPC to the renderer to fetch the web app manifest via GetManifest().
  2. When the manifest response eventually arrives, execution resumes in SelfDeleteFetcher::FetchPaymentAppManifestCallback. The code verifies that the WebContents is still alive via the WeakPtr, but it fails to re-verify that the frame’s current origin is still the same as the original context_url.
  3. The code then extracts an icon URL from the manifest and initiates a download via ManifestIconDownloader::Download(). Crucially, this function call does not specify an initiator_frame_routing_id.
  4. Inside WebContentsImpl::DownloadImageInFrame(), because the initiator_frame_routing_id is empty, the browser falls back to calling GetPrimaryMainFrame() to resolve the initiator frame.
  5. If the tab has navigated cross-origin between the time the manifest was requested and the response was received, GetPrimaryMainFrame() will return the newly committed, cross-origin frame. Consequently, the DownloadImage Mojo request is sent to the new trusted renderer, executing a first-party, credentialed fetch.

Potential Attack Vector

Note: These are suggested steps; our tooling has not executed this as a live exploit.

  1. An attacker controls a compromised renderer process hosting https://attacker.com/.
  2. The attacker uses the bound payments::mojom::PaymentManager Mojo interface to call Init(), ensuring the browser will attempt to fetch payment app info, followed by SetPaymentInstrument().
  3. The browser validates the origin in PaymentAppInfoFetcher, passes the check, and sends a RequestManifest IPC to the attacker’s renderer.
  4. The attacker’s compromised renderer intentionally stalls the RequestManifest response and initiates a top-level navigation of the frame to https://victim.com/.
  5. After the browser commits the navigation (updating GetPrimaryMainFrame() to point to victim.com), the old attacker.com frame enters the pending_delete state but remains alive to run unload handlers. During this window, the attacker’s renderer finally sends the delayed RequestManifestResponse, containing an icon URL pointing to a private resource on victim.com (e.g., https://victim.com/private_avatar.png).
  6. The browser processes the response. Because it skips re-validating the origin, it attempts to download the icon.
  7. Due to the lack of an initiator_frame_routing_id, the download request falls back to GetPrimaryMainFrame(), routing the DownloadImage request to the trusted victim.com renderer.
  8. The victim.com renderer executes a same-origin fetch for the image, attaching all relevant credentials (including SameSite=Strict cookies).
  9. The success or failure of the image decode operation is returned to the browser. This result dictates whether the original SetPaymentInstrument call succeeds or fails (FETCH_PAYMENT_APP_INFO_FAILED).
  10. The attacker receives this success/failure signal via their original Mojo callback, providing a 1-bit oracle confirming whether the credentialed fetch on victim.com succeeded.

Impact

While an attacker cannot read the image pixels directly, they gain a 1-bit oracle that can detect whether a specific credentialed request on a cross-origin site succeeded. This can be used to infer the user’s login state or the existence of private resources on the victim domain.

Suggested Fix

  1. In PaymentAppInfoFetcher::SelfDeleteFetcher::FetchPaymentAppManifestCallback, re-validate that url::IsSameOriginWith(context_url_, web_contents_->GetLastCommittedURL()) before proceeding with the icon download.
  2. In PaymentAppInfoFetcher::SelfDeleteFetcher::Start, capture the GlobalRenderFrameHostId of the validated frame and pass it explicitly to ManifestIconDownloader::Download() rather than relying on the GetPrimaryMainFrame() fallback. This ensures the download request is routed to the correct, originally validated frame, failing safely if that frame is no longer active.

Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b


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