Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Contacts
DescriptionMemory corruption in Contacts
ComponentContacts
Sub-componentContacts
Bug ClassMemory Corruption
Tracker503583985
Fix commitbde9a1a8552d (chromium/src) +121/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/contacts/contacts_manager_impl_unittest.cc
modified

Files Changed

  • content/browser/contacts/contacts_manager_impl_unittest.cc
  • content/browser/contacts/contacts_manager_impl.cc
From bde9a1a8552de59b1b035f354e165ff1069a5253 Mon Sep 17 00:00:00 2001
From: Dan Murphy <dmurph@chromium.org>
Date: Tue, 15 Sep 2026 14:06:14 -0700
Subject: [PATCH] [contacts] Check user activation in the browser process

The Contacts API requires transient user activation to invoke
navigator.contacts.select(), but the activation check was previously
only enforced in the renderer before sending the Mojo IPC. A compromised
renderer could bypass Blink-side checks and directly call
ContactsManager::Select over Mojo to display the contacts picker UI
dialog without authentic user interaction.

This change mirrors the security check in the browser process
(ContactsManagerImpl::Select), following the pattern established in
commit 33ba921e9fc12 [1] for WebUSB, WebHID, and Web Serial. The browser
atomically verifies and consumes transient user activation on the active
primary main frame's FrameTreeNode before displaying the contacts picker
dialog, returning std::nullopt if activation is absent or expired.

[1]: https://chromium-review.git.corp.google.com/c/chromium/src/+/8210800

TAG=agy
CONV=ad31b8b9-fe64-4a51-968e-eedea8f090b2

Fixed: 503583985
Test: content_unittests --gtest_filter="ContactsManagerImplTest.*"
Change-Id: Iff0f7c5d8ad28a34a127fab0e6e79ace4ae79c6a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8399884
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698372}
---
diff --git a/content/browser/contacts/contacts_manager_impl.cc b/content/browser/contacts/contacts_manager_impl.cc
index 4238bde..7efcd3a 100644
--- a/content/browser/contacts/contacts_manager_impl.cc
+++ b/content/browser/contacts/contacts_manager_impl.cc
@@ -10,11 +10,14 @@
 
 #include "base/functional/callback.h"
 #include "build/build_config.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/public/browser/contacts_picker_properties.h"
 #include "mojo/public/cpp/bindings/self_owned_receiver.h"
 #include "services/metrics/public/cpp/metrics_utils.h"
 #include "services/metrics/public/cpp/ukm_builders.h"
+#include "third_party/blink/public/mojom/frame/user_activation_notification_type.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_update_types.mojom.h"
 
 #if BUILDFLAG(IS_ANDROID)
 #include "content/browser/contacts/contacts_provider_android.h"
@@ -93,8 +96,16 @@
                                  bool include_addresses,
                                  bool include_icons,
                                  SelectCallback mojom_callback) {
+  // Ensure the frame is active and in the primary main frame, and consume
+  // transient user activation to prevent compromised renderers from opening
+  // the contacts picker dialog without user intent.
   if (!render_frame_host().IsActive() ||
-      !render_frame_host().IsInPrimaryMainFrame()) {
+      !render_frame_host().IsInPrimaryMainFrame() ||
+      !FrameTreeNode::From(&render_frame_host())
+           ->UpdateUserActivationState(
+               blink::mojom::UserActivationUpdateType::
+                   kConsumeTransientActivation,
+               blink::mojom::UserActivationNotificationType::kNone)) {
     std::move(mojom_callback).Run(std::nullopt);
     return;
   }
diff --git a/content/browser/contacts/contacts_manager_impl_unittest.cc b/content/browser/contacts/contacts_manager_impl_unittest.cc
index 80df5ac5..85ff029b 100644
--- a/content/browser/contacts/contacts_manager_impl_unittest.cc
+++ b/content/browser/contacts/contacts_manager_impl_unittest.cc
@@ -72,6 +72,8 @@
 };
 
 TEST_F(ContactsManagerImplTest, SelectActivePrimaryMainFrame) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
   auto mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* mock_provider_ptr = mock_provider.get();
 
@@ -101,6 +103,109 @@
   EXPECT_EQ(future.Get()->at(0)->name->at(0), "John Doe");
 }
 
+TEST_F(ContactsManagerImplTest, SelectWithoutUserActivation) {
+  auto mock_provider = std::make_unique<MockContactsProvider>();
+  MockContactsProvider* mock_provider_ptr = mock_provider.get();
+
+  EXPECT_CALL(*mock_provider_ptr, Select).Times(0);
+
+  InitService(std::move(mock_provider));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future.GetCallback());
+
+  EXPECT_EQ(future.Get(), std::nullopt);
+}
+
+TEST_F(ContactsManagerImplTest, SelectConsumesUserActivation) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
+  auto mock_provider = std::make_unique<MockContactsProvider>();
+  MockContactsProvider* mock_provider_ptr = mock_provider.get();
+
+  std::vector<blink::mojom::ContactInfoPtr> expected_contacts;
+  auto contact = blink::mojom::ContactInfo::New();
+  contact->name = std::vector<std::string>{"John Doe"};
+  expected_contacts.push_back(std::move(contact));
+
+  EXPECT_CALL(*mock_provider_ptr, Select)
+      .WillOnce(base::test::RunOnceCallback<6>(std::move(expected_contacts),
+                                               /*percentage_shared=*/100,
+                                               ContactsPickerProperties()));
+
+  InitService(std::move(mock_provider));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future1;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future1.GetCallback());
+
+  ASSERT_TRUE(future1.Get().has_value());
+  EXPECT_EQ(future1.Get()->size(), 1u);
+  EXPECT_EQ(future1.Get()->at(0)->name->at(0), "John Doe");
+
+  // A second call without renewed user activation should be rejected because
+  // the previous call consumed transient user activation.
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future2;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future2.GetCallback());
+
+  EXPECT_EQ(future2.Get(), std::nullopt);
+
+  // Renewing user activation should allow subsequent selections to succeed.
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+  std::vector<blink::mojom::ContactInfoPtr> expected_contacts2;
+  auto contact2 = blink::mojom::ContactInfo::New();
+  contact2->name = std::vector<std::string>{"Jane Doe"};
+  expected_contacts2.push_back(std::move(contact2));
+
+  EXPECT_CALL(*mock_provider_ptr, Select)
+      .WillOnce(base::test::RunOnceCallback<6>(std::move(expected_contacts2),
+                                               /*percentage_shared=*/100,
+                                               ContactsPickerProperties()));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future3;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future3.GetCallback());
+
+  ASSERT_TRUE(future3.Get().has_value());
+  EXPECT_EQ(future3.Get()->size(), 1u);
+  EXPECT_EQ(future3.Get()->at(0)->name->at(0), "Jane Doe");
+}
+
+TEST_F(ContactsManagerImplTest, SelectNullProviderConsumesUserActivation) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
+  InitService(/*provider=*/nullptr);
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future.GetCallback());
+
+  EXPECT_EQ(future.Get(), std::nullopt);
+  EXPECT_FALSE(main_rfh()->HasTransientUserActivation());
+}
+
 TEST_F(ContactsManagerImplTest, SelectBFCachedFrame) {
   auto mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* mock_provider_ptr = mock_provider.get();
@@ -109,6 +214,8 @@
 
   InitService(std::move(mock_provider));
 
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
   // Put the frame into BFCache.
   static_cast<TestRenderFrameHost*>(main_rfh())->DidEnterBackForwardCache();
   EXPECT_TRUE(main_rfh()->IsInLifecycleState(
@@ -139,6 +246,8 @@
   navigation->Commit();
   subframe = navigation->GetFinalRenderFrameHost();
 
+  static_cast<TestRenderFrameHost*>(subframe)->SimulateUserActivation();
+
   mojo::Remote<blink::mojom::ContactsManager> subframe_remote;
   auto subframe_mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* subframe_mock_provider_ptr =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/contacts/contacts_manager_impl_unittest.cc b/content/browser/contacts/contacts_manager_impl_unittest.cc
index 80df5ac5..85ff029b 100644
--- a/content/browser/contacts/contacts_manager_impl_unittest.cc
+++ b/content/browser/contacts/contacts_manager_impl_unittest.cc
@@ -72,6 +72,8 @@
 };
 
 TEST_F(ContactsManagerImplTest, SelectActivePrimaryMainFrame) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
   auto mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* mock_provider_ptr = mock_provider.get();
 
@@ -101,6 +103,109 @@
   EXPECT_EQ(future.Get()->at(0)->name->at(0), "John Doe");
 }
 
+TEST_F(ContactsManagerImplTest, SelectWithoutUserActivation) {
+  auto mock_provider = std::make_unique<MockContactsProvider>();
+  MockContactsProvider* mock_provider_ptr = mock_provider.get();
+
+  EXPECT_CALL(*mock_provider_ptr, Select).Times(0);
+
+  InitService(std::move(mock_provider));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future.GetCallback());
+
+  EXPECT_EQ(future.Get(), std::nullopt);
+}
+
+TEST_F(ContactsManagerImplTest, SelectConsumesUserActivation) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
+  auto mock_provider = std::make_unique<MockContactsProvider>();
+  MockContactsProvider* mock_provider_ptr = mock_provider.get();
+
+  std::vector<blink::mojom::ContactInfoPtr> expected_contacts;
+  auto contact = blink::mojom::ContactInfo::New();
+  contact->name = std::vector<std::string>{"John Doe"};
+  expected_contacts.push_back(std::move(contact));
+
+  EXPECT_CALL(*mock_provider_ptr, Select)
+      .WillOnce(base::test::RunOnceCallback<6>(std::move(expected_contacts),
+                                               /*percentage_shared=*/100,
+                                               ContactsPickerProperties()));
+
+  InitService(std::move(mock_provider));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future1;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future1.GetCallback());
+
+  ASSERT_TRUE(future1.Get().has_value());
+  EXPECT_EQ(future1.Get()->size(), 1u);
+  EXPECT_EQ(future1.Get()->at(0)->name->at(0), "John Doe");
+
+  // A second call without renewed user activation should be rejected because
+  // the previous call consumed transient user activation.
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future2;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future2.GetCallback());
+
+  EXPECT_EQ(future2.Get(), std::nullopt);
+
+  // Renewing user activation should allow subsequent selections to succeed.
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+  std::vector<blink::mojom::ContactInfoPtr> expected_contacts2;
+  auto contact2 = blink::mojom::ContactInfo::New();
+  contact2->name = std::vector<std::string>{"Jane Doe"};
+  expected_contacts2.push_back(std::move(contact2));
+
+  EXPECT_CALL(*mock_provider_ptr, Select)
+      .WillOnce(base::test::RunOnceCallback<6>(std::move(expected_contacts2),
+                                               /*percentage_shared=*/100,
+                                               ContactsPickerProperties()));
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future3;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future3.GetCallback());
+
+  ASSERT_TRUE(future3.Get().has_value());
+  EXPECT_EQ(future3.Get()->size(), 1u);
+  EXPECT_EQ(future3.Get()->at(0)->name->at(0), "Jane Doe");
+}
+
+TEST_F(ContactsManagerImplTest, SelectNullProviderConsumesUserActivation) {
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
+  InitService(/*provider=*/nullptr);
+
+  base::test::TestFuture<
+      std::optional<std::vector<blink::mojom::ContactInfoPtr>>>
+      future;
+  remote()->Select(/*multiple=*/false, /*include_names=*/true,
+                   /*include_emails=*/false, /*include_tel=*/false,
+                   /*include_addresses=*/false, /*include_icons=*/false,
+                   future.GetCallback());
+
+  EXPECT_EQ(future.Get(), std::nullopt);
+  EXPECT_FALSE(main_rfh()->HasTransientUserActivation());
+}
+
 TEST_F(ContactsManagerImplTest, SelectBFCachedFrame) {
   auto mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* mock_provider_ptr = mock_provider.get();
@@ -109,6 +214,8 @@
 
   InitService(std::move(mock_provider));
 
+  static_cast<TestRenderFrameHost*>(main_rfh())->SimulateUserActivation();
+
   // Put the frame into BFCache.
   static_cast<TestRenderFrameHost*>(main_rfh())->DidEnterBackForwardCache();
   EXPECT_TRUE(main_rfh()->IsInLifecycleState(
@@ -139,6 +246,8 @@
   navigation->Commit();
   subframe = navigation->GetFinalRenderFrameHost();
 
+  static_cast<TestRenderFrameHost*>(subframe)->SimulateUserActivation();
+
   mojo::Remote<blink::mojom::ContactsManager> subframe_remote;
   auto subframe_mock_provider = std::make_unique<MockContactsProvider>();
   MockContactsProvider* subframe_mock_provider_ptr =
Loading diff…

Original Bug Report

reported by fe...@google.com

Systemic User Activation bypass in Contacts API via Mojo

Pasting the entirety of http://b/495429423 but bug is about the 1 specific instance in the title.

Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.

Overview: Several sensitive Mojo interfaces in the browser process fail to properly verify transient user activation before displaying UI choosers or pickers. A compromised renderer can bypass Blink’s security checks and directly invoke these interfaces to trigger dialogs without genuine user intent. When combined with an ‘Enter key holding’ social engineering attack, this can lead to unauthorized file system write access or hardware device access.

Affected files:

  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • content/browser/usb/web_usb_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/serial/serial_service.cc
  • content/browser/hid/hid_service.cc
  • chrome/browser/webshare/share_service_impl.cc
  • content/browser/contacts/contacts_manager_impl.cc
  • content/browser/presentation/presentation_service_impl.cc

Estimated timestamp from git blame: 2025-12-23

Summary

There is a potential systemic vulnerability across multiple browser-side Mojo interface implementations where transient user activation is either improperly verified or entirely unchecked before displaying sensitive UI elements (like file pickers and device choosers).

Chrome’s security architecture (User Activation v2) dictates that the browser process must be the authoritative source of truth for user activation. While Blink (the renderer) performs checks before sending these Mojo messages, a compromised renderer can bypass the Blink bindings and call the Mojo methods directly. Without robust browser-side enforcement, an attacker can forcefully display these dialogs and potentially exploit them via social engineering.

Technical Details

  1. File System Access API (Flawed Check): In content/browser/file_system_access/file_system_access_manager_impl.cc (FileSystemAccessManagerImpl::ChooseEntries), the code attempts to consume user activation:

    if (GetContentClient()->browser()->IsTransientActivationRequiredForShowFileOrDirectoryPicker(WebContents::FromRenderFrameHost(rfh))) {
      FrameTreeNode::From(rfh)->UpdateUserActivationState(
          blink::mojom::UserActivationUpdateType::kConsumeTransientActivation,
          blink::mojom::UserActivationNotificationType::kNone);
    }
    // Execution continues regardless of the return value...
    

    The method UpdateUserActivationState returns a bool indicating whether transient activation was actually present and successfully consumed. However, ChooseEntries completely ignores this return value and proceeds to show the file picker even if it evaluates to false.

  2. Hardware APIs & Other Pickers (Missing Checks): The browser-side implementations for the following APIs do not perform any transient user activation checks before initiating their respective device choosers, relying entirely on renderer-side enforcement:

    • WebUSB: WebUsbServiceImpl::GetPermission (content/browser/usb/web_usb_service_impl.cc)
    • WebBluetooth: WebBluetoothServiceImpl::RequestDeviceImpl (content/browser/bluetooth/web_bluetooth_service_impl.cc)
    • WebSerial: SerialService::RequestPort (content/browser/serial/serial_service.cc)
    • WebHID: HidService::RequestDevice (content/browser/hid/hid_service.cc)
    • Web Share: ShareServiceImpl::Share (chrome/browser/webshare/share_service_impl.cc)
    • Contacts: ContactsManagerImpl::Select (content/browser/contacts/contacts_manager_impl.cc)

Potential Attack Scenario

Note: The following steps are theoretical as the Flapjack LLM agent does not execute code, but they trace the logical flow of the vulnerability based on the codebase.

  1. Renderer Compromise & Social Engineering: An attacker compromises the sandboxed renderer process (e.g., via a V8 bug). The attacker’s webpage displays a lure (like a game) tricking the user into holding down the “Enter” key.
  2. Mojo Invocation: Bypassing Blink’s security checks, the compromised renderer directly sends a ChooseEntries Mojo message to the browser process. It specifies a SaveFilePickerOptions targeting the user’s Downloads folder with an attacker-controlled suggested_name (e.g., malware.bat).
  3. Browser-Side Bypass: The browser process receives the message. It calls UpdateUserActivationState(kConsumeTransientActivation). Since holding the Enter key does not constitute a valid, recent transient activation in the browser’s state, this returns false. However, because the code ignores the return value, the browser proceeds.
  4. Dialog Steals Focus: The native OS Save File Picker dialog appears and instantly intercepts window focus.
  5. Automatic Confirmation: Because the user is already holding the “Enter” key, the OS immediately registers an “Enter” keystroke on the newly focused dialog. This triggers the default “Save” action.
  6. Sandbox Escape / Unauthorized Access: The dialog closes, and the browser process grants the compromised renderer a valid FileSystemAccessFileHandle for malware.bat, allowing the attacker to silently write arbitrary payloads to the user’s disk. This identical pattern can be used to silently grant access to connected USB/Bluetooth/Serial hardware.

Recommendation

All sensitive Mojo methods that trigger browser-side choosers or pickers must strictly verify that transient user activation is present in the calling frame.

  • For ChooseEntries, the return value of UpdateUserActivationState(kConsumeTransientActivation) must be checked, and the request should be rejected (e.g., returning kPermissionDenied) if it is false.
  • For the hardware and sharing APIs, a similar browser-side check to consume transient user activation must be implemented before calling RunChooser or equivalent.

Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f


Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s 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
Links in the report