Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Chromoting
DescriptionUse after free in Chromoting
ComponentChromoting
Bug ClassUAF
Tracker505204771
Fix commit897153e8be5e (chromium/src) +203/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
It2MeConfirmationDialogFactory
remoting/host/it2me/it2me_confirmation_dialog.h
modified
if
remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc
modified

Files Changed

  • remoting/host/chromeos/message_box.cc
  • remoting/host/chromeos/message_box.h
  • remoting/host/it2me/it2me_confirmation_dialog.h
  • remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc
  • remoting/host/it2me/it2me_confirmation_dialog_chromeos.h
  • remoting/host/it2me/it2me_confirmation_dialog_proxy.cc
From 897153e8be5ef2716c5b12dbeda554ed16ec279c Mon Sep 17 00:00:00 2001
From: Joe Downing <[email protected]>
Date: Tue, 28 Apr 2026 22:47:39 -0700
Subject: [PATCH] Add input disabling and event draining to confirmation dialogs

This CL ensures that any pending input events are injected prior to
the continue dialog being shown. This change affects all platforms.
I have additional CLs staged which will disable input on the dialog
for ~1 second after it becomes visible.

Bug: 505204771, 505083297

Change-Id: I5318f9d60d78086c5b90cf527e4c6e57db0360ce
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7793507
Reviewed-by: Yuwei Huang <[email protected]>
Commit-Queue: Joe Downing <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1622243}
---

diff --git a/remoting/host/chromeos/message_box.cc b/remoting/host/chromeos/message_box.cc
index a2d335f..18df8b18 100644
--- a/remoting/host/chromeos/message_box.cc
+++ b/remoting/host/chromeos/message_box.cc
@@ -61,6 +61,8 @@
 
   void SetMessageLabel(const std::u16string& message_label);
 
+  void SetDisableInputs(bool disable);
+
   // Called by MessageBox when it is destroyed.
   void OnMessageBoxDestroyed();
 
@@ -174,6 +176,11 @@
   message_box_view_->SetMessageLabel(message_label);
 }
 
+void MessageBoxCore::SetDisableInputs(bool disable) {
+  SetButtonEnabled(ui::mojom::DialogButton::kOk, !disable);
+  SetButtonEnabled(ui::mojom::DialogButton::kCancel, !disable);
+}
+
 void MessageBoxCore::OnMessageBoxDestroyed() {
   DCHECK(message_box_);
   message_box_ = nullptr;
@@ -214,6 +221,10 @@
   core_->SetMessageLabel(message_label);
 }
 
+void MessageBox::SetDisableInputs(bool disable) {
+  core_->SetDisableInputs(disable);
+}
+
 views::DialogDelegate& MessageBox::GetDialogDelegate() {
   return CHECK_DEREF(core_->AsDialogDelegate());
 }
diff --git a/remoting/host/chromeos/message_box.h b/remoting/host/chromeos/message_box.h
index 6967502..09b7ae1 100644
--- a/remoting/host/chromeos/message_box.h
+++ b/remoting/host/chromeos/message_box.h
@@ -65,6 +65,8 @@
 
   void SetMessageLabel(const std::u16string& message_label);
 
+  void SetDisableInputs(bool disable);
+
   views::DialogDelegate& GetDialogDelegate();
 
   // Called by MessageBoxCore when it is about to be destroyed.
diff --git a/remoting/host/it2me/it2me_confirmation_dialog.h b/remoting/host/it2me/it2me_confirmation_dialog.h
index 1f3ece1..f35e112 100644
--- a/remoting/host/it2me/it2me_confirmation_dialog.h
+++ b/remoting/host/it2me/it2me_confirmation_dialog.h
@@ -33,6 +33,9 @@
   // |callback| will not be called if the dialog is destroyed.
   virtual void Show(const std::string& remote_user_email,
                     ResultCallback callback) = 0;
+
+  // Set whether the dialog's inputs are disabled.
+  virtual void SetDisableInputs(bool disable) {}
 };
 
 class It2MeConfirmationDialogFactory {
diff --git a/remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc b/remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc
index a0f0128..08306b6a 100644
--- a/remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc
+++ b/remoting/host/it2me/it2me_confirmation_dialog_chromeos.cc
@@ -206,6 +206,8 @@
 
   void ShowConfirmationDialog();
 
+  void SetDisableInputs(bool disable);
+
   views::DialogDelegate& GetDialogDelegate();
 
  private:
@@ -268,6 +270,10 @@
   }
 }
 
+void It2MeConfirmationDialogChromeOS::Core::SetDisableInputs(bool disable) {
+  message_box_->SetDisableInputs(disable);
+}
+
 void It2MeConfirmationDialogChromeOS::Core::OnConfirmationDialogResult(
     MessageBox::Result result) {
   std::move(callback_).Run(result == MessageBox::Result::OK ? Result::OK
@@ -346,12 +352,20 @@
         base::BindOnce(
             &It2MeConfirmationDialogChromeOS::OnConfirmationDialogResult,
             base::Unretained(this)));
+    core_->SetDisableInputs(inputs_disabled_);
     core_->ShowConfirmationDialog();
   } else {
     ShowConfirmationNotification(remote_user_email);
   }
 }
 
+void It2MeConfirmationDialogChromeOS::SetDisableInputs(bool disable) {
+  inputs_disabled_ = disable;
+  if (core_) {
+    core_->SetDisableInputs(disable);
+  }
+}
+
 void It2MeConfirmationDialogChromeOS::ShowConfirmationNotification(
     const std::string& remote_user_email) {
   message_center::RichNotificationData data;
diff --git a/remoting/host/it2me/it2me_confirmation_dialog_chromeos.h b/remoting/host/it2me/it2me_confirmation_dialog_chromeos.h
index 322380d..56a7260e 100644
--- a/remoting/host/it2me/it2me_confirmation_dialog_chromeos.h
+++ b/remoting/host/it2me/it2me_confirmation_dialog_chromeos.h
@@ -34,6 +34,7 @@
   // It2MeConfirmationDialog implementation.
   void Show(const std::string& remote_user_email,
             ResultCallback callback) override;
+  void SetDisableInputs(bool disable) override;
 
   views::DialogDelegate& GetDialogDelegateForTest();
 
@@ -54,6 +55,7 @@
   ResultCallback callback_;
   DialogStyle style_;
   base::TimeDelta auto_accept_timeout_;
+  bool inputs_disabled_ = false;
 };
 
 }  // namespace remoting
diff --git a/remoting/host/it2me/it2me_confirmation_dialog_proxy.cc b/remoting/host/it2me/it2me_confirmation_dialog_proxy.cc
index 35e5c0d..0dfb035 100644
--- a/remoting/host/it2me/it2me_confirmation_dialog_proxy.cc
+++ b/remoting/host/it2me/it2me_confirmation_dialog_proxy.cc
@@ -28,6 +28,10 @@
   // Shows the wrapped dialog. Must be called on the UI thread.
   void Show(const std::string& remote_user_email);
 
+  // Sets whether the wrapped dialog's inputs are disabled. Must be called on
+  // the UI thread.
+  void SetDisableInputs(bool disable);
+
   scoped_refptr<base::SingleThreadTaskRunner> ui_task_runner() {
     return ui_task_runner_;
   }
@@ -40,10 +44,22 @@
   // Reports the dialog result on the caller's thread.
   void ReportResult(It2MeConfirmationDialog::Result result);
 
+  // Shows the wrapped dialog. Must be called on the UI thread.
+  void ShowAfterDrain(const std::string& remote_user_email);
+
+  // Updates the wrapped dialog's inputs state based on |is_disabled_by_caller_|
+  // and |is_disabled_for_drain_|.
+  void UpdateDialogInputs();
+
   scoped_refptr<base::SingleThreadTaskRunner> ui_task_runner_;
   scoped_refptr<base::SingleThreadTaskRunner> caller_task_runner_;
   base::WeakPtr<It2MeConfirmationDialogProxy> parent_;
   std::unique_ptr<It2MeConfirmationDialog> dialog_;
+
+  bool is_disabled_by_caller_ = false;
+  bool is_disabled_for_drain_ = false;
+
+  base::WeakPtrFactory<Core> weak_factory_{this};
 };
 
 It2MeConfirmationDialogProxy::Core::Core(
@@ -64,10 +80,43 @@
     const std::string& remote_user_email) {
   DCHECK(ui_task_runner_->BelongsToCurrentThread());
 
+  // Set inputs to disabled before showing the dialog to avoid accidental
+  // clicks.
+  is_disabled_for_drain_ = true;
+  UpdateDialogInputs();
+
+  // Post a task to actually show the dialog. This allows any pending events in
+  // the queue to be processed before the dialog is shown.
+  ui_task_runner_->PostTask(
+      FROM_HERE,
+      base::BindOnce(&It2MeConfirmationDialogProxy::Core::ShowAfterDrain,
+                     weak_factory_.GetWeakPtr(), remote_user_email));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/remoting/host/it2me/it2me_confirmation_dialog_proxy_unittest.cc b/remoting/host/it2me/it2me_confirmation_dialog_proxy_unittest.cc
index e54f476..953c3a4 100644
--- a/remoting/host/it2me/it2me_confirmation_dialog_proxy_unittest.cc
+++ b/remoting/host/it2me/it2me_confirmation_dialog_proxy_unittest.cc
@@ -7,12 +7,14 @@
 #include <memory>
 
 #include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/memory/ref_counted.h"
 #include "base/run_loop.h"
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/task_environment.h"
 #include "base/threading/thread.h"
+#include "base/synchronization/waitable_event.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -38,6 +40,8 @@
     std::move(callback_).Run(result);
   }
 
+  bool inputs_disabled() const { return inputs_disabled_; }
+
   MOCK_METHOD(void, OnShow, ());
 
   // It2MeConfirmationDialog implementation.
@@ -46,13 +50,24 @@
     EXPECT_TRUE(callback_.is_null());
     EXPECT_TRUE(task_runner_->BelongsToCurrentThread());
     EXPECT_EQ(remote_user_email.compare(kTestEmailAddress), 0);
+    // At the moment Show() is called, inputs should have been disabled by
+    // the proxy's draining logic.
+    EXPECT_TRUE(inputs_disabled_);
     callback_ = std::move(callback);
     OnShow();
   }
 
+  void SetDisableInputs(bool disable) override {
+    inputs_disabled_ = disable;
+    OnSetDisableInputs(disable);
+  }
+
+  MOCK_METHOD1(OnSetDisableInputs, void(bool));
+
  private:
   scoped_refptr<base::SingleThreadTaskRunner> task_runner_;
   ResultCallback callback_;
+  bool inputs_disabled_ = false;
 };
 
 // Encapsulates a target for It2MeConfirmationDialog::ResultCallback.
@@ -130,6 +145,9 @@
   ResultCallbackTarget callback_target(main_task_runner());
 
   StubIt2MeConfirmationDialog* confirm_dialog = dialog();
+
+  EXPECT_CALL(*dialog(), OnSetDisableInputs(::testing::_))
+      .Times(::testing::AtLeast(2));
   EXPECT_CALL(*dialog(), OnShow())
       .WillOnce(InvokeWithoutArgs([confirm_dialog]() {
         confirm_dialog->ReportResult(It2MeConfirmationDialog::Result::CANCEL);
@@ -145,4 +163,98 @@
   Run();
 }
 
+TEST_F(It2MeConfirmationDialogProxyTest, DrainingAndDisabling) {
+  ResultCallbackTarget callback_target(main_task_runner());
+
+  StubIt2MeConfirmationDialog* confirm_dialog = dialog();
+
+  // Sequence of events we expect on the dialog thread:
+  // 1. OnSetDisableInputs(true)
+  // 2. OnShow()
+  // 3. OnSetDisableInputs(false)
+
+  ::testing::InSequence s;
+  EXPECT_CALL(*dialog(), OnSetDisableInputs(true));
+  EXPECT_CALL(*dialog(), OnShow());
+  EXPECT_CALL(*dialog(), OnSetDisableInputs(false))
+      .WillOnce(InvokeWithoutArgs([confirm_dialog]() {
+        confirm_dialog->ReportResult(It2MeConfirmationDialog::Result::OK);
+      }));
+
+  EXPECT_CALL(callback_target,
+              OnDialogResult(It2MeConfirmationDialog::Result::OK))
+      .WillOnce(
+          InvokeWithoutArgs(this, &It2MeConfirmationDialogProxyTest::Quit));
+
+  dialog_proxy()->Show(kTestEmailAddress, callback_target.MakeCallback());
+
+  Run();
+  }
+
+  TEST_F(It2MeConfirmationDialogProxyTest, EventDraining) {
+  ResultCallbackTarget callback_target(main_task_runner());
+  StubIt2MeConfirmationDialog* confirm_dialog = dialog();
+
+  // Setup expectations FIRST to avoid race conditions with background thread.
+  // We expect these calls on the dialog thread.
+  EXPECT_CALL(*dialog(), OnSetDisableInputs(true));
+  EXPECT_CALL(*dialog(), OnShow());
+  EXPECT_CALL(*dialog(), OnSetDisableInputs(false));
+
+  // Setup the default action for OnShow to report the result.
+  ON_CALL(*dialog(), OnShow())
+      .WillByDefault(InvokeWithoutArgs([confirm_dialog]() {
+        confirm_dialog->ReportResult(It2MeConfirmationDialog::Result::OK);
+      }));
+
+  // We expect this call on the main thread.
+  EXPECT_CALL(callback_target,
+              OnDialogResult(It2MeConfirmationDialog::Result::OK))
+      .WillOnce(
+          InvokeWithoutArgs(this, &It2MeConfirmationDialogProxyTest::Quit));
+
+  base::WaitableEvent blocker_event;
+
+  // We want to guarantee this exact order on the dialog thread:
+  // 1. blocker_task (blocks until signaled)
+  // 2. Core::Show (posted by proxy->Show)
+  // 3. simulated_input_task (posted by test)
+  // 4. Core::ShowAfterDrain (posted by Core::Show)
+
+  // 1. Post blocker.
+  dialog_task_runner()->PostTask(
+      FROM_HERE, base::BindOnce(
+                     [](base::WaitableEvent* event) {
+                       event->Wait();
+                     },
+                     base::Unretained(&blocker_event)));
+
+  // 2. Call Show(). This posts Core::Show to the dialog thread.
+  dialog_proxy()->Show(kTestEmailAddress, callback_target.MakeCallback());
+
+  // 3. Post a "simulated input" task to the dialog thread.
+  dialog_task_runner()->PostTask(
+      FROM_HERE, base::BindOnce(
+                     [](StubIt2MeConfirmationDialog* dialog) {
+                       // At this point, Core::Show should have executed,
+                       // calling SetDisableInputs(true) and posting
+                       // ShowAfterDrain.
+                       EXPECT_TRUE(dialog->inputs_disabled());
+                     },
+                     base::Unretained(confirm_dialog)));
+
+  // Now release the blocker. Tasks will run in the guaranteed order.
+  blocker_event.Signal();
+
+  Run();
+
+  // After the run loop quits, we need to ensure the dialog thread has
+  // finished its remaining work (like re-enabling inputs) before the
+  // test ends and mocks are destroyed.
+  base::RunLoop fencing_run_loop;
+  dialog_task_runner()->PostTaskAndReply(FROM_HERE, base::DoNothing(),
+                                         fencing_run_loop.QuitClosure());
+  fencing_run_loop.Run();
+}
+
 }  // namespace remoting
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in ContinueWindowMacController during Chrome Remote Desktop session

Flapjack, 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: A potential Use-After-Free (UAF) vulnerability exists in the macOS Chrome Remote Desktop (CRD) IT2Me host process. The issue occurs when the ContinueWindowMacController synchronously destroys its own instance while handling a button’s action message, leaving a dangling pointer that AppKit accesses after the method returns. A remote attacker could potentially exploit this by racing the session timeout to inject a mouse click, bypassing input restrictions and achieving Remote Code Execution.

Affected files:

  • remoting/host/continue_window_mac.mm
  • remoting/host/continue_window.cc

Estimated timestamp from git blame: 2023-06-12

Summary

A potential Use-After-Free (UAF) vulnerability exists in the macOS Chrome Remote Desktop (CRD) IT2Me host (remote_assistance_host). The vulnerability is triggered during the 30-minute session continuation prompt. The Objective-C controller responsible for the prompt synchronously destroys itself inside its own button handler. When the handler returns to the native AppKit event loop, AppKit attempts to access the freed target object, resulting in a UAF.

Vulnerability Details

In remoting/host/continue_window_mac.mm, the ContinueWindowMacController class handles the UI for the session expiration warning. When the “Continue” button is clicked, the following sequence occurs:

  1. AppKit calls -[NSApplication sendAction:to:from:], which dispatches to [ContinueWindowMacController onContinue:].
  2. onContinue: calls _continue_window->ContinueSession().
  3. ContinueSession() (in remoting/host/continue_window.cc) synchronously calls HideUi().
  4. ContinueWindowMac::HideUi() clears the only strong reference to the controller: controller_ = nil;.
  5. Because Objective-C ARC does not automatically retain self for the duration of an instance method, setting controller_ = nil drops the reference count to zero. The controller is immediately deallocated while onContinue: is still executing.
  6. onContinue: returns. Control flows back to AppKit’s sendAction:to:from: mechanism.
  7. AppKit internally references the action’s target (the freed controller) or sender (the freed NSButton) to clean up state or manage the responder chain, resulting in a Use-After-Free.

Bypassing Input Restrictions

The CRD host attempts to block remote input while the prompt is visible by calling client_session_control_->SetDisableInputs(true) when the 30-minute timer expires. However, this check can be bypassed due to a cross-thread race condition:

  • The 30-minute timer fires on the UI thread and posts an asynchronous task to the Host thread to disable inputs.
  • If a remote WebRTC input message arrives at the Host thread before the disable task is processed, it is accepted.
  • The Host thread then posts an input injection task (CGEventPost) back to the UI thread.
  • The UI thread shows the dialog, then immediately processes the queued CGEventPost task. The native macOS WindowServer then delivers the click to the newly visible “Continue” button.

Suggested Attacker Steps

Note: These are potential steps based on code analysis; our tooling agent cannot execute a working proof-of-concept.

  1. An attacker establishes a remote assistance (IT2Me) connection to a macOS host.
  2. The attacker tracks the session duration. Just before the 30-minute kSessionExpirationTimeout fires, the attacker sprays the macOS heap with controlled Objective-C object payloads via remote WebRTC messages.
  3. The attacker sends a perfectly timed remote “Mouse Down” event aimed at the expected coordinates of the “Continue” button.
  4. The input message races the SetDisableInputs IPC, successfully posting a CGEventPost task to the UI thread just behind the ShowUi() task.
  5. The “Continue” dialog appears, and the queued click is immediately injected and processed by AppKit.
  6. onContinue: executes, controller_ = nil is called, and the object is freed. The attacker’s heap spray reclaims the freed memory.
  7. When onContinue: returns, AppKit attempts to use the freed target pointer, invoking a virtual method on the attacker’s fake object and yielding Remote Code Execution (RCE) in the remote_assistance_host process.

Suggested Fix

The destruction of the ContinueWindowMacController (or the underlying ContinueWindowMac C++ object) should not happen synchronously while the UI is handling an event.

  1. In ContinueWindowMac::HideUi(), defer the destruction using base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon() or a PostTask to clear the controller_ pointer.
  2. Alternatively, use the NS_VALID_UNTIL_END_OF_SCOPE macro or a local __strong reference inside onContinue: to ensure the object survives the synchronous teardown until the method returns to AppKit.

Evaluated with Chrome root at commit: 4a3e9db74111a3c6c4b3acfd70050a05077cf27a


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