Overview

Critical
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtension Management
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.
Tracker506558270
Fix commit9a50771f1596 (chromium/src) +124/-6
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-26 (per vendor advisory)
Fix landed2026-05-06 (public git)
Disclosed2026-06-30
Patch gap55 days — fix public in source before the advisory

Background

`chrome.management.installReplacementWebApp`
an Extensions API that lets a Chrome Web Store extension install and launch a replacement PWA, ultimately reaching the browser-process installability handler modified by this patch.
`content::WebContents` ownership transfer
the handler creates a content::WebContents, keeps a pointer to it, and then hands ownership to chrome::AddWebContents via std::move(web_contents), so any later use must not assume the object still exists.
`base::WeakPtr<content::WebContents>`
a non-owning smart pointer that automatically becomes null when the referenced object is destroyed; the fix uses GetWeakPtr() to detect destruction across the ownership transfer.
Focus-existing launch capturing
a PWA launch_handler client_mode of focus-existing plus link capturing causes a navigation whose URL falls inside the PWA scope to be routed into the existing app window instead of a new tab, which is the trigger condition for the synchronous destruction described in the commit message.

Root Cause Analysis

In the InstallableCheckResult::kInstallable branch of the installability handler (OnWebAppInstallabilityChecked, per the commit message; the enclosing name is not present in the @@ hunk header), the pre-patch code saved a raw pointer with content::WebContents* containing_contents = web_contents.get(); and then transferred ownership of the same object into chrome::AddWebContents(..., std::move(web_contents), ...). According to the commit message, when the navigation is captured by an existing PWA window (focus-existing launch handler), chrome::AddWebContents skips inserting the WebContents into the TabStripModel and synchronously destroys it, so containing_contents is left dangling. Execution then continued unconditionally into web_app::CreateWebAppFromManifest(containing_contents, ...), dereferencing the freed object (a vtable call per the commit message) — a use-after-free in the browser process. The invariant violated is that a pointer aliasing a std::moved object may not be used after the move without confirming the object survived.

The fix captures base::WeakPtr<content::WebContents> weak_contents = web_contents->GetWeakPtr(); before the move, and after chrome::AddWebContents returns checks if (!weak_contents); if the object was destroyed it runs the callback with InstallOrLaunchWebAppResult::kUnknownError and returns before any dereference, otherwise it passes weak_contents.get() into web_app::CreateWebAppFromManifest.

Key insight
The single mistake is retaining and reusing a raw pointer (containing_contents) to a content::WebContents whose ownership was surrendered to chrome::AddWebContents, which can synchronously free it; the fix replaces that raw alias with a base::WeakPtr and adds a validity check (if (!weak_contents)) that aborts the install on the failure path before the object is dereferenced.

Attack Path

  1. Set up a capturing PWA On the victim profile, an already-installed PWA exists with launch_handler client_mode: "focus-existing" and link capturing enabled, whose scope covers the URL the replacement web app will resolve to (the regression test installs “App A” this way via web_app::test::InstallWebApp and apps_util::SetSupportedLinksPreferenceAndWait).
  2. Invoke the management API An extension calls chrome.management.installReplacementWebApp (the test drives this from a webstore extension with a runWithUserGesture background script), reaching the browser-process installability handler.
  3. Hit the installable branch and move ownership The installability check returns InstallableCheckResult::kInstallable; the handler saves the raw pointer containing_contents = web_contents.get() and transfers ownership via chrome::AddWebContents(..., std::move(web_contents), ...).
  4. Trigger synchronous destruction Because the navigation is captured by the existing PWA window, chrome::AddWebContents skips the TabStripModel insertion and synchronously destroys the new WebContents, leaving containing_contents dangling (per the commit message).
  5. Dereference the freed object Execution continues into web_app::CreateWebAppFromManifest(containing_contents, ...), which dereferences the dangling pointer (a vtable call per the commit message), producing the use-after-free; leveraging this into a read/write primitive or code execution is (inferred) and not demonstrated by the diff.

Impact Assessment

An attacker who can get an extension installed (the test marks the extension from_webstore=true) and who arranges a capturing PWA in the profile can trigger a use-after-free of a content::WebContents in the privileged browser process, reached via chrome.management.installReplacementWebApp. The preconditions are a focus-existing/link-capturing PWA whose scope covers the replacement app URL and a user-gesture call into the API. The vendor advisory rates this critical severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.1. Escalation to controlled memory corruption or code execution is (inferred), and the advisory metadata provided states no exploited-in-the-wild status or severity rating.

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/extensions/api/management/management_apitest.cc
modified

Files Changed

  • chrome/browser/extensions/api/management/DEPS
  • chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
  • chrome/browser/extensions/api/management/management_apitest.cc
  • chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html

Audit Directions

  • Raw pointer aliasing a moved-out owner
    Search for T* p = owner.get(); followed by std::move(owner) into a call that can destroy the object, then a later use of p; convert such aliases to base::WeakPtr and re-validate after the transfer.
  • Synchronous destruction inside sink APIs
    Audit callers of chrome::AddWebContents and similar UI/tab-strip and launcher entry points that may synchronously destroy or reject their argument (e.g. under launch-handler capturing), and confirm no caller assumes the passed object survives the call.
  • Multi-step install/launch continuations
    Review web-app install flows that create a content::WebContents and pass it across an asynchronous or ownership-transferring boundary before dereferencing it (e.g. web_app::CreateWebAppFromManifest call sites), ensuring liveness is checked on every branch, including error/early-exit paths.
From 9a50771f1596ce24f099b92b097b4bdbc13d51a3 Mon Sep 17 00:00:00 2001
From: Dan Murphy <dmurph@chromium.org>
Date: Wed, 06 May 2026 11:28:45 -0700
Subject: [PATCH] [Extension Management] Fix UAF in installReplacementWebApp

Fix a Use-After-Free (UAF) vulnerability in the browser process within
OnWebAppInstallabilityChecked.

When chrome.management.installReplacementWebApp is called, it creates a
WebContents and saves a raw pointer onto the stack. Ownership of the
WebContents is then transferred to chrome::AddWebContents.

If the navigation is captured by an existing PWA window (e.g. due to
focus-existing launch handler), chrome::AddWebContents will
synchronously destroy the newly created WebContents because it skips
inserting it into the TabStripModel. This leaves the stack raw pointer
dangling.

Execution then erroneously continued using this dangling pointer in
web_app::CreateWebAppFromManifest, leading to a UAF when performing a
vtable call.

This CL fixes the issue by capturing a
base::WeakPtr<content::WebContents> before moving it, and verifying it
is still valid after AddWebContents returns. If it was destroyed, we
abort the installation and fail safely.

Bug: b:506558270
Test: InstallReplacementWebAppApiTest.CapturedNavigation
Change-Id: Ic3fbeae7895a3c11399eff39cb8e87ae9472fe1d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7814724
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1626347}
---

diff --git a/chrome/browser/extensions/api/management/DEPS b/chrome/browser/extensions/api/management/DEPS
index 5862eefd..8d09a23a 100644
--- a/chrome/browser/extensions/api/management/DEPS
+++ b/chrome/browser/extensions/api/management/DEPS
@@ -8,11 +8,18 @@
     "+chrome/browser/apps/app_service/browser_app_launcher.h",
   ],
 
-  # This DEPS violation is a temporary workaround to continue supporting Chrome
-  # Apps tests while the Chrome Apps deprecation is finalised.
-  # TODO(crbug.com/379262505) - Remove this.
+  # This DEPS violation contains workarounds for Chrome Apps deprecation
+  # and additions for testing the management API's PWA integration.
+  # TODO(crbug.com/379262505) - Remove the chrome_app_deprecation entry when finalised.
   "management_apitest\\.cc": [
-    "+chrome/browser/apps/app_service/chrome_app_deprecation/chrome_app_deprecation.h"
+    "+chrome/browser/apps/app_service/chrome_app_deprecation/chrome_app_deprecation.h",
+
+    # The management API can install/launch replacement webapps, so its tests
+    # need to access AppService and preferred apps test utilities to verify
+    # navigation capturing behavior.
+    "+chrome/browser/apps/app_service/app_service_proxy.h",
+    "+chrome/browser/apps/app_service/app_service_proxy_factory.h",
+    "+chrome/browser/apps/intent_helper/preferred_apps_test_util.h",
   ],
 
   # This DEPS violation is a temporary workaround to continue supporting Chrome
diff --git a/chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc b/chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
index 4c30b20..8a5fbd1 100644
--- a/chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
+++ b/chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
@@ -247,15 +247,21 @@
       std::move(callback).Run(InstallOrLaunchWebAppResult::kInvalidWebApp);
       return;
     case InstallableCheckResult::kInstallable:
-      content::WebContents* containing_contents = web_contents.get();
+      base::WeakPtr<content::WebContents> weak_contents =
+          web_contents->GetWeakPtr();
       chrome::ScopedTabbedBrowserDisplayer displayer(profile.get());
       const GURL& url = web_contents->GetLastCommittedURL();
+
       chrome::AddWebContents(displayer.browser(), nullptr,
                              std::move(web_contents), url,
                              WindowOpenDisposition::NEW_FOREGROUND_TAB,
                              blink::mojom::WindowFeatures());
+      if (!weak_contents) {
+        std::move(callback).Run(InstallOrLaunchWebAppResult::kUnknownError);
+        return;
+      }
       web_app::CreateWebAppFromManifest(
-          containing_contents, webapps::WebappInstallSource::MANAGEMENT_API,
+          weak_contents.get(), webapps::WebappInstallSource::MANAGEMENT_API,
           base::BindOnce(&OnWebAppInstallCompleted, std::move(callback)));
       return;
   }
diff --git a/chrome/browser/extensions/api/management/management_apitest.cc b/chrome/browser/extensions/api/management/management_apitest.cc
index 1fbb0c1c..3ce352d 100644
--- a/chrome/browser/extensions/api/management/management_apitest.cc
+++ b/chrome/browser/extensions/api/management/management_apitest.cc
@@ -40,6 +40,9 @@
 #endif
 
 #if BUILDFLAG(ENABLE_EXTENSIONS)
+#include "chrome/browser/apps/app_service/app_service_proxy.h"
+#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
+#include "chrome/browser/apps/intent_helper/preferred_apps_test_util.h"
 #include "chrome/browser/ui/browser.h"
 #include "chrome/browser/ui/browser_window/public/browser_window_interface_iterator.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
@@ -50,11 +53,15 @@
 #include "chrome/browser/web_applications/test/fake_web_app_ui_manager.h"
 #include "chrome/browser/web_applications/test/os_integration_test_override_impl.h"
 #include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
+#include "chrome/browser/web_applications/web_app_command_scheduler.h"
 #include "chrome/browser/web_applications/web_app_filter.h"
 #include "chrome/browser/web_applications/web_app_helpers.h"
 #include "chrome/browser/web_applications/web_app_provider.h"
 #include "chrome/browser/web_applications/web_app_registrar.h"
 #include "chrome/test/base/ui_test_utils.h"
+#include "components/services/app_service/public/cpp/app_launch_params.h"
+#include "content/public/common/content_features.h"
+#include "content/public/test/browser_test_utils.h"
 #endif
 
 static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -397,6 +404,59 @@
 
   RunInstallableWebAppTest(kAppManifest, kGoodWebAppURL, kGoodWebAppURL);
 }
+
+IN_PROC_BROWSER_TEST_F(InstallReplacementWebAppApiTest, CapturedNavigation) {
+  auto auto_accept_pwa_install_confirmation =
+      web_app::SetAutoAcceptPWAInstallConfirmationForTesting();
+
+  static constexpr char kAppBPath[] =
+      "/management/install_replacement_web_app/acceptable_web_app_standalone/"
+      "nested/index.html";
+
+  // Install App A with focus-existing.
+  // Scope will be derived from start_url, which covers kAppBPath.
+  const GURL appA_url = https_test_server_.GetURL(
+      "/management/install_replacement_web_app/acceptable_web_app_standalone/"
+      "index.html");
+  auto appA_info = web_app::WebAppInstallInfo::CreateForTesting(
+      appA_url, blink::mojom::DisplayMode::kStandalone,
+      web_app::mojom::UserDisplayMode::kStandalone,
+      blink::mojom::ManifestLaunchHandler_ClientMode::kFocusExisting);
+
+  webapps::AppId appA_id =
+      web_app::test::InstallWebApp(profile(), std::move(appA_info),
+                                   /*overwrite_existing_manifest_fields=*/true);
+
+  // Explicitly enable link capturing for App A via AppService to ensure it
+  // works on CrOS/AppService intent filtering.
+  apps_util::SetSupportedLinksPreferenceAndWait(profile(), appA_id);
+
+  // Use UrlLoadObserver to wait for the asynchronous app navigation to our
+  // start_url to complete. This handles the case where the window is created
+  // but initially loads about:blank.
+  ui_test_utils::UrlLoadObserver url_observer(appA_url);
+
+  // Launch App A window using AppServiceProxy to hit intent filters.
+  apps::AppServiceProxyFactory::GetForProfile(profile())->LaunchAppWithParams(
+      apps::AppLaunchParams(
+          appA_id, apps::LaunchContainer::kLaunchContainerWindow,
+          WindowOpenDisposition::NEW_WINDOW, apps::LaunchSource::kFromTest));
+
+  url_observer.Wait();
+
+  // We need a custom background script that expects an error because the
+  // navigation will be captured.
+  static constexpr char kExpectErrorScript[] =
+      R"(chrome.test.runWithUserGesture(function() {
+           chrome.management.installReplacementWebApp(function() {
+             chrome.test.assertLastError(
+                 'Failed to install the generated app.');
+             chrome.test.notifyPass();
+           });
+         });)";
+
+  RunTest(kManifest, kAppBPath, kExpectErrorScript, /*from_webstore=*/true);
+}
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS)
 
 // Tests actions on extensions when no management policy is in place.
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html
new file mode 100644
index 0000000..04d4bb8
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html
@@ -0,0 +1,9 @@
+<html>
+  <head>
+    <title>Standalone Capturing PWA</title>
+    <link rel="manifest" href="manifest.json"></link>
+  </head>
+  <body>
+    Standalone capturing PWA page.
+  </body>
+</html>
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/manifest.json b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/manifest.json
new file mode 100644
index 0000000..c59b7c6
--- /dev/null
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/management/management_apitest.cc b/chrome/browser/extensions/api/management/management_apitest.cc
index 1fbb0c1c..3ce352d 100644
--- a/chrome/browser/extensions/api/management/management_apitest.cc
+++ b/chrome/browser/extensions/api/management/management_apitest.cc
@@ -40,6 +40,9 @@
 #endif
 
 #if BUILDFLAG(ENABLE_EXTENSIONS)
+#include "chrome/browser/apps/app_service/app_service_proxy.h"
+#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
+#include "chrome/browser/apps/intent_helper/preferred_apps_test_util.h"
 #include "chrome/browser/ui/browser.h"
 #include "chrome/browser/ui/browser_window/public/browser_window_interface_iterator.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
@@ -50,11 +53,15 @@
 #include "chrome/browser/web_applications/test/fake_web_app_ui_manager.h"
 #include "chrome/browser/web_applications/test/os_integration_test_override_impl.h"
 #include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
+#include "chrome/browser/web_applications/web_app_command_scheduler.h"
 #include "chrome/browser/web_applications/web_app_filter.h"
 #include "chrome/browser/web_applications/web_app_helpers.h"
 #include "chrome/browser/web_applications/web_app_provider.h"
 #include "chrome/browser/web_applications/web_app_registrar.h"
 #include "chrome/test/base/ui_test_utils.h"
+#include "components/services/app_service/public/cpp/app_launch_params.h"
+#include "content/public/common/content_features.h"
+#include "content/public/test/browser_test_utils.h"
 #endif
 
 static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -397,6 +404,59 @@
 
   RunInstallableWebAppTest(kAppManifest, kGoodWebAppURL, kGoodWebAppURL);
 }
+
+IN_PROC_BROWSER_TEST_F(InstallReplacementWebAppApiTest, CapturedNavigation) {
+  auto auto_accept_pwa_install_confirmation =
+      web_app::SetAutoAcceptPWAInstallConfirmationForTesting();
+
+  static constexpr char kAppBPath[] =
+      "/management/install_replacement_web_app/acceptable_web_app_standalone/"
+      "nested/index.html";
+
+  // Install App A with focus-existing.
+  // Scope will be derived from start_url, which covers kAppBPath.
+  const GURL appA_url = https_test_server_.GetURL(
+      "/management/install_replacement_web_app/acceptable_web_app_standalone/"
+      "index.html");
+  auto appA_info = web_app::WebAppInstallInfo::CreateForTesting(
+      appA_url, blink::mojom::DisplayMode::kStandalone,
+      web_app::mojom::UserDisplayMode::kStandalone,
+      blink::mojom::ManifestLaunchHandler_ClientMode::kFocusExisting);
+
+  webapps::AppId appA_id =
+      web_app::test::InstallWebApp(profile(), std::move(appA_info),
+                                   /*overwrite_existing_manifest_fields=*/true);
+
+  // Explicitly enable link capturing for App A via AppService to ensure it
+  // works on CrOS/AppService intent filtering.
+  apps_util::SetSupportedLinksPreferenceAndWait(profile(), appA_id);
+
+  // Use UrlLoadObserver to wait for the asynchronous app navigation to our
+  // start_url to complete. This handles the case where the window is created
+  // but initially loads about:blank.
+  ui_test_utils::UrlLoadObserver url_observer(appA_url);
+
+  // Launch App A window using AppServiceProxy to hit intent filters.
+  apps::AppServiceProxyFactory::GetForProfile(profile())->LaunchAppWithParams(
+      apps::AppLaunchParams(
+          appA_id, apps::LaunchContainer::kLaunchContainerWindow,
+          WindowOpenDisposition::NEW_WINDOW, apps::LaunchSource::kFromTest));
+
+  url_observer.Wait();
+
+  // We need a custom background script that expects an error because the
+  // navigation will be captured.
+  static constexpr char kExpectErrorScript[] =
+      R"(chrome.test.runWithUserGesture(function() {
+           chrome.management.installReplacementWebApp(function() {
+             chrome.test.assertLastError(
+                 'Failed to install the generated app.');
+             chrome.test.notifyPass();
+           });
+         });)";
+
+  RunTest(kManifest, kAppBPath, kExpectErrorScript, /*from_webstore=*/true);
+}
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS)
 
 // Tests actions on extensions when no management policy is in place.
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html
new file mode 100644
index 0000000..04d4bb8
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html
@@ -0,0 +1,9 @@
+<html>
+  <head>
+    <title>Standalone Capturing PWA</title>
+    <link rel="manifest" href="manifest.json"></link>
+  </head>
+  <body>
+    Standalone capturing PWA page.
+  </body>
+</html>
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/manifest.json b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/manifest.json
new file mode 100644
index 0000000..c59b7c6
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/manifest.json
@@ -0,0 +1,15 @@
+{
+  "name": "Standalone Capturing PWA",
+  "icons": [
+   {
+      "src": "../acceptable_web_app/image-512px.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "start_url": "index.html",
+  "display": "standalone",
+  "launch_handler": {
+    "client_mode": "focus-existing"
+  }
+}
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/index.html b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/index.html
new file mode 100644
index 0000000..23b9d28
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/index.html
@@ -0,0 +1,9 @@
+<html>
+  <head>
+    <title>Nested Installable web app</title>
+    <link rel="manifest" href="manifest.json"></link>
+  </head>
+  <body>
+    Nested do-nothing page with a manifest inside standalone scope.
+  </body>
+</html>
diff --git a/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/manifest.json b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/manifest.json
new file mode 100644
index 0000000..60c5c3e8
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/nested/manifest.json
@@ -0,0 +1,12 @@
+{
+  "name": "Nested Installable web app",
+  "icons": [
+   {
+      "src": "../../acceptable_web_app/image-512px.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "start_url": "index.html",
+  "display": "standalone"
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Use-After-Free in Chrome Management API via PWA Navigation Capturing

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 exists in the browser process when the chrome.management.installReplacementWebApp API interacts with PWA Navigation Capturing. If the navigation is captured by an existing PWA window, the newly created WebContents is destroyed, but a stack-allocated raw pointer is still used to make a virtual method call. This could potentially allow an attacker to achieve arbitrary code execution in the unsandboxed browser process.

Affected files:

  • chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc
  • chrome/browser/ui/browser_tabstrip.cc
  • chrome/browser/ui/browser_navigator.cc
  • chrome/browser/ui/web_applications/web_app_dialog_utils.cc
  • chrome/browser/web_applications/web_app_provider.cc

Estimated timestamp from git blame: 2023-10-26

Description

A Use-After-Free (UAF) vulnerability exists in the browser process within OnWebAppInstallabilityChecked (located in chrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc). The issue occurs when the chrome.management.installReplacementWebApp extension API is used to install an app whose URL is subsequently intercepted by the PWA Navigation Capturing logic.

When OnWebAppInstallabilityChecked is called, it saves a raw pointer to a newly created WebContents onto the stack:

content::WebContents* containing_contents = web_contents.get();

Because this is a stack-local raw pointer, it is not protected by MiraclePtr (BackupRefPtr).

Ownership of the WebContents is then transferred to chrome::AddWebContents:

chrome::AddWebContents(displayer.browser(), nullptr, std::move(web_contents), ...);

Inside AddWebContents, the WebContents is moved into a NavigateParams object, and Navigate(&params) is called. During navigation, if the URL matches an installed PWA configured with navigation capturing (e.g., client_mode: focus-existing), web_app::NavigationCapturingProcess intercepts it and sets params.browser to the existing PWA window, returning a valid singleton_index for the existing tab.

Because singleton_index != -1, Navigate skips the block that inserts the newly created WebContents into the TabStripModel. When Navigate and AddWebContents return, the NavigateParams object goes out of scope, which synchronously destroys the newly created WebContents.

Execution then continues in OnWebAppInstallabilityChecked, where the dangling containing_contents pointer is passed to web_app::CreateWebAppFromManifest. This function calls WebAppProvider::GetForWebContents(web_contents), which immediately performs a virtual method call on the freed object:

Profile* profile = Profile::FromBrowserContext(web_contents->GetBrowserContext());

Because WebContentsImpl is a large object, PartitionAlloc frees it directly to the central allocator rather than the thread-local cache. This makes the memory immediately available for concurrent threads (such as the Mojo IPC thread) to reclaim and populate with attacker-controlled data, allowing hijacking of the virtual call.

Potential Steps to Reproduce

Note: These are suggested/potential steps based on static analysis, as our tooling agent does not yet have the ability to run code to verify a full exploit chain.

  1. An attacker publishes a malicious Chrome Web Store extension that requests the management permission and sets the replacement_web_app manifest key to an attacker-controlled URL.
  2. The attacker hosts a malicious PWA at that URL, configured with a launch_handler using client_mode: focus-existing or navigate-existing.
  3. The victim is convinced to install both the extension and the PWA.
  4. The victim has the malicious PWA open in a browser window.
  5. The malicious extension calls chrome.management.installReplacementWebApp().
  6. The browser process creates an empty WebContents and performs the installability check.
  7. When chrome::AddWebContents is called, the navigation is captured by the already-open PWA window, causing the new WebContents to be destroyed.
  8. Concurrently, the attacker uses the compromised renderer (e.g., via high-volume Mojo IPC messages) to spray the central allocator with objects matching the size class of WebContentsImpl, reclaiming the freed memory and placing a fake vtable pointer at offset 0.
  9. The UI thread dereferences the fake vtable during the GetBrowserContext() call, resulting in arbitrary code execution in the browser process.

Suggested Fix

Do not rely on a raw pointer that bypasses lifetime management. Instead of saving a raw content::WebContents*, capture a base::WeakPtr<content::WebContents> before moving the unique pointer:

base::WeakPtr<content::WebContents> weak_contents = web_contents->GetWeakPtr();
chrome::AddWebContents(...);

if (!weak_contents) {
  // The WebContents was destroyed during navigation (e.g., captured by an existing PWA).
  std::move(callback).Run(InstallOrLaunchWebAppResult::kUnknownError);
  return;
}

web_app::CreateWebAppFromManifest(
    weak_contents.get(), webapps::WebappInstallSource::MANAGEMENT_API,
    base::BindOnce(&OnWebAppInstallCompleted, std::move(callback)));

Alternatively, chrome::AddWebContents returns a pointer to the navigated or inserted WebContents. OnWebAppInstallabilityChecked could verify that the returned pointer matches the original WebContents before proceeding.

Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f


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