Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebAppInstalls
DescriptionUse after free in WebAppInstalls
ComponentWebAppInstalls
Bug ClassUAF
Tracker513128608
Fix commit9a7a96bc3bd3 (chromium/src) +40/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
modified

Files Changed

  • chrome/browser/apps/app_shim/app_shim_manager_mac.cc
  • chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
From 9a7a96bc3bd35bce84e9812cd32849839ceba6d7 Mon Sep 17 00:00:00 2001
From: Dibyajyoti Pal <[email protected]>
Date: Mon, 18 May 2026 11:52:34 -0700
Subject: [PATCH] [PWA][Mac] Asynchronously deactivate app on launch cancellation

When an app shim launch is cancelled (e.g., when a disallowed protocol
launch is aborted by the delegate),
AppShimManager::OnAppLaunchCancelled() previously invoked
OnAppDeactivated() synchronously. This immediately destroyed the
ProfileState while LoadAndLaunchApp() was still executing. When control
returned to the launch completion callback, accessing the freed
ProfileState resulted in a heap use-after-free (UAF).

This CL fixes the UAF by changing OnAppLaunchCancelled() to call
OnAppDeactivated() asynchronously via PostTask, matching the existing
pattern used by OnBrowserClosed(). This breaks the synchronous
reentrancy chain, allowing the initial launch stack to complete safely
against a valid ProfileState, which is subsequently cleaned up on the
next message loop pump.

Also includes an unit-test that reliably reproduces this UAF.

Fixed: 513128608
Change-Id: I5c8412dfcdd5d8e54a94259314a17aabebcef090
Include-Ci-Only-Tests: chromium.mac:mac15-x64-rel-tests|browser_tests
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7848488
Commit-Queue: Dibyajyoti Pal <[email protected]>
Reviewed-by: Marijn Kruisselbrink <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1632313}
---

diff --git a/chrome/browser/apps/app_shim/app_shim_manager_mac.cc b/chrome/browser/apps/app_shim/app_shim_manager_mac.cc
index 2362f60e..a2a1c58 100644
--- a/chrome/browser/apps/app_shim/app_shim_manager_mac.cc
+++ b/chrome/browser/apps/app_shim/app_shim_manager_mac.cc
@@ -1849,9 +1849,14 @@
 
   // If there are no browser windows open, then close the ProfileState
   // (and potentially the shim as well).
+  // Do this asynchronously to prevent reentrancy issues so that
+  // `OnShimProcessConnectedAndAllLaunchesDone()` can access the ProfileState
+  // without it being destroyed.
   ProfileState* profile_state = found_profile->second.get();
   if (profile_state->browsers.empty()) {
-    OnAppDeactivated(context, app_id);
+    content::GetUIThreadTaskRunner({})->PostTask(
+        FROM_HERE, base::BindOnce(&AppShimManager::OnAppDeactivated,
+                                  weak_factory_.GetWeakPtr(), context, app_id));
   }
 }
 
diff --git a/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc b/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
index 347163a..f2345b9e 100644
--- a/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
+++ b/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
@@ -708,6 +708,37 @@
   NormalLaunch(bootstrap_aa_, std::move(host_aa_unique_));
 }
 
+// Regression test for crbug.com/513128608.
+TEST_F(AppShimManagerTest, DisallowedProtocolCancelsLaunchReentrancy) {
+  // OnAppDeactivated -> apps_.empty() -> MaybeTerminate(). Mock it as a no-op.
+  EXPECT_CALL(*manager_, MaybeTerminate()).Times(testing::AnyNumber());
+
+  // When the delegate's LaunchApp is invoked (synchronously, from inside
+  // LoadAndLaunchApp_OnProfilesAndAppReady), simulate the production
+  // disallowed-protocol fast-path: synchronously call OnAppLaunchCancelled,
+  // which frees the just-created ProfileState before control returns to the
+  // caller that still holds a bare pointer to it.
+  EXPECT_CALL(*delegate_, LaunchApp(&profile_a_, kTestAppIdA, _, _, _, _, _))
+      .WillOnce(WithArgs<6>([this](base::OnceClosure finished) {
+        // Mirrors the following flow: cancel app launch ->
+        // AppShimManager::Get()->OnAppLaunchCancelled()
+        manager_->OnAppLaunchCancelled(&profile_a_, kTestAppIdA);
+        std::move(finished).Run();
+      }));
+
+  // Launch with a non-empty `urls` vector so params.HasFilesOrURLs() is true
+  // and delegate_->LaunchApp is called from
+  // LoadAndLaunchApp_LaunchIfAppropriate.
+  //
+  // ASAN: heap-use-after-free fires inside this call at
+  // OnShimProcessConnectedAndAllLaunchesDone -> profile_state->GetHost().
+  DoShimLaunch(bootstrap_aa_, std::move(host_aa_unique_),
+               chrome::mojom::AppShimLaunchType::kNormal,
+               /*files=*/std::vector<base::FilePath>(),
+               /*urls=*/std::vector<GURL>{GURL("web+evil://x")},
+               chrome::mojom::AppShimLoginItemRestoreState::kNone);
+}
+
 TEST_F(AppShimManagerTest, LaunchAndCloseShim) {
   // Normal startup.
   NormalLaunch(bootstrap_aa_, std::move(host_aa_unique_));
@@ -794,8 +825,9 @@
   EXPECT_EQ(host_bb_.get(), manager_->FindHost(&profile_b_, kTestAppIdB));
   EXPECT_CALL(*manager_, MaybeTerminate()).WillOnce(Return());
   manager_->OnAppLaunchCancelled(&profile_b_, kTestAppIdB);
-  EXPECT_FALSE(manager_->FindHost(&profile_b_, kTestAppIdB));
-  EXPECT_EQ(host_bb_.get(), nullptr);
+  EXPECT_TRUE(base::test::RunUntil([&] {
+    return !host_bb_ && !manager_->FindHost(&profile_b_, kTestAppIdB);
+  }));
 
   // Validate that if a browser is registered during a launch
   // that OnAppLaunchCancelled is an no-op
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc b/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
index 347163a..f2345b9e 100644
--- a/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
+++ b/chrome/browser/apps/app_shim/app_shim_manager_mac_unittest.cc
@@ -708,6 +708,37 @@
   NormalLaunch(bootstrap_aa_, std::move(host_aa_unique_));
 }
 
+// Regression test for crbug.com/513128608.
+TEST_F(AppShimManagerTest, DisallowedProtocolCancelsLaunchReentrancy) {
+  // OnAppDeactivated -> apps_.empty() -> MaybeTerminate(). Mock it as a no-op.
+  EXPECT_CALL(*manager_, MaybeTerminate()).Times(testing::AnyNumber());
+
+  // When the delegate's LaunchApp is invoked (synchronously, from inside
+  // LoadAndLaunchApp_OnProfilesAndAppReady), simulate the production
+  // disallowed-protocol fast-path: synchronously call OnAppLaunchCancelled,
+  // which frees the just-created ProfileState before control returns to the
+  // caller that still holds a bare pointer to it.
+  EXPECT_CALL(*delegate_, LaunchApp(&profile_a_, kTestAppIdA, _, _, _, _, _))
+      .WillOnce(WithArgs<6>([this](base::OnceClosure finished) {
+        // Mirrors the following flow: cancel app launch ->
+        // AppShimManager::Get()->OnAppLaunchCancelled()
+        manager_->OnAppLaunchCancelled(&profile_a_, kTestAppIdA);
+        std::move(finished).Run();
+      }));
+
+  // Launch with a non-empty `urls` vector so params.HasFilesOrURLs() is true
+  // and delegate_->LaunchApp is called from
+  // LoadAndLaunchApp_LaunchIfAppropriate.
+  //
+  // ASAN: heap-use-after-free fires inside this call at
+  // OnShimProcessConnectedAndAllLaunchesDone -> profile_state->GetHost().
+  DoShimLaunch(bootstrap_aa_, std::move(host_aa_unique_),
+               chrome::mojom::AppShimLaunchType::kNormal,
+               /*files=*/std::vector<base::FilePath>(),
+               /*urls=*/std::vector<GURL>{GURL("web+evil://x")},
+               chrome::mojom::AppShimLoginItemRestoreState::kNone);
+}
+
 TEST_F(AppShimManagerTest, LaunchAndCloseShim) {
   // Normal startup.
   NormalLaunch(bootstrap_aa_, std::move(host_aa_unique_));
@@ -794,8 +825,9 @@
   EXPECT_EQ(host_bb_.get(), manager_->FindHost(&profile_b_, kTestAppIdB));
   EXPECT_CALL(*manager_, MaybeTerminate()).WillOnce(Return());
   manager_->OnAppLaunchCancelled(&profile_b_, kTestAppIdB);
-  EXPECT_FALSE(manager_->FindHost(&profile_b_, kTestAppIdB));
-  EXPECT_EQ(host_bb_.get(), nullptr);
+  EXPECT_TRUE(base::test::RunUntil([&] {
+    return !host_bb_ && !manager_->FindHost(&profile_b_, kTestAppIdB);
+  }));
 
   // Validate that if a browser is registered during a launch
   // that OnAppLaunchCancelled is an no-op
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in AppShimManager on macOS via synchronous reentrancy

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 Use-After-Free (UAF) vulnerability exists in the macOS browser process due to synchronous reentrancy during the app shim launch sequence. If a launch is cancelled synchronously (for example, due to a disallowed protocol handler), internal state objects can be destroyed while raw pointers to them are still being processed on the stack. Subsequent dereferences of these dangling pointers result in a UAF in the unsandboxed browser process.

Affected files:

  • chrome/browser/apps/app_shim/app_shim_manager_mac.cc
  • chrome/browser/apps/app_shim/web_app_shim_manager_delegate_mac.cc

Estimated timestamp from git blame: 2021-09-15

Description

A potential Use-After-Free (UAF) vulnerability has been identified in the AppShimManager component on macOS. The issue arises from synchronous reentrancy during the app shim launch process. Specifically, the AppShimManager maintains bare pointers to ProfileState objects on the stack. If a launch is cancelled synchronously, these objects can be erased from their owning maps and destroyed, leaving the stack pointers dangling. Subsequent dereferences of these pointers lead to a UAF.

Technical Details

In chrome/browser/apps/app_shim/app_shim_manager_mac.cc, the method LoadAndLaunchApp_OnProfilesAndAppReady manages the launch of app shims. It retrieves or creates a ProfileState and stores it in a bare local pointer:

ProfileState* profile_state = nullptr;
if (delegate_->AppCanCreateHost(profile, params.app_id)) {
  profile_state = GetOrCreateProfileState(profile, params.app_id);
}
LoadAndLaunchApp_LaunchIfAppropriate(profile, profile_state, params,
                                     launch_finished.Release());

The call to LoadAndLaunchApp_LaunchIfAppropriate eventually invokes delegate_->LaunchApp. In chrome/browser/apps/app_shim/web_app_shim_manager_delegate_mac.cc, if the application is launched via a protocol that the user has previously disallowed and marked as “remembered”, the delegate synchronously cancels the launch:

if (registrar.IsDisallowedLaunchProtocol(app_id, protocol_url.GetScheme())) {
  CancelAppLaunch(profile, app_id);
  return;
}

The CancelAppLaunch call flows back into AppShimManager::OnAppLaunchCancelled, which calls OnAppDeactivated. If there are no active browser windows for the app (which is common during a fresh launch sequence), OnAppDeactivated removes the ProfileState from its containing map, causing it to be deleted:

app_state->profiles.erase(found_profile); // Immediately destroys ProfileState
if (app_state->ShouldDeleteAppState()) {
  apps_.erase(found_app);                // Immediately destroys AppState
}

When control returns to LoadAndLaunchApp_OnProfilesAndAppReady, the profile_state pointer is dangling. It is subsequently passed to OnShimProcessConnectedAndAllLaunchesDone, where it is dereferenced:

DCHECK(profile_state);
AppShimHost* host = profile_state->GetHost(); // UAF read

This vulnerability is not mitigated by MiraclePtr because the affected pointers are bare pointers on the stack, and the objects are destroyed via their owner’s erasure from a map during the reentrant call.

Potential Attack Scenario

  1. Preparation: An attacker induces a user to install a Progressive Web App (PWA) that registers a custom protocol handler (e.g., web+evil://).
  2. Initial Interaction: The user clicks a web+evil:// link. When the Chrome protocol handler permission dialog appears, the user selects “Don’t Allow” and “Remember my choice”.
  3. Trigger: The user is induced to click another web+evil:// link. Due to the asynchronous nature of OS integration updates on macOS, the app shim may still be launched by the OS.
  4. Execution: The browser process receives the connection from the shim. During the launch sequence, the manager identifies the protocol as disallowed. This triggers the synchronous cancellation path, destroying the ProfileState. The browser process then dereferences the dangling pointer.

Note: These steps are based on a source code analysis; a functional proof-of-concept has not been executed.

Impact

This is a potential high-severity UAF in the browser process. Successful exploitation could allow an attacker to achieve arbitrary code execution in the context of the unsandboxed browser process on macOS.

Suggested Fix

Avoid performing synchronous deletions of ProfileState or AppState objects during the launch sequence. Deletions in OnAppDeactivated should be made asynchronous (e.g., via base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask) when triggered during a launch, or the AppShimManager should use base::WeakPtr to track the lifetime of these objects safely across reentrant calls.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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