High watch CVSS 9.6 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Payments
DescriptionUse after free in Payments
ComponentPayments
Sub-componentPayments
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker540138836
Fix commit9934ca9908d8 (chromium/src) +135/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-29 (per vendor advisory)
Fix landed2026-07-31 (public git)
Disclosed2026-08-06
Patch gap6 days — fix public in source before the advisory

Background

`PaymentRequest` object graph
In the browser process, a PaymentRequest owns its PaymentRequestSpec (spec_), its PaymentRequestState, and UI controllers such as SecurePaymentConfirmationController. All of them are freed when the requesting WebContents is destroyed.
Initialization observers
PaymentRequestSpec and PaymentRequestState are InitializationTasks whose NotifyInitialized() synchronously runs InitializationTask::Observer::OnInitialized. Those observers can proceed to show the payment UI.
Secure Payment Confirmation (SPC) modal and fullscreen exit
Per the commit, showing the SPC dialog via ShowWebModalDialogViews() calls ExitFullscreen(true) synchronously if the tab is fullscreen. On Windows and macOS this can pump a nested message loop or pending window messages, during which the WebContents may be destroyed.
`base::WeakPtr` self-liveness guard
taking weak_ptr_factory_.GetWeakPtr() before a re-entrant call and returning if it has been invalidated afterwards.

Root Cause Analysis

Several Payments methods made synchronous calls that can, through observers, end up showing the SPC modal. Each of them then kept using this. Those calls are NotifyInitialized() in PaymentRequestState::OnDoneCreatingPaymentApps and PaymentRequestSpec::RecomputeSpecForDetails, spec_->UpdateWith() in PaymentRequest::UpdateWith, and view_->ShowDialog() in SecurePaymentConfirmationController::SetupModelAndShowDialogIfApplicable. Showing that modal can exit fullscreen and spin a nested message loop that destroys the WebContents, freeing PaymentRequest and everything it owns while these frames are still on the stack. Execution then resumed in freed objects, for example by assigning current_update_reason_, running can_make_payment_callback_, reading spec_->details(), or continuing with the auto-accept/auto-reject logic in the SPC controller. Nothing checked that the object was still alive after a call that could trigger arbitrary teardown.

The fix takes a WeakPtr before each re-entrant call and returns immediately if it has been invalidated, so no member is touched after destruction.

Key insight
Synchronous observer and UI calls (NotifyInitialized, UpdateWith, ShowDialog) were treated as non-reentrant, but showing the SPC modal can pump messages and delete the whole PaymentRequest graph. The fix adds WeakPtr liveness checks right after each such call.

Attack Path

  1. Set up an SPC request A page with an enrolled SPC credential (the browser test adds one for a.com) calls PaymentRequest with Secure Payment Confirmation, optionally while it is fullscreen.
  2. Reach a re-entrant call Initialization finishes (OnDoneCreatingPaymentApps or RecomputeSpecForDetails calling NotifyInitialized()), or the page resolves show() details (PaymentRequest::UpdateWith). This leads to SetupModelAndShowDialogIfApplicable calling view_->ShowDialog().
  3. Teardown inside the call Showing the modal calls ExitFullscreen(true), and the resulting nested message processing on Windows or macOS destroys the WebContents, for example a pending tab close. The test simulates this by calling WebContents::Close() from OnUIDisplayed.
  4. Use after free Before the patch, each caller resumed and dereferenced members of the now-freed PaymentRequest, PaymentRequestSpec, PaymentRequestState or SecurePaymentConfirmationController in the browser process. Turning this into a controlled corruption primitive is (inferred).

Impact Assessment

The use-after-free is in the browser process, so it could in principle give code execution outside the renderer sandbox (inferred). The patch shows only the stale member accesses. Preconditions are that ordinary web content invokes the Payment Request API with SPC, which needs an SPC credential and an authenticator for the relying party, and that the WebContents is destroyed during the synchronous dialog-show path. The commit attributes that destruction to the fullscreen exit pumping messages on Windows and macOS, so it is likely timing- and platform-dependent; for example the page must be fullscreen, which normally requires a user gesture (inferred). The advisory states only “Use after free in Payments” and does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
components/payments/content/payment_request.cc
modified
if
components/payments/content/payment_request_spec.cc
modified
if
components/payments/content/payment_request_state.cc
modified
if
components/payments/content/secure_payment_confirmation_controller.cc
modified
SecurePaymentConfirmationSynchronousDestructionTest
chrome/browser/payments/secure_payment_confirmation_browsertest.cc
modified
PaymentRequestSpecSynchronousDestructionTest
components/payments/content/payment_request_spec_unittest.cc
modified
TEST_F
components/payments/content/payment_request_spec_unittest.cc
modified
PaymentRequestStateSynchronousDestructionTest
components/payments/content/payment_request_state_unittest.cc
modified
if
components/payments/content/payment_request_state_unittest.cc
modified

Files Changed

  • components/payments/content/payment_request_spec_unittest.cc
  • components/payments/content/secure_payment_confirmation_controller.cc
  • components/payments/content/payment_request_state_unittest.cc
  • components/payments/content/payment_request.cc
  • chrome/browser/payments/secure_payment_confirmation_browsertest.cc
  • components/payments/content/payment_request_state.cc
  • components/payments/content/payment_request_spec.cc

Audit Directions

  • Other show-UI paths that can exit fullscreen
    Audit callers of ShowWebModalDialogViews() and other ExitFullscreen(true) call sites in the browser, such as WebAuthn, permission prompts, autofill and FedCM dialogs. Look for code that continues to use this after showing a modal synchronously.
  • Nested message loops on Windows and macOS
    Search for calls that may pump messages (window activation, fullscreen transitions, SendMessage-based Win32 APIs, NSApp modal sessions) inside browser-side objects owned by WebContents. Verify that the code after those calls tolerates the tab being destroyed.
From 9934ca9908d82b853b4391af74405398db9f1242 Mon Sep 17 00:00:00 2001
From: Stephen McGruer <[email protected]>
Date: Thu, 30 Jul 2026 17:52:57 -0700
Subject: [PATCH] [payments] Avoid UAF for calls that can show an SPC modal

When showing a Secure Payment Confirmation (SPC) web modal dialog via
ShowWebModalDialogViews(), the modal delegate checks if the web contents
is in fullscreen and if so synchronously calls ExitFullscreen(true). On
platforms like Windows and macOS, this can spin a nested message loop or
process pending window messages, destroying the WebContents and freeing
PaymentRequest and its owned objects while they are still on the stack.

This change adds base::WeakPtr self-liveness guards after synchronous
re-entrant calls in:

- PaymentRequestState::OnDoneCreatingPaymentApps()
- PaymentRequestSpec::RecomputeSpecForDetails()
- PaymentRequest::UpdateWith()
- SecurePaymentConfirmationController::
      SetupModelAndShowDialogIfApplicable

Fixed: 540138836
Change-Id: I7bfc5e8685abe7bc708520fa48059f623d24eaec
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162516
Commit-Queue: Stephen McGruer <[email protected]>
Reviewed-by: Darwin Yang <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1671557}
---
diff --git a/components/payments/content/payment_request.cc b/components/payments/content/payment_request.cc
index 2b1b2821..bbb4dac 100644
--- a/components/payments/content/payment_request.cc
+++ b/components/payments/content/payment_request.cc
@@ -590,7 +590,13 @@
 
   bool is_resolving_promise_passed_into_show_method = !spec_->IsInitialized();
 
+  // spec_->UpdateWith() can synchronously trigger observers that destroy the
+  // payment window's WebContents and delete `this`.
+  auto weak_this = weak_ptr_factory_.GetWeakPtr();
   spec_->UpdateWith(std::move(details));
+  if (!weak_this) {
+    return;
+  }
 
   if (is_resolving_promise_passed_into_show_method) {
     DCHECK(spec_->details().total);
diff --git a/components/payments/content/payment_request_spec.cc b/components/payments/content/payment_request_spec.cc
index 86e6e606..58ba3b0 100644
--- a/components/payments/content/payment_request_spec.cc
+++ b/components/payments/content/payment_request_spec.cc
@@ -214,8 +214,14 @@
 
   NotifyOnSpecUpdated();
 
+  // NotifyInitialized() can synchronously trigger observers that destroy the
+  // payment window's WebContents and delete `this`.
+  auto weak_this = weak_ptr_factory_.GetWeakPtr();
   if (is_initialization)
     NotifyInitialized();
+  if (!weak_this) {
+    return;
+  }
 
   current_update_reason_ = UpdateReason::NONE;
 }
diff --git a/components/payments/content/payment_request_state.cc b/components/payments/content/payment_request_state.cc
index c6bb3ae..f88dcff3 100644
--- a/components/payments/content/payment_request_state.cc
+++ b/components/payments/content/payment_request_state.cc
@@ -218,7 +218,14 @@
       [](const auto& app) { return app->HasEnrolledInstrument(); });
   are_requested_methods_supported_ |= !available_apps_.empty();
   NotifyOnGetAllPaymentAppsFinished();
+
+  // NotifyInitialized() can synchronously trigger observers that destroy the
+  // payment window's WebContents and delete `this`.
+  auto weak_this = weak_ptr_factory_.GetWeakPtr();
   NotifyInitialized();
+  if (!weak_this) {
+    return;
+  }
 
   // Fulfill the pending CanMakePayment call.
   if (can_make_payment_callback_)
diff --git a/components/payments/content/secure_payment_confirmation_controller.cc b/components/payments/content/secure_payment_confirmation_controller.cc
index a232b58c..052e5a4e 100644
--- a/components/payments/content/secure_payment_confirmation_controller.cc
+++ b/components/payments/content/secure_payment_confirmation_controller.cc
@@ -373,6 +373,9 @@
   view_ = SecurePaymentConfirmationView::Create(
       request_->state()->GetPaymentRequestDelegate()->GetPaymentUIObserver());
 
+  // view_->ShowDialog() can potentially trigger observers that destroy the
+  // payment window's WebContents and delete `this`.
+  auto weak_this = weak_ptr_factory_.GetWeakPtr();
   view_->ShowDialog(
       request_->web_contents(), model_.GetWeakPtr(),
       base::BindOnce(&SecurePaymentConfirmationController::OnConfirm,
@@ -383,6 +386,9 @@
                      weak_ptr_factory_.GetWeakPtr()),
       base::BindOnce(&SecurePaymentConfirmationController::OnOptOut,
                      weak_ptr_factory_.GetWeakPtr()));
+  if (!weak_this) {
+    return;
+  }
 
   // For automated testing, SPC can be placed in an 'autoaccept' or
   // 'autoreject' mode, where the dialog should immediately be
diff --git a/chrome/browser/payments/secure_payment_confirmation_browsertest.cc b/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
index 2e52323..c5ce281 100644
--- a/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
+++ b/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
@@ -523,6 +523,44 @@
   test_controller()->CloseDialog();
 }
 
+class SecurePaymentConfirmationSynchronousDestructionTest
+    : public SecurePaymentConfirmationTest {
+ public:
+  SecurePaymentConfirmationSynchronousDestructionTest() = default;
+  ~SecurePaymentConfirmationSynchronousDestructionTest() override = default;
+
+  void OnUIDisplayed() override {
+    SecurePaymentConfirmationTest::OnUIDisplayed();
+    GetActiveWebContents()->Close();
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(SecurePaymentConfirmationSynchronousDestructionTest,
+                       CloseTabDuringUIDisplayed) {
+  test_controller()->SetHasAuthenticator(true);
+  NavigateTo("a.com", "/secure_payment_confirmation.html");
+  std::vector<uint8_t> credential_id = {'c', 'r', 'e', 'd'};
+  std::vector<uint8_t> user_id = {'u', 's', 'e', 'r'};
+  webdata_services::WebDataServiceWrapperFactory::
+      GetWebPaymentsWebDataServiceForBrowserContext(
+          GetActiveWebContents()->GetBrowserContext(),
+          ServiceAccessType::EXPLICIT_ACCESS)
+          ->AddSecurePaymentConfirmationCredential(
+              std::make_unique<SecurePaymentConfirmationCredential>(
+                  std::move(credential_id), "a.com", std::move(user_id)),
+              base::BindOnce(
+                  &SecurePaymentConfirmationTest::OnWebDataServiceRequestDone,
+                  weak_ptr_factory_.GetWeakPtr()));
+
+  ResetEventWaiterForSingleEvent(TestEvent::kUIDisplayed);
+  ExecuteScriptAsync(GetActiveWebContents(),
+                     "getSecurePaymentConfirmationStatus()");
+  WaitForObservedEvent();
+
+  // The WebContents and PaymentRequest were closed during OnUIDisplayed(),
+  // and SetupModelAndShowDialogIfApplicable() returned safely without a UAF.
+}
+
 #if !BUILDFLAG(IS_ANDROID)
 // Intentionally do not enable the "SecurePaymentConfirmation" Blink runtime
 // feature or the browser-side Finch flag.
diff --git a/components/payments/content/payment_request_spec_unittest.cc b/components/payments/content/payment_request_spec_unittest.cc
index 1e84b12..63400cc 100644
--- a/components/payments/content/payment_request_spec_unittest.cc
+++ b/components/payments/content/payment_request_spec_unittest.cc
@@ -10,6 +10,7 @@
 #include "base/memory/weak_ptr.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "components/payments/content/initialization_task.h"
 #include "components/strings/grit/components_strings.h"
 #include "content/public/common/content_features.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -47,6 +48,7 @@
   }
 
   PaymentRequestSpec* spec() { return spec_.get(); }
+  void ResetSpec() { spec_.reset(); }
 
  private:
   std::unique_ptr<PaymentRequestSpec> spec_;
@@ -312,4 +314,29 @@
 
   EXPECT_TRUE(spec()->has_payer_error());
 }
+
+class PaymentRequestSpecSynchronousDestructionTest
+    : public PaymentRequestSpecTest,
+      public InitializationTask::Observer {
+ public:
+  // InitializationTask::Observer:
+  void OnInitialized(InitializationTask* initialization_task) override {
+    ResetSpec();
+  }
+};
+
+TEST_F(PaymentRequestSpecSynchronousDestructionTest, RecomputeSpecForDetails) {
+  RecreateSpecWithOptionsAndDetails(mojom::PaymentOptions::New(),
+                                    mojom::PaymentDetails::New());
+
+  spec()->AddInitializationObserver(this);
+  spec()->StartWaitingForUpdateWith(
+      PaymentRequestSpec::UpdateReason::INITIAL_PAYMENT_DETAILS);
+  spec()->RecomputeSpecForDetails();
+
+  // RecomputeSpecForDetails will have synchronously torn down the spec object,
+  // but should not cause a UAF.
+  EXPECT_FALSE(spec());
+}
+
 }  // namespace payments
diff --git a/components/payments/content/payment_request_state_unittest.cc b/components/payments/content/payment_request_state_unittest.cc
index 32061ce1..ebc0fe5 100644
--- a/components/payments/content/payment_request_state_unittest.cc
+++ b/components/payments/content/payment_request_state_unittest.cc
@@ -20,6 +20,7 @@
 #include "components/autofill/core/browser/data_model/addresses/autofill_profile.h"
 #include "components/autofill/core/browser/data_model/addresses/autofill_profile_test_api.h"
 #include "components/autofill/core/browser/test_utils/autofill_test_utils.h"
+#include "components/payments/content/initialization_task.h"
 #include "components/payments/content/payment_app_factory.h"
 #include "components/payments/content/payment_app_service.h"
 #include "components/payments/content/payment_request_spec.h"
@@ -637,5 +638,49 @@
   EXPECT_FALSE(response().is_null());
 }
 
+class PaymentRequestStateSynchronousDestructionTest
+    : public PaymentRequestStateTest,
+      public InitializationTask::Observer {
+ public:
+  // InitializationTask::Observer:
+  void OnInitialized(InitializationTask* initialization_task) override {
+    state_.reset();
+    if (on_initialized_) {
+      std::move(on_initialized_).Run();
+    }
+  }
+
+  base::OnceClosure on_initialized_;
+};
+
+TEST_F(PaymentRequestStateSynchronousDestructionTest,
+       OnDoneCreatingPaymentApps) {
+  auto app_service = std::make_unique<PaymentAppService>(&context_);
+  app_service->AddFactoryForTesting(
+      std::make_unique<TestAppFactory>("https://example.test"));
+
+  base::RunLoop run_loop;
+  on_initialized_ = run_loop.QuitClosure();
+
+  RecreateState(mojom::PaymentOptions::New(), CreateDefaultDetails(),
+                GetMethodDataForUrlMethod("https://example.test"),
+                std::move(app_service));
+
+  // PaymentRequestState's constructor in RecreateState immediately calls
+  // app_service_->Create(), which kicks off payment app factories before
+  // callers can register as an InitializationTask::Observer. We rely on the
+  // fact that default payment app factories in PaymentAppService complete
+  // asynchronously on the message loop, so will not complete until we yield the
+  // run loop below.
+  state()->AddInitializationObserver(this);
+
+  run_loop.Run();
+
+  // OnDoneCreatingPaymentApps should have run and called the initialization
+  // observers, which then synchronously deleted the state. This should not
+  // cause a UAF.
+  EXPECT_FALSE(state_);
+}
+
 }  // namespace
 }  // namespace payments
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/payments/secure_payment_confirmation_browsertest.cc b/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
index 2e52323..c5ce281 100644
--- a/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
+++ b/chrome/browser/payments/secure_payment_confirmation_browsertest.cc
@@ -523,6 +523,44 @@
   test_controller()->CloseDialog();
 }
 
+class SecurePaymentConfirmationSynchronousDestructionTest
+    : public SecurePaymentConfirmationTest {
+ public:
+  SecurePaymentConfirmationSynchronousDestructionTest() = default;
+  ~SecurePaymentConfirmationSynchronousDestructionTest() override = default;
+
+  void OnUIDisplayed() override {
+    SecurePaymentConfirmationTest::OnUIDisplayed();
+    GetActiveWebContents()->Close();
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(SecurePaymentConfirmationSynchronousDestructionTest,
+                       CloseTabDuringUIDisplayed) {
+  test_controller()->SetHasAuthenticator(true);
+  NavigateTo("a.com", "/secure_payment_confirmation.html");
+  std::vector<uint8_t> credential_id = {'c', 'r', 'e', 'd'};
+  std::vector<uint8_t> user_id = {'u', 's', 'e', 'r'};
+  webdata_services::WebDataServiceWrapperFactory::
+      GetWebPaymentsWebDataServiceForBrowserContext(
+          GetActiveWebContents()->GetBrowserContext(),
+          ServiceAccessType::EXPLICIT_ACCESS)
+          ->AddSecurePaymentConfirmationCredential(
+              std::make_unique<SecurePaymentConfirmationCredential>(
+                  std::move(credential_id), "a.com", std::move(user_id)),
+              base::BindOnce(
+                  &SecurePaymentConfirmationTest::OnWebDataServiceRequestDone,
+                  weak_ptr_factory_.GetWeakPtr()));
+
+  ResetEventWaiterForSingleEvent(TestEvent::kUIDisplayed);
+  ExecuteScriptAsync(GetActiveWebContents(),
+                     "getSecurePaymentConfirmationStatus()");
+  WaitForObservedEvent();
+
+  // The WebContents and PaymentRequest were closed during OnUIDisplayed(),
+  // and SetupModelAndShowDialogIfApplicable() returned safely without a UAF.
+}
+
 #if !BUILDFLAG(IS_ANDROID)
 // Intentionally do not enable the "SecurePaymentConfirmation" Blink runtime
 // feature or the browser-side Finch flag.
diff --git a/components/payments/content/payment_request_spec_unittest.cc b/components/payments/content/payment_request_spec_unittest.cc
index 1e84b12..63400cc 100644
--- a/components/payments/content/payment_request_spec_unittest.cc
+++ b/components/payments/content/payment_request_spec_unittest.cc
@@ -10,6 +10,7 @@
 #include "base/memory/weak_ptr.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "components/payments/content/initialization_task.h"
 #include "components/strings/grit/components_strings.h"
 #include "content/public/common/content_features.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -47,6 +48,7 @@
   }
 
   PaymentRequestSpec* spec() { return spec_.get(); }
+  void ResetSpec() { spec_.reset(); }
 
  private:
   std::unique_ptr<PaymentRequestSpec> spec_;
@@ -312,4 +314,29 @@
 
   EXPECT_TRUE(spec()->has_payer_error());
 }
+
+class PaymentRequestSpecSynchronousDestructionTest
+    : public PaymentRequestSpecTest,
+      public InitializationTask::Observer {
+ public:
+  // InitializationTask::Observer:
+  void OnInitialized(InitializationTask* initialization_task) override {
+    ResetSpec();
+  }
+};
+
+TEST_F(PaymentRequestSpecSynchronousDestructionTest, RecomputeSpecForDetails) {
+  RecreateSpecWithOptionsAndDetails(mojom::PaymentOptions::New(),
+                                    mojom::PaymentDetails::New());
+
+  spec()->AddInitializationObserver(this);
+  spec()->StartWaitingForUpdateWith(
+      PaymentRequestSpec::UpdateReason::INITIAL_PAYMENT_DETAILS);
+  spec()->RecomputeSpecForDetails();
+
+  // RecomputeSpecForDetails will have synchronously torn down the spec object,
+  // but should not cause a UAF.
+  EXPECT_FALSE(spec());
+}
+
 }  // namespace payments
diff --git a/components/payments/content/payment_request_state_unittest.cc b/components/payments/content/payment_request_state_unittest.cc
index 32061ce1..ebc0fe5 100644
--- a/components/payments/content/payment_request_state_unittest.cc
+++ b/components/payments/content/payment_request_state_unittest.cc
@@ -20,6 +20,7 @@
 #include "components/autofill/core/browser/data_model/addresses/autofill_profile.h"
 #include "components/autofill/core/browser/data_model/addresses/autofill_profile_test_api.h"
 #include "components/autofill/core/browser/test_utils/autofill_test_utils.h"
+#include "components/payments/content/initialization_task.h"
 #include "components/payments/content/payment_app_factory.h"
 #include "components/payments/content/payment_app_service.h"
 #include "components/payments/content/payment_request_spec.h"
@@ -637,5 +638,49 @@
   EXPECT_FALSE(response().is_null());
 }
 
+class PaymentRequestStateSynchronousDestructionTest
+    : public PaymentRequestStateTest,
+      public InitializationTask::Observer {
+ public:
+  // InitializationTask::Observer:
+  void OnInitialized(InitializationTask* initialization_task) override {
+    state_.reset();
+    if (on_initialized_) {
+      std::move(on_initialized_).Run();
+    }
+  }
+
+  base::OnceClosure on_initialized_;
+};
+
+TEST_F(PaymentRequestStateSynchronousDestructionTest,
+       OnDoneCreatingPaymentApps) {
+  auto app_service = std::make_unique<PaymentAppService>(&context_);
+  app_service->AddFactoryForTesting(
+      std::make_unique<TestAppFactory>("https://example.test"));
+
+  base::RunLoop run_loop;
+  on_initialized_ = run_loop.QuitClosure();
+
+  RecreateState(mojom::PaymentOptions::New(), CreateDefaultDetails(),
+                GetMethodDataForUrlMethod("https://example.test"),
+                std::move(app_service));
+
+  // PaymentRequestState's constructor in RecreateState immediately calls
+  // app_service_->Create(), which kicks off payment app factories before
+  // callers can register as an InitializationTask::Observer. We rely on the
+  // fact that default payment app factories in PaymentAppService complete
+  // asynchronously on the message loop, so will not complete until we yield the
+  // run loop below.
+  state()->AddInitializationObserver(this);
+
+  run_loop.Run();
+
+  // OnDoneCreatingPaymentApps should have run and called the initialization
+  // observers, which then synchronously deleted the state. This should not
+  // cause a UAF.
+  EXPECT_FALSE(state_);
+}
+
 }  // namespace
 }  // namespace payments
Loading diff…

Original Bug Report

reported by [email protected]

Potential Browser-Process Use-After-Free in PaymentRequestState::OnDoneCreatingPaymentApps

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

Overview: A potential Use-After-Free (UAF) vulnerability exists in the browser process during the completion of payment app discovery. Under certain conditions, notifying observers synchronously triggers a fullscreen exit and WebContents destruction, which destroys the PaymentRequest object and frees both SecurePaymentConfirmationController and PaymentRequestState on the stack. Subsequent unwinding of the execution flow leads to multiple potential Use-After-Free reads and callback execution on freed memory.

Affected files:

  • components/payments/content/payment_request_state.cc
  • components/payments/content/secure_payment_confirmation_controller.cc
  • components/payments/content/initialization_task.cc

Estimated timestamp from git blame: 2020-08-27

Root Cause Analysis

In PaymentRequestState::OnDoneCreatingPaymentApps (components/payments/content/payment_request_state.cc), NotifyInitialized() is called to notify initialization observers:

  NotifyOnGetAllPaymentAppsFinished();
  NotifyInitialized();                               // Can synchronously free |this|

  // Fulfill the pending CanMakePayment call.
  if (can_make_payment_callback_)
    std::move(can_make_payment_callback_).Run(GetCanMakePaymentValue());

During the execution of NotifyInitialized(), the registered SecurePaymentConfirmationController observer receives the notification and triggers SetupModelAndShowDialogIfApplicable(), which shows a web-modal dialog via constrained_window::ShowWebModalDialogViews().

When blocking web contents for the modal dialog, BrowserWindowModalDialogDelegate::SetWebContentsBlocked() detects if the tab is in HTML5 content fullscreen and synchronously requests exiting fullscreen by calling web_contents->ExitFullscreen(true) to ensure the user has full context for making a security decision. If exiting fullscreen synchronously destroys the initiating WebContents (for example, due to fullscreen-exit re-entrancy or nested loops), the parent PaymentRequest (which is a DocumentService) will be destroyed. This synchronously resets the dialog controller (spc_dialog_.reset()) and deletes the PaymentRequestState (state_).

As the synchronous call stack unwinds:

  1. UAF in Controller: SecurePaymentConfirmationController::SetupModelAndShowDialogIfApplicable attempts to access its freed state (request_->spc_transaction_mode()).
  2. UAF in InitializationTask: InitializationTask::NotifyInitialized attempts to iterate over its freed observers_ list.
  3. UAF in PaymentRequestState: PaymentRequestState::OnDoneCreatingPaymentApps attempts to access and execute its pending callbacks (such as can_make_payment_callback_), which can result in an arbitrary indirect call in the unsandboxed browser process.

Potential Trigger Path

  1. Place the tab into HTML5 content fullscreen via element.requestFullscreen().
  2. Call PaymentRequest.show() with a secure-payment-confirmation payment method.
  3. Wait for the asynchronous payment app discovery to complete.
  4. Upon completion, PaymentRequestState::OnDoneCreatingPaymentApps() executes, calling NotifyInitialized(), which shows the SPC modal dialog and triggers ExitFullscreen(true).
  5. Trigger re-entrant/synchronous destruction of the WebContents during the fullscreen-exit state transition, causing both the controller and state object to be freed while they are still on the stack.

Note: The steps and behavior described above represent a potential analysis of the code flow; our tooling agent does not have the ability to run code or verify this via a live proof-of-concept.


Suggested Fix

To mitigate this potential issue, a base::WeakPtr self-liveness check should be introduced in PaymentRequestState::OnDoneCreatingPaymentApps() after calling NotifyInitialized(). If the state is destroyed, it should return immediately:

  base::WeakPtr<PaymentRequestState> weak_this = weak_ptr_factory_.GetWeakPtr();
  NotifyInitialized();
  if (!weak_this)
    return;

Additionally, the SPC controller should be hardened with weak_ptr checks during dialog setup and after returning from showing the dialog to prevent use of a freed SecurePaymentConfirmationController or request_ pointer.

Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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