Harden os password (terminal windows and headless linux) anti brute force (#14985)

* fix(windows): terminal, preauth bruteforce

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): headless, preauth bruteforce

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): headless, OS login, minimal fix

Signed-off-by: fufesou <linlong1266@gmail.com>

* Terminal session, click-only

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor, logs

Signed-off-by: fufesou <linlong1266@gmail.com>

* harden os password, better scoped failure set

Signed-off-by: fufesou <linlong1266@gmail.com>

* harden os password, ip failure count

Signed-off-by: fufesou <linlong1266@gmail.com>

* Check prelogin before starting cm

Signed-off-by: fufesou <linlong1266@gmail.com>

* Isolate terminal OS login failure tracking

Terminal OS login no longer reads or updates the default RustDesk
per-IP failure bucket. It now uses only the OS credential policy, while
RustDesk password attempts keep using the existing LOGIN_FAILURES[0]
bucket.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-05-11 12:58:01 +08:00
committed by GitHub
parent 9c831dc59b
commit 0e4b91b8d7
4 changed files with 633 additions and 69 deletions

View File

@@ -1,3 +1,8 @@
#[cfg(target_os = "windows")]
use super::login_failure_check::try_acquire_os_credential_login_gate;
use super::login_failure_check::{
evaluate_os_credential_policy, record_os_credential_failure, FailureScope,
};
use super::{input_service::*, *};
#[cfg(feature = "unix-file-copy-paste")]
use crate::clipboard::try_empty_clipboard_files;
@@ -82,6 +87,9 @@ lazy_static::lazy_static! {
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
}
#[cfg(target_os = "windows")]
const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password.";
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
@@ -94,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
x == 0
}
#[cfg(target_os = "linux")]
fn should_check_linux_headless_os_auth_before_desktop_start(
is_headless_allowed: bool,
username: &str,
) -> bool {
is_headless_allowed
&& !username.trim().is_empty()
&& linux_desktop_manager::get_username().is_empty()
}
#[cfg(target_os = "linux")]
fn should_record_linux_headless_os_auth_failure(
is_headless_allowed: bool,
username: &str,
err_msg: &str,
) -> bool {
is_headless_allowed
&& !username.trim().is_empty()
&& err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool {
cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty()
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
lazy_static::lazy_static! {
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
@@ -1497,6 +1531,9 @@ impl Connection {
// Keep the connection alive so the client can continue with 2FA.
return true;
}
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
return keep_alive;
}
if !self.connect_port_forward_if_needed().await {
return false;
}
@@ -2376,33 +2413,6 @@ impl Connection {
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
}
self.terminal_service_id = terminal.service_id;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(msg) =
self.fill_terminal_user_token(&lr.os_login.username, &lr.os_login.password)
{
self.send_login_error(msg).await;
sleep(1.).await;
return false;
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(is_user) =
terminal_service::is_service_specified_user(&self.terminal_service_id)
{
if let Some(user_token) = &self.terminal_user_token {
let has_service_token =
user_token.to_terminal_service_token().is_some();
if is_user != has_service_token {
// This occurs when the service id (in the configuration) is manually changed by the user, causing a mismatch in validation.
log::error!("Terminal service user mismatch detected. The service ID may have been manually changed in the configuration, causing validation to fail.");
// No need to translate the following message, because it is in an abnormal case.
self.send_login_error("Terminal service user mismatch detected.")
.await;
sleep(1.).await;
return false;
}
}
}
}
Some(login_request::Union::PortForward(mut pf)) => {
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
@@ -2420,8 +2430,43 @@ impl Connection {
}
}
if !hbb_common::is_ip_str(&lr.username)
&& !hbb_common::is_domain_port_str(&lr.username)
&& lr.username != Config::get_id()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
.await;
return false;
}
#[cfg(target_os = "windows")]
if self.terminal
&& lr.os_login.username.trim().is_empty()
&& crate::platform::is_prelogin()
{
self.send_login_error(
"No active console user logged on, please connect and logon first.",
)
.await;
sleep(1.).await;
return false;
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
self.try_start_cm_ipc();
if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
self.try_start_cm_ipc();
}
#[cfg(target_os = "linux")]
if should_check_linux_headless_os_auth_before_desktop_start(
self.linux_headless_handle.is_headless_allowed,
&lr.os_login.username,
) {
let (_failure, res) = self.check_failure(0).await;
if !res {
return true;
}
}
#[cfg(not(target_os = "linux"))]
let err_msg = "".to_owned();
@@ -2433,6 +2478,18 @@ impl Connection {
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
{
#[cfg(target_os = "linux")]
if should_record_linux_headless_os_auth_failure(
self.linux_headless_handle.is_headless_allowed,
&lr.os_login.username,
&err_msg,
) {
let (failure, res) = self.check_failure(0).await;
if !res {
return true;
}
self.update_failure(failure, false, 0);
}
self.send_login_error(err_msg).await;
return true;
}
@@ -2461,17 +2518,16 @@ impl Connection {
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
&& is_logon();
if !hbb_common::is_ip_str(&lr.username)
&& !hbb_common::is_domain_port_str(&lr.username)
&& lr.username != Config::get_id()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
.await;
return false;
} else if (password::approve_mode() == ApproveMode::Click
&& !allow_logon_screen_password)
if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password)
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
{
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await
{
return keep_alive;
}
}
self.try_start_cm(lr.my_id, lr.my_name, false);
if hbb_common::get_version_number(&lr.version)
>= hbb_common::get_version_number("1.2.0")
@@ -2493,6 +2549,14 @@ impl Connection {
}
} else if lr.password.is_empty() {
if err_msg.is_empty() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
if let Some(keep_alive) =
self.prepare_terminal_login_for_authorization().await
{
return keep_alive;
}
}
self.try_start_cm(lr.my_id, lr.my_name, false);
} else {
self.send_login_error(
@@ -2506,7 +2570,7 @@ impl Connection {
return true;
}
if !self.validate_password(allow_logon_screen_password) {
self.update_failure(failure, false, 0);
self.update_failure_with_scope(failure, false, 0, FailureScope::Default);
self.check_update_temporary_password(false);
if err_msg.is_empty() {
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
@@ -2519,7 +2583,7 @@ impl Connection {
.await;
}
} else {
self.update_failure(failure, true, 0);
self.update_failure_with_scope(failure, true, 0, FailureScope::Default);
if err_msg.is_empty() {
#[cfg(target_os = "linux")]
self.linux_headless_handle.wait_desktop_cm_ready().await;
@@ -3484,16 +3548,16 @@ impl Connection {
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
None
} else {
Some("The user is not an administrator.")
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
}
}
Ok(Err(e)) => {
log::error!("Failed to check if the user is an administrator: {}", e);
Some("Failed to check if the user is an administrator.")
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
}
Err(e) => {
log::error!("Failed to get logon user token: {}", e);
Some("Incorrect username or password.")
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
}
}
}
@@ -3529,6 +3593,146 @@ impl Connection {
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
if !self.terminal || self.terminal_user_token.is_some() {
return None;
}
#[derive(Copy, Clone)]
enum TerminalAuthorizationMode {
OsLogin {
failure: ((i32, i32, i32), i32),
scope: FailureScope,
},
SessionUser,
}
let normalized_username = self.lr.os_login.username.trim().to_owned();
let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) {
// Check failure state
let failure_scope = FailureScope::TerminalOsLogin;
let (failure, res) = self.check_failure_with_scope(0, failure_scope).await;
if !res {
log::warn!(
"OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}",
self.ip,
self.inner.id(),
failure_scope
);
// Terminal OS login is sensitive. Close this connection instead of keeping it
// alive for retries on the same socket after a rate-limit block.
return Some(false);
}
TerminalAuthorizationMode::OsLogin {
failure,
scope: failure_scope,
}
} else {
TerminalAuthorizationMode::SessionUser
};
let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. });
let failure_scope = match auth_mode {
TerminalAuthorizationMode::OsLogin { scope, .. } => scope,
TerminalAuthorizationMode::SessionUser => FailureScope::Default,
};
let username = normalized_username;
let password = self.lr.os_login.password.clone();
let terminal_login_error = {
#[cfg(target_os = "windows")]
{
// Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks.
let _os_login_concurrency_guard = if is_terminal_os_login {
let guard = try_acquire_os_credential_login_gate();
if guard.is_err() {
log::warn!(
"OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}",
self.ip,
self.inner.id(),
failure_scope
);
self.send_login_error("Please try 1 minute later").await;
sleep(1.).await;
Self::post_alarm_audit(
AlarmAuditType::TerminalOsLoginConcurrency,
json!({
"ip": self.ip,
"id": self.lr.my_id.clone(),
"name": self.lr.my_name.clone(),
}),
);
return Some(false);
}
guard.ok()
} else {
None
};
self.fill_terminal_user_token(&username, &password)
}
#[cfg(not(target_os = "windows"))]
{
self.fill_terminal_user_token(&username, &password)
}
};
if let Some(msg) = terminal_login_error {
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
self.update_failure_with_scope(failure, false, 0, scope);
}
let auth_context = if is_terminal_os_login {
"OS credential login verification"
} else {
"Terminal session-user authorization"
};
log::warn!(
"{} failed: ip={} conn_id={} scope={:?} msg='{}'",
auth_context,
self.ip,
self.inner.id(),
failure_scope,
msg
);
self.send_login_error(msg).await;
sleep(1.).await;
return Some(false);
}
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
self.update_failure_with_scope(failure, true, 0, scope);
}
if let Some(is_user) =
terminal_service::is_service_specified_user(&self.terminal_service_id)
{
if let Some(user_token) = &self.terminal_user_token {
let has_service_token = user_token.to_terminal_service_token().is_some();
if is_user != has_service_token {
log::error!(
"Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.",
self.ip,
self.inner.id(),
is_user,
has_service_token
);
// No need to translate the following message, because it is in an abnormal case.
self.send_login_error("Terminal service user mismatch detected.")
.await;
sleep(1.).await;
return Some(false);
}
}
}
if is_terminal_os_login {
self.try_start_cm_ipc();
}
None
}
#[cfg(any(target_os = "android", target_os = "ios"))]
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
None
}
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
// Parsing an IPv4 address just returns None.
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
@@ -3555,18 +3759,37 @@ impl Connection {
Some((p64, p56, p48))
}
fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) {
fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
if cur.0 == time {
cur.1 += 1;
cur.2 += 1;
} else {
cur.0 = time;
cur.1 = 1;
cur.2 += 1;
}
cur
fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
if cur.0 == time {
cur.1 += 1;
cur.2 += 1;
} else {
cur.0 = time;
cur.1 = 1;
cur.2 += 1;
}
cur
}
fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) {
self.update_failure_with_scope(failure, remove, i, FailureScope::Default);
}
fn update_failure_with_scope(
&self,
(failure, time): ((i32, i32, i32), i32),
remove: bool,
i: usize,
scope: FailureScope,
) {
let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin);
if os_credential_scope {
if !remove {
record_os_credential_failure(scope);
}
return;
}
let map_mutex = &LOGIN_FAILURES[i];
if remove {
if failure.0 != 0 {
@@ -3587,14 +3810,15 @@ impl Connection {
let mut m = map_mutex.lock().unwrap();
for key in [p64, p56, p48] {
let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
m.insert(key, bump(cur, time));
m.insert(key, Self::bump_failure_entry(cur, time));
}
// Update full IP: bump from the *original* passed-in failure
m.insert(self.ip.clone(), bump(failure, time));
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
} else {
// Update full IP: bump from the *original* passed-in failure
// Re-read the full IP bucket in case another failed attempt updated it.
let mut m = map_mutex.lock().unwrap();
m.insert(self.ip.clone(), bump(failure, time));
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
}
}
@@ -3634,8 +3858,50 @@ impl Connection {
}
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
self.check_failure_with_scope(i, FailureScope::Default)
.await
}
async fn check_failure_with_scope(
&mut self,
i: usize,
scope: FailureScope,
) -> (((i32, i32, i32), i32), bool) {
let time = (get_time() / 60_000) as i32;
if matches!(scope, FailureScope::TerminalOsLogin) {
let decision = evaluate_os_credential_policy(scope, get_time());
let res = if decision.allowed {
true
} else {
log::warn!(
"OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'",
self.ip,
self.inner.id(),
i,
decision.login_error.as_deref().unwrap_or("")
);
if let Some(login_error) = decision.login_error {
// Rare branch and currently temporary response copy; translation can be added later if needed.
self.send_login_error(login_error).await;
}
if let Some(audit) = decision.audit {
// For OS blocked/backoff events, we currently emit one alarm report per blocked attempt.
// TODO: Add unified cumulative/aggregation fields across alarm producers.
Self::post_alarm_audit(
audit,
json!({
"ip": self.ip,
"id": self.lr.my_id.clone(),
"name": self.lr.my_name.clone(),
}),
);
}
false
};
return (((0, 0, 0), time), res);
}
// IPv6 addresses are cheap to make so we check prefix/netblock as well
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
@@ -5219,6 +5485,8 @@ pub enum AlarmAuditType {
// MultipleLoginsAttemptsWithinOneMinute = 4,
// MultipleLoginsAttemptsWithinOneHour = 5,
ExceedIPv6PrefixAttempts = 6,
TerminalOsLoginBackoff = 7,
TerminalOsLoginConcurrency = 8,
}
pub enum FileAuditType {