mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-20 20:48:39 +03:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user