CVE-2026-13774
Overview
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 tochrome::AddWebContentsviastd::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_handlerclient_modeoffocus-existingplus 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.
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
- Set up a capturing PWA
On the victim profile, an already-installed PWA exists with
launch_handlerclient_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 viaweb_app::test::InstallWebAppandapps_util::SetSupportedLinksPreferenceAndWait). - Invoke the management API
An extension calls
chrome.management.installReplacementWebApp(the test drives this from a webstore extension with arunWithUserGesturebackground script), reaching the browser-process installability handler. - Hit the installable branch and move ownership
The installability check returns
InstallableCheckResult::kInstallable; the handler saves the raw pointercontaining_contents = web_contents.get()and transfers ownership viachrome::AddWebContents(..., std::move(web_contents), ...). - Trigger synchronous destruction
Because the navigation is captured by the existing PWA window,
chrome::AddWebContentsskips theTabStripModelinsertion and synchronously destroys the newWebContents, leavingcontaining_contentsdangling (per the commit message). - 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
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
| 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
Audit Directions
- Raw pointer aliasing a moved-out ownerSearch for
T* p = owner.get();followed bystd::move(owner)into a call that can destroy the object, then a later use ofp; convert such aliases tobase::WeakPtrand re-validate after the transfer. - Synchronous destruction inside sink APIsAudit callers of
chrome::AddWebContentsand 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 continuationsReview web-app install flows that create a
content::WebContentsand pass it across an asynchronous or ownership-transferring boundary before dereferencing it (e.g.web_app::CreateWebAppFromManifestcall sites), ensuring liveness is checked on every branch, including error/early-exit paths.
Patch
From 9a50771f1596ce24f099b92b097b4bdbc13d51a3 Mon Sep 17 00:00:00 2001 From: Dan Murphy <[email protected]> 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 <[email protected]> Reviewed-by: Reilly Grant <[email protected]> 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.