CVE-2026-13774
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/extensions/api/management/management_apitest.cc |
modified |
Files Changed
chrome/browser/extensions/api/management/DEPSchrome/browser/extensions/api/management/chrome_management_api_delegate_non_android.ccchrome/browser/extensions/api/management/management_apitest.ccchrome/test/data/extensions/api_test/management/install_replacement_web_app/acceptable_web_app_standalone/index.html
Patch
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
Regression Test / PoC
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"
+}
Original Bug Report
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.ccchrome/browser/ui/browser_tabstrip.ccchrome/browser/ui/browser_navigator.ccchrome/browser/ui/web_applications/web_app_dialog_utils.ccchrome/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(¶ms) 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.
- An attacker publishes a malicious Chrome Web Store extension that requests the
managementpermission and sets thereplacement_web_appmanifest key to an attacker-controlled URL. - The attacker hosts a malicious PWA at that URL, configured with a
launch_handlerusingclient_mode: focus-existingornavigate-existing. - The victim is convinced to install both the extension and the PWA.
- The victim has the malicious PWA open in a browser window.
- The malicious extension calls
chrome.management.installReplacementWebApp(). - The browser process creates an empty
WebContentsand performs the installability check. - When
chrome::AddWebContentsis called, the navigation is captured by the already-open PWA window, causing the newWebContentsto be destroyed. - 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. - 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.