High watch CVSS 7.8 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in UI
DescriptionInsufficient validation of untrusted input in UI
ComponentUI
Sub-componentUI>Browser
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker504104263
Fix commit0e083d39ead6 (chromium/src) +106/-32
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-18 (per vendor advisory)
Fix landed2026-04-22 (public git)
Disclosed2026-06-02
Patch gap41 days — fix public in source before the advisory

Background

Named pipe squatting
on Windows, whoever creates a named pipe first owns that name, so a less-privileged local process that creates a pipe a privileged client expects to connect to can pose as the legitimate server.
`NamedPlatformChannel::Options`
the Mojo options struct used to create or connect to named pipes and sockets; this patch adds verify_server_privilege (Windows only, default false) and user-declared Options()/~Options(), which turns the struct into a non-aggregate. That is why many call sites change from designated initializers to field-by-field assignment.
Integrity level
the Windows mandatory-integrity label on a process token (Low/Medium/High/System); VerifyServerPrivilege compares the server’s and the client’s levels using base::win::AccessToken.
`OpenRemoteProcess`
an IPCz driver helper in invitation.cc that finds the peer of a connected named pipe and opens it with PROCESS_DUP_HANDLE so Mojo can duplicate handles into it.

Root Cause Analysis

Clients of privileged Chrome services on Windows (the updater’s update_service_dialer_win.cc, which already set allow_impersonation = true, and enterprise_companion_client.cc) connected to a well-known named pipe through named_mojo_ipc_server::ConnectToServer and trusted whichever process answered, without checking that the server was at least as privileged as the client. The client-endpoint creation path in named_platform_channel_win.cc returned the opened pipe handle as soon as it was valid.

The fix adds VerifyServerPrivilege, which resolves the server PID with GetNamedPipeServerProcessId, opens it with PROCESS_QUERY_LIMITED_INFORMATION, and requires server_token->IntegrityLevel() >= client_token->IntegrityLevel(). When options.verify_server_privilege is set, any failure returns an empty PlatformChannelEndpoint() instead of the connected handle, and the updater and enterprise-companion Windows dialers now set this flag. Separately, OpenRemoteProcess no longer relies on a caller-supplied remote_is_server role: it queries both GetNamedPipeClientProcessId and GetNamedPipeServerProcessId, takes whichever PID is not the current process, and rejects 0 or self. The removed code already returned an invalid process when the queried PID equalled the current PID, so this hunk makes role detection robust rather than closing an unchecked self-handle path. The commit itself describes the fix as hardening against a local privilege escalation in the updater and enterprise companion, while the advisory labels it “Insufficient validation of untrusted input in UI”.

Key insight
Privileged Windows clients treated “someone is listening on the expected pipe name” as proof that they were talking to the real service. The fix adds an opt-in server-integrity check at connect time and turns it on for the updater and enterprise-companion dialers, so a lower-integrity squatter’s endpoint is rejected before Mojo starts using it.

Attack Path

  1. Squat the pipe name A local, lower-integrity process creates the named pipe that the updater or enterprise companion server would normally publish (named via GetUpdateServiceServerName(scope) / GetUpdateServiceInternalServerName(scope) or the companion’s server_name), for example while the real server is not running (inferred timing).
  2. Privileged client connects A higher-integrity updater or enterprise-companion client calls ConnectToServer; before the patch the client-endpoint path returned the squatter’s pipe handle without checking who owns it.
  3. Mojo trusts the endpoint The client builds its Mojo invitation over the attacker-owned pipe. For the updater this connection also allows impersonation (allow_impersonation = true), so the squatting server can impersonate the client (inferred from the option name; the impersonation level is not shown).
  4. Privilege abuse The attacker serves forged responses to, or acts with the identity of, the privileged client, which the commit describes as local privilege escalation (inferred beyond what the diff demonstrates).

Impact Assessment

A local attacker who can already run code at lower integrity on a Windows machine could pose as the Chrome updater or enterprise-companion server to higher-privileged clients. This could give local privilege escalation into the client’s security context (inferred; the commit message describes it as LPE, but the diff shows only the missing check). It is not reachable from web content alone. The advisory rates it as high severity under “Insufficient validation of untrusted input in UI” and does not report exploitation in the wild; the component label does not match the patched code, which is Mojo and updater IPC.

Changed Functions

FunctionChangeNotes
if
chrome/updater/ipc/update_service_dialer_posix.cc
modified
if
chrome/updater/ipc/update_service_dialer_win.cc
modified
if
mojo/core/ipcz_driver/invitation.cc
modified
NamedPlatformChannel
mojo/public/cpp/platform/named_platform_channel.cc
modified
if
mojo/public/cpp/platform/named_platform_channel_win.cc
modified

Files Changed

  • chrome/enterprise_companion/enterprise_companion_client.cc
  • chrome/updater/ipc/update_service_dialer_posix.cc
  • chrome/updater/ipc/update_service_dialer_win.cc
  • chromeos/ash/experiences/arc/session/arc_session_impl.cc
  • components/named_mojo_ipc_server/named_mojo_ipc_server_client_util.cc
  • components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_linux.cc
  • components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_mac.cc
  • mojo/core/ipcz_driver/invitation.cc
  • mojo/public/cpp/platform/named_platform_channel.cc
  • mojo/public/cpp/platform/named_platform_channel.h
  • mojo/public/cpp/platform/named_platform_channel_win.cc

Audit Directions

  • Opt-in server authentication
    verify_server_privilege defaults to false; list every Windows NamedPlatformChannel/named_mojo_ipc_server client that runs at elevated or SYSTEM integrity (elevation service, other updater internal services, remoting host, native messaging hosts) and check whether it still connects without verifying the server.
  • Strength of the check
    VerifyServerPrivilege compares only integrity levels and resolves the server by PID, so check for same-integrity squatting by a different user, PID reuse between GetNamedPipeServerProcessId and OpenProcess, and whether the pipe DACL or FILE_FLAG_FIRST_PIPE_INSTANCE on the server side would be a stronger guard.
  • Peer-identity assumptions in IPC bootstrap
    Review other code that assumes the peer’s role or identity (client vs. server PID, remote_is_server-style flags, socket peer credentials on POSIX/macOS) before granting powerful access such as PROCESS_DUP_HANDLE or impersonation.
diff --git a/chrome/enterprise_companion/enterprise_companion_client.cc b/chrome/enterprise_companion/enterprise_companion_client.cc
index c572cf2..1a1ccd1 100644
--- a/chrome/enterprise_companion/enterprise_companion_client.cc
+++ b/chrome/enterprise_companion/enterprise_companion_client.cc
@@ -103,8 +103,16 @@
     return;
   }
 
+#if BUILDFLAG(IS_WIN)
+  mojo::NamedPlatformChannel::Options options;
+  options.server_name = server_name;
+  options.verify_server_privilege = true;
+  mojo::PlatformChannelEndpoint endpoint =
+      named_mojo_ipc_server::ConnectToServer(options);
+#else
   mojo::PlatformChannelEndpoint endpoint =
       named_mojo_ipc_server::ConnectToServer(server_name);
+#endif
   if (endpoint.is_valid()) {
     std::move(callback).Run(std::move(endpoint));
     return;
@@ -132,7 +140,14 @@
       FROM_HERE, {base::MayBlock()},
       base::BindOnce(
           [](const mojo::NamedPlatformChannel::ServerName& server_name) {
+#if BUILDFLAG(IS_WIN)
+            mojo::NamedPlatformChannel::Options options;
+            options.server_name = server_name;
+            options.verify_server_privilege = true;
+            return named_mojo_ipc_server::ConnectToServer(options);
+#else
             return named_mojo_ipc_server::ConnectToServer(server_name);
+#endif
           },
           server_name)
           .Then(base::BindPostTaskToCurrentDefault(
diff --git a/chrome/updater/ipc/update_service_dialer_posix.cc b/chrome/updater/ipc/update_service_dialer_posix.cc
index 9724c9f4..b5d2e56 100644
--- a/chrome/updater/ipc/update_service_dialer_posix.cc
+++ b/chrome/updater/ipc/update_service_dialer_posix.cc
@@ -45,10 +45,11 @@
       return std::nullopt;
     }
 
-    return named_mojo_ipc_server::ConnectToServer(
-        {.server_name = is_internal_service
-                            ? GetUpdateServiceInternalServerName(scope)
-                            : GetUpdateServiceServerName(scope)});
+    mojo::NamedPlatformChannel::Options options;
+    options.server_name = is_internal_service
+                              ? GetUpdateServiceInternalServerName(scope)
+                              : GetUpdateServiceServerName(scope);
+    return named_mojo_ipc_server::ConnectToServer(options);
   }();
 
   if (!endpoint) {
diff --git a/chrome/updater/ipc/update_service_dialer_win.cc b/chrome/updater/ipc/update_service_dialer_win.cc
index 87a924bb..5c5f06f 100644
--- a/chrome/updater/ipc/update_service_dialer_win.cc
+++ b/chrome/updater/ipc/update_service_dialer_win.cc
@@ -84,12 +84,13 @@
     }
 
     server = result;
-    return named_mojo_ipc_server::ConnectToServer({
-        .server_name = is_internal_service
-                           ? GetUpdateServiceInternalServerName(scope)
-                           : GetUpdateServiceServerName(scope),
-        .allow_impersonation = true,
-    });
+    mojo::NamedPlatformChannel::Options options;
+    options.server_name = is_internal_service
+                              ? GetUpdateServiceInternalServerName(scope)
+                              : GetUpdateServiceServerName(scope);
+    options.allow_impersonation = true;
+    options.verify_server_privilege = true;
+    return named_mojo_ipc_server::ConnectToServer(options);
   }();
 
   if (tries >= 1 && !endpoint) {
diff --git a/chromeos/ash/experiences/arc/session/arc_session_impl.cc b/chromeos/ash/experiences/arc/session/arc_session_impl.cc
index fdd52826..99658e4a 100644
--- a/chromeos/ash/experiences/arc/session/arc_session_impl.cc
+++ b/chromeos/ash/experiences/arc/session/arc_session_impl.cc
@@ -271,7 +271,9 @@
     }
   }
 
-  auto endpoint = mojo::NamedPlatformChannel({socket_path.value()});
+  mojo::NamedPlatformChannel::Options options;
+  options.server_name = socket_path.value();
+  auto endpoint = mojo::NamedPlatformChannel(options);
   // TODO(cmtm): use NamedPlatformChannel to bootstrap mojo connection after
   // libchrome uprev in android.
   base::ScopedFD socket_fd =
diff --git a/components/named_mojo_ipc_server/named_mojo_ipc_server_client_util.cc b/components/named_mojo_ipc_server/named_mojo_ipc_server_client_util.cc
index d3c3173..f4bcbf6 100644
--- a/components/named_mojo_ipc_server/named_mojo_ipc_server_client_util.cc
+++ b/components/named_mojo_ipc_server/named_mojo_ipc_server_client_util.cc
@@ -57,7 +57,9 @@
 // static
 mojo::PlatformChannelEndpoint ConnectToServer(
     const mojo::NamedPlatformChannel::ServerName& server_name) {
-  return ConnectToServer({.server_name = server_name});
+  mojo::NamedPlatformChannel::Options options;
+  options.server_name = server_name;
+  return ConnectToServer(options);
 }
 
 }  // namespace named_mojo_ipc_server
diff --git a/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_linux.cc b/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_linux.cc
index 1cfa150..c5cf0b7 100644
--- a/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_linux.cc
+++ b/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_linux.cc
@@ -106,8 +106,10 @@
 bool NamedMojoServerEndpointConnectorLinux::TryStart() {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
+  mojo::NamedPlatformChannel::Options options;
+  options.server_name = options_.server_name;
   mojo::PlatformChannelServerEndpoint server_endpoint =
-      mojo::NamedPlatformChannel({options_.server_name}).TakeServerEndpoint();
+      mojo::NamedPlatformChannel(options).TakeServerEndpoint();
   if (!server_endpoint.is_valid()) {
     return false;
   }
diff --git a/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_mac.cc b/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_mac.cc
index a6bb6519..1ed8928c 100644
--- a/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_mac.cc
+++ b/components/named_mojo_ipc_server/named_mojo_server_endpoint_connector_mac.cc
@@ -112,8 +112,10 @@
 bool NamedMojoServerEndpointConnectorMac::TryStart() {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
+  mojo::NamedPlatformChannel::Options options;
+  options.server_name = options_.server_name;
   mojo::PlatformChannelServerEndpoint server_endpoint =
-      mojo::NamedPlatformChannel({options_.server_name}).TakeServerEndpoint();
+      mojo::NamedPlatformChannel(options).TakeServerEndpoint();
   if (!server_endpoint.is_valid() ||
       !server_endpoint.platform_handle().is_valid_mach_receive()) {
     return false;
diff --git a/mojo/core/ipcz_driver/invitation.cc b/mojo/core/ipcz_driver/invitation.cc
index 9f808aef..69ec2887 100644
--- a/mojo/core/ipcz_driver/invitation.cc
+++ b/mojo/core/ipcz_driver/invitation.cc
@@ -109,31 +109,38 @@
 // given a handle to a connected named pipe. It may return an invalid process
 // object if either the handle does not refer to a named pipe or the handle
 // refers to a named pipe that is not connected.
-base::Process OpenRemoteProcess(const MojoInvitationTransportEndpoint& endpoint,
-                                bool remote_is_server) {
-  base::ProcessId remote_process_id = 0;
+base::Process OpenRemoteProcess(
+    const MojoInvitationTransportEndpoint& endpoint) {
   // Extract the handle to the connected named pipe from mojo invitation
   // transport endpoint.
   HANDLE handle =
       LongToHandle(static_cast<long>(endpoint.platform_handles[0].value));
-  auto get_remote_pid = remote_is_server ? &GetNamedPipeServerProcessId
-                                         : &GetNamedPipeClientProcessId;
-  // Try to get the remote client process id given the extracted handle via
-  // GetNamedPipe(Server|Client)ProcessId API.
-  if ((!get_remote_pid(handle, &remote_process_id) ||
-       remote_process_id == base::Process::Current().Pid())) {
-    DVLOG(2) << "Failed to get remote process id via the connected named pipe";
+
+  base::ProcessId client_pid = 0;
+  base::ProcessId server_pid = 0;
+  base::ProcessId current_pid = base::GetCurrentProcId();
+
+  if (!GetNamedPipeClientProcessId(handle, &client_pid)) {
+    PLOG(ERROR) << "GetNamedPipeClientProcessId failed";
+    return base::Process();
+  }
+  if (!GetNamedPipeServerProcessId(handle, &server_pid)) {
+    PLOG(ERROR) << "GetNamedPipeServerProcessId failed";
     return base::Process();
   }
 
-  if (remote_process_id == 0) {
-    DVLOG(2) << "Remote process id is invalid";
+  // The remote PID is whichever one isn't us.
+  base::ProcessId remote_pid =
+      (client_pid == current_pid) ? server_pid : client_pid;
+
+  if (remote_pid == 0 || remote_pid == current_pid) {
+    DLOG(ERROR) << "Could not identify remote process ID.";
     return base::Process();
   }
 
   // Try to open the remote process.
   base::Process remote_process =
-      base::Process::OpenWithAccess(remote_process_id, PROCESS_DUP_HANDLE);
+      base::Process::OpenWithAccess(remote_pid, PROCESS_DUP_HANDLE);
   if (!remote_process.IsValid()) {
     DVLOG(2) << "Remote process is invalid";
     return base::Process();
@@ -287,8 +294,7 @@
   // in such case, rely on the connected named pipe to get the remote process
   // id, then open and set the remote process.
   if (!remote_process.IsValid()) {
-    remote_process =
-        OpenRemoteProcess(*transport_endpoint, /* remote_is_server= */ false);
+    remote_process = OpenRemoteProcess(*transport_endpoint);
   }
 #endif
 
@@ -422,8 +428,7 @@
   base::Process remote_process;
 #if BUILDFLAG(IS_WIN)
   if (is_elevated) {
-    remote_process =
-        OpenRemoteProcess(*transport_endpoint, /* remote_is_server= */ true);
+    remote_process = OpenRemoteProcess(*transport_endpoint);
   }
 #endif
   if (remote_process.IsValid()) {
diff --git a/mojo/public/cpp/platform/named_platform_channel.cc b/mojo/public/cpp/platform/named_platform_channel.cc
index 078f8aab..e8edd10b 100644
--- a/mojo/public/cpp/platform/named_platform_channel.cc
+++ b/mojo/public/cpp/platform/named_platform_channel.cc
@@ -13,6 +13,9 @@
 const char NamedPlatformChannel::kNamedHandleSwitch[] =
     "mojo-named-platform-channel-pipe";
 
+NamedPlatformChannel::Options::Options() = default;
+NamedPlatformChannel::Options::~Options() = default;
+
 NamedPlatformChannel::NamedPlatformChannel(const Options& options) {
   server_endpoint_ = PlatformChannelServerEndpoint(
       CreateServerEndpoint(options, &server_name_));
@@ -45,7 +48,8 @@
 PlatformChannelEndpoint NamedPlatformChannel::ConnectToServer(
     const ServerName& server_name) {
   DCHECK(!server_name.empty());
-  Options options = {.server_name = server_name};
+  Options options;
+  options.server_name = server_name;
   return CreateClientEndpoint(options);
 }
 
diff --git a/mojo/public/cpp/platform/named_platform_channel.h b/mojo/public/cpp/platform/named_platform_channel.h
index 3633b29..74ccec4 100644
--- a/mojo/public/cpp/platform/named_platform_channel.h
+++ b/mojo/public/cpp/platform/named_platform_channel.h
@@ -40,6 +40,9 @@
 #endif
 
   struct COMPONENT_EXPORT(MOJO_CPP_PLATFORM) Options {
+    Options();
+    ~Options();
+
     // Specifies the name to use for the server. If empty, a random name is
     // generated.
     ServerName server_name;
@@ -64,6 +67,12 @@
     // Acceptable values are in the range 1 through PIPE_UNLIMITED_INSTANCES
     // (255).
     size_t max_clients = 1;
+
+    // If |true|, the client will verify that the server process is running
+    // at the same or higher integrity level as the client. This is useful
+    // to prevent named pipe squatting attacks, where a lower-privilege
+    // process attempts to impersonate a trusted server.
+    bool verify_server_privilege = false;
 #elif BUILDFLAG(IS_POSIX)
     // On POSIX, every new unnamed NamedPlatformChannel creates a server socket
     // with a random name. This controls the directory where that happens.
diff --git a/mojo/public/cpp/platform/named_platform_channel_win.cc b/mojo/public/cpp/platform/named_platform_channel_win.cc
index abf32b2..cf96b30 100644
--- a/mojo/public/cpp/platform/named_platform_channel_win.cc
+++ b/mojo/public/cpp/platform/named_platform_channel_win.cc
@@ -17,6 +17,7 @@
 #include "base/strings/strcat_win.h"
 #include "base/strings/string_number_conversions_win.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/win/access_token.h"
 #include "base/win/scoped_handle.h"
 #include "base/win/windows_version.h"
 
@@ -33,6 +34,28 @@
 constexpr wchar_t kDefaultSecurityDescriptor[] =
     L"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;OW)";
 
+bool VerifyServerPrivilege(HANDLE pipe_handle) {
+  DWORD pid = 0;
+  if (!GetNamedPipeServerProcessId(pipe_handle, &pid)) {
+    return false;
+  }
+
+  base::win::ScopedHandle process(
+      OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
+  if (!process.is_valid()) {
+    return false;
+  }
+
+  auto server_token = base::win::AccessToken::FromProcess(process.get());
+  auto client_token = base::win::AccessToken::FromCurrentProcess();
+
+  if (!server_token || !client_token) {
+    return false;
+  }
+
+  return server_token->IntegrityLevel() >= client_token->IntegrityLevel();
+}
+
 }  // namespace
 
 // static
@@ -133,6 +156,14 @@
   DPLOG_IF(ERROR, !handle.is_valid())
       << "Named pipe " << pipe_name
       << " could not be opened after WaitNamedPipe succeeded";
+
+  if (handle.is_valid() && options.verify_server_privilege) {
+    if (!VerifyServerPrivilege(handle.GetHandle().Get())) {
+      DLOG(ERROR) << "Server privilege check failed.";
+      return PlatformChannelEndpoint();
+    }
+  }
+
   return PlatformChannelEndpoint(std::move(handle));
 }
 
Loading diff…

Original Bug Report

reported by [email protected]

Potential LPE via Named Pipe Squatting and Mojo IPCz Handle Confusion in Google Updater

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 go/chrome-ai-generated-security-bugs-faq for more information.

Overview: The Google Updater on Windows connects to a predictable named pipe without verifying the server’s identity, allowing a local attacker to intercept the connection. Due to flaws in how Mojo IPCz handles isolated connections and queries process IDs, the attacker is trusted as a broker, bypassing handle decoding security checks. This allows the attacker to force the SYSTEM-privileged updater to close arbitrary handles.

Affected files:

  • chrome/enterprise_companion/enterprise_companion_client.cc
  • mojo/core/ipcz_driver/transport.cc
  • mojo/core/ipcz_driver/invitation.cc
  • mojo/public/cpp/platform/named_platform_channel_win.cc
  • chrome/updater/policy/policy_fetcher.cc

Estimated timestamp from git blame: 2025-12-18

Description

A potential local privilege escalation (LPE) vulnerability exists in the Google Updater on Windows. When the SYSTEM-privileged updater attempts to connect to the Chrome Enterprise Companion App (CECA), it does so over a named pipe with a predictable name and fails to verify the identity of the server. This allows a local, unprivileged attacker to squat the pipe and act as a malicious server.

Due to the way the updater establishes this connection (mojo::IsolatedConnection), Mojo IPCz classifies the connection as broker-to-broker. Furthermore, a logic error in OpenRemoteProcess causes the updater to query its own process ID instead of the attacker’s, leaving the remote process handle invalid. These conditions inadvertently bypass critical security checks in IPCz handle decoding, granting the attacker the ability to pass arbitrary handle values that the SYSTEM process will then close via base::win::ScopedHandle destruction. This provides a reliable Arbitrary CloseHandle primitive, which is a known vector for privilege escalation.

Technical Details

  1. Named Pipe Squatting: The updater connects to \\.\pipe\mojo.ChromeEnterpriseCompanionService via ConnectWithRetries(). The connection logic (mojo::NamedPlatformChannel::CreateClientEndpoint) uses SECURITY_ANONYMOUS to prevent impersonation but does not verify the identity (e.g., owner SID or process signature) of the server that created the pipe. Windows named pipes are first-come-first-served, so an unprivileged attacker can create this pipe before the legitimate CECA server does.

  2. Broker Trust via Isolated Connection: The updater wraps the connection in a mojo::IsolatedConnection. When this is passed down to IPCz via OutgoingInvitation::SendIsolated(), the MOJO_SEND_INVITATION_FLAG_ISOLATED flag causes Invitation::Send() to set the transport’s destination type to Transport::kBroker.

  3. Invalid Remote Process via Logic Flaw: Because IsolatedConnection does not explicitly provide the remote process, Invitation::Send() attempts to derive it by calling OpenRemoteProcess(*transport_endpoint, /* remote_is_server= */ false). Because remote_is_server is false, it calls GetNamedPipeClientProcessId. However, the updater is the client of this named pipe. It receives its own PID, detects the mismatch, and returns an invalid base::Process. Consequently, the transport is created with an invalid remote_process.

  4. Handle Verification Bypass: When the attacker sends an IPCz message, the handles are decoded in Transport::DecodeHandle(). The attacker can specify HandleOwner::kRecipient for a handle. The security check designed to prevent untrusted peers from sending kRecipient handles is:

    if (from_transport.destination_type() != Transport::kBroker &&
        !from_transport.is_peer_trusted() && !remote_process.is_current()) {
        return std::nullopt;
    }
    

    Because the destination type was set to Transport::kBroker (Step 2), this check is entirely bypassed.

  5. Arbitrary CloseHandle: Because the check is bypassed, DecodeHandle takes the attacker’s arbitrary 64-bit value, calls ::GetHandleInformation to verify the handle exists in the local (SYSTEM) process, and wraps it in a base::win::ScopedHandle (held by PlatformHandle). If the attacker intentionally crafts a malformed message so that Transport::DeserializeObject() fails, the PlatformHandle objects are destroyed. The destruction of the ScopedHandle invokes the Windows API ::CloseHandle() on the attacker-controlled value.

Potential Attack Steps

Note: Our tooling agent cannot run code, so these are suggested steps based on static analysis.

  1. As a non-admin user, create a named pipe at \\.\pipe\mojo.ChromeEnterpriseCompanionService.
  2. Wait for the scheduled task or invoke the COM method (IUpdater::FetchPolicies) to trigger the SYSTEM updater to connect to the pipe.
  3. Upon connection, send a serialized Mojo IPCz message (e.g., ConnectFromBrokerToBroker).
  4. Include handle data in the message with handle_owner = kRecipient and raw values corresponding to valid handles inside the SYSTEM process (e.g., small multiples of 4 like 0x4, 0x8, 0xC).
  5. Intentionally malform the rest of the message so that deserialization fails.
  6. The SYSTEM updater processes the message, wraps the handles, fails deserialization, and automatically closes the attacker-specified handles, disrupting the process or opening a window for LPE via handle re-use.

Suggested Fix

  1. Verify Server Identity: Modify named_mojo_ipc_server::ConnectToServer or mojo::NamedPlatformChannel::CreateClientEndpoint to verify the process ID, owner SID, or digital signature of the server process after successfully connecting to the named pipe.
  2. Fix PID Query Logic: Update OpenRemoteProcess in mojo/core/ipcz_driver/invitation.cc to ensure that when a client is attempting to verify a peer, it uses GetNamedPipeServerProcessId, and vice versa.
  3. Review Isolated Connection Trust: Re-evaluate whether IsolatedConnection should unconditionally set the destination_type to Transport::kBroker, especially when the remote process is unknown or unverified.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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
Uploaded PoC files