CVE-2026-10887
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
It2MeConfirmationDialogFactoryremoting/host/it2me/it2me_confirmation_dialog.h |
modified | |
ifremoting/host/it2me/it2me_confirmation_dialog_chromeos.cc |
modified |
Files Changed
remoting/host/chromeos/message_box.ccremoting/host/chromeos/message_box.hremoting/host/it2me/it2me_confirmation_dialog.hremoting/host/it2me/it2me_confirmation_dialog_chromeos.ccremoting/host/it2me/it2me_confirmation_dialog_chromeos.hremoting/host/it2me/it2me_confirmation_dialog_proxy.cc
Patch
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));
Regression Test / PoC
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
Original Bug Report
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.mmremoting/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:
- AppKit calls
-[NSApplication sendAction:to:from:], which dispatches to[ContinueWindowMacController onContinue:]. onContinue:calls_continue_window->ContinueSession().ContinueSession()(inremoting/host/continue_window.cc) synchronously callsHideUi().ContinueWindowMac::HideUi()clears the only strong reference to the controller:controller_ = nil;.- Because Objective-C ARC does not automatically retain
selffor the duration of an instance method, settingcontroller_ = nildrops the reference count to zero. The controller is immediately deallocated whileonContinue:is still executing. onContinue:returns. Control flows back to AppKit’ssendAction:to:from:mechanism.- AppKit internally references the action’s
target(the freed controller) orsender(the freedNSButton) 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
CGEventPosttask. 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.
- An attacker establishes a remote assistance (IT2Me) connection to a macOS host.
- The attacker tracks the session duration. Just before the 30-minute
kSessionExpirationTimeoutfires, the attacker sprays the macOS heap with controlled Objective-C object payloads via remote WebRTC messages. - The attacker sends a perfectly timed remote “Mouse Down” event aimed at the expected coordinates of the “Continue” button.
- The input message races the
SetDisableInputsIPC, successfully posting aCGEventPosttask to the UI thread just behind theShowUi()task. - The “Continue” dialog appears, and the queued click is immediately injected and processed by AppKit.
onContinue:executes,controller_ = nilis called, and the object is freed. The attacker’s heap spray reclaims the freed memory.- When
onContinue:returns, AppKit attempts to use the freedtargetpointer, invoking a virtual method on the attacker’s fake object and yielding Remote Code Execution (RCE) in theremote_assistance_hostprocess.
Suggested Fix
The destruction of the ContinueWindowMacController (or the underlying ContinueWindowMac C++ object) should not happen synchronously while the UI is handling an event.
- In
ContinueWindowMac::HideUi(), defer the destruction usingbase::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon()or aPostTaskto clear thecontroller_pointer. - Alternatively, use the
NS_VALID_UNTIL_END_OF_SCOPEmacro or a local__strongreference insideonContinue: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.