From 9df486a689dbee26ba9868c68131d6a627018fba Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 18:15:00 +0800 Subject: [PATCH] fix(ipc): harden local IPC authorization and portable-service bootstrap flow (#14671) * fix(ipc): harden ipc access Signed-off-by: fufesou * fix(ipc): full cmd path, comments, simple refactor Signed-off-by: fufesou * fix(ipc): portable service, ipc exit Signed-off-by: fufesou * fix(ipc): Remove unused logs Signed-off-by: fufesou * fix(ipc): Use SetEntriesInAclW instead of icacls Signed-off-by: fufesou * fix(ipc): Comments Signed-off-by: fufesou * fix(ipc): check is_reparse_point Signed-off-by: fufesou * fix(ipc): shmem name, no fallback Signed-off-by: fufesou * fix(ipc): Simple refactor Signed-off-by: fufesou * fix(ipc): better exit and clear Signed-off-by: fufesou * fix(ipc): portable service, better exit Signed-off-by: fufesou * fix(ipc): comments, id -u Signed-off-by: fufesou * fix: comments linux headless, rx desktop ready Signed-off-by: fufesou * fix(ipc): magic number Signed-off-by: fufesou * fix(ipc): update deps Signed-off-by: fufesou * Update Cargo.lock * Update Cargo.lock * fix(ipc): harden ipc, test `identity_unavailable` Signed-off-by: fufesou * fix(ipc): portable service, check dir of shmem Signed-off-by: fufesou * fix(ipc): macos, better check exe allowed Signed-off-by: fufesou * fix(ipc): update hbb_common Signed-off-by: fufesou * fix(ipc): update hbb_common Signed-off-by: fufesou * fix(ipc): harden ipc, better active uid for uinput Signed-off-by: fufesou * fix(ipc): harden portable service token validation Compare portable service IPC tokens in constant time and document the CSPRNG source used for one-time token generation. Clarify Windows IPC authorization comments around canonical path matching and partial peer identity lookup. Signed-off-by: fufesou * fix(ipc): simple refactor Signed-off-by: fufesou * fix(ipc): harden portable service token handling Generate the portable service IPC token directly from OsRng, keep token comparison in the IPC layer as a fixed-length byte-wise check, and document the malformed-frame behavior for protected service IPC. Signed-off-by: fufesou * fix(ipc): comments Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- Cargo.lock | 4 +- src/core_main.rs | 8 +- src/ipc.rs | 467 +++++++++++--- src/ipc/auth.rs | 1036 ++++++++++++++++++++++++++++++++ src/ipc/fs.rs | 951 +++++++++++++++++++++++++++++ src/platform/linux.rs | 51 ++ src/platform/windows.rs | 320 +++++++++- src/platform/windows/acl.rs | 903 ++++++++++++++++++++++++++++ src/server.rs | 10 +- src/server/connection.rs | 162 +++-- src/server/portable_service.rs | 790 +++++++++++++++++++++--- src/server/uinput.rs | 47 +- 12 files changed, 4500 insertions(+), 249 deletions(-) create mode 100644 src/ipc/auth.rs create mode 100644 src/ipc/fs.rs create mode 100644 src/platform/windows/acl.rs diff --git a/Cargo.lock b/Cargo.lock index febfd6b17..fe1f67cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5996,8 +5996,8 @@ dependencies = [ [[package]] name = "parity-tokio-ipc" -version = "0.7.3-5" -source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291" +version = "0.7.3-6" +source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01" dependencies = [ "futures", "libc", diff --git a/src/core_main.rs b/src/core_main.rs index e27091927..67a83a37e 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -146,7 +146,13 @@ pub fn core_main() -> Option> { crate::portable_service::client::set_quick_support(_is_quick_support); } let mut log_name = "".to_owned(); - if args.len() > 0 && args[0].starts_with("--") { + // Keep portable-service logs under a stable directory name. + let has_portable_service_shmem_arg = args + .iter() + .any(|arg| arg.starts_with("--portable-service-shmem-name=")); + if has_portable_service_shmem_arg { + log_name = "portable-service".to_owned(); + } else if args.len() > 0 && args[0].starts_with("--") { let name = args[0].replace("--", ""); if !name.is_empty() { log_name = name; diff --git a/src/ipc.rs b/src/ipc.rs index 82b52a60c..0258a2816 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,33 +1,28 @@ -use crate::{ - common::CheckTestNatType, - privacy_mode::PrivacyModeState, - ui_interface::{get_local_option, set_local_option}, -}; -use bytes::Bytes; -use parity_tokio_ipc::{ - Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, -}; -use serde_derive::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - sync::atomic::{AtomicBool, Ordering}, -}; -#[cfg(not(windows))] -use std::{fs::File, io::prelude::*}; +#[path = "ipc/auth.rs"] +mod ipc_auth; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[path = "ipc/fs.rs"] +mod ipc_fs; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::plugin::ipc::Plugin; +use crate::{ + common::{is_server, CheckTestNatType}, + privacy_mode, + privacy_mode::PrivacyModeState, + rendezvous_mediator::RendezvousMediator, + ui_interface::{get_local_option, set_local_option}, +}; +use bytes::Bytes; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use clipboard::ClipboardFile; +#[cfg(target_os = "linux")] +use hbb_common::anyhow; use hbb_common::{ allow_err, bail, bytes, bytes_codec::BytesCodec, - config::{ - self, - keys::{self, OPTION_ALLOW_WEBSOCKET}, - Config, Config2, - }, + config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2}, futures::StreamExt as _, futures_util::sink::SinkExt, log, password_security as password, timeout, @@ -38,13 +33,55 @@ use hbb_common::{ tokio_util::codec::Framed, ResultType, }; - -use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_auth::authorize_service_scoped_ipc_connection; +#[cfg(windows)] +pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; +#[cfg(windows)] +pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; +#[cfg(windows)] +pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; +#[cfg(target_os = "linux")] +pub(crate) use ipc_auth::{ + active_uid, ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, + log_rejected_uinput_connection, peer_uid_from_fd, +}; +#[cfg(windows)] +use ipc_auth::{ + authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, + should_allow_everyone_create_on_windows, +}; +#[cfg(target_os = "linux")] +use ipc_fs::terminal_count_candidate_uids; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_fs::{ + check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, + should_scrub_parent_entries_after_check_pid, write_pid, +}; +use parity_tokio_ipc::{ + Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, +}; +use serde_derive::{Deserialize, Serialize}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::fs::PermissionsExt; +use std::{ + collections::HashMap, + sync::atomic::{AtomicBool, Ordering}, +}; // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; +const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; +pub(crate) const IPC_TOKEN_LEN: usize = 64; +const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; +const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); +#[inline] +pub async fn connect_service(ms_timeout: u64) -> ResultType> { + connect(ms_timeout, crate::POSTFIX_SERVICE).await +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum FS { @@ -207,6 +244,8 @@ pub enum DataControl { pub enum DataPortableService { Ping, Pong, + AuthToken(String), + AuthResult(bool), ConnCount(Option), Mouse((Vec, i32, String, u32, bool, bool)), Pointer((Vec, i32)), @@ -411,6 +450,22 @@ pub async fn start(postfix: &str) -> ResultType<()> { Ok(stream) => { let mut stream = Connection::new(stream); let postfix = postfix.to_owned(); + #[cfg(any(target_os = "linux", target_os = "macos"))] + if config::is_service_ipc_postfix(&postfix) { + if !authorize_service_scoped_ipc_connection(&stream, &postfix) { + continue; + } + } + #[cfg(windows)] + if postfix.is_empty() { + // Windows main IPC (`postfix == ""`) is authorized here. + // Other security-sensitive channels use dedicated authorization paths: + // - `_portable_service`: portable-service listener + handshake policy + // - service-scoped postfixes: service-specific listener/authorization + if !authorize_windows_main_ipc_connection(&stream, &postfix) { + continue; + } + } tokio::spawn(async move { loop { match stream.next().await { @@ -419,9 +474,48 @@ pub async fn start(postfix: &str) -> ResultType<()> { break; } Ok(Some(data)) => { + // On Linux/macOS, the protected `_service` channel is used only for + // syncing config between root service and the active user process. + // + // NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those + // channels are handled by the dedicated uinput listener/protocol in + // `src/server/uinput.rs` and therefore do not share this Data enum + // allowlist. The SyncConfig allowlist here is intentionally scoped to the + // `_service` channel only. + // + // Keep this explicit branch to avoid policy drift between `_service` and + // uinput IPC paths while still minimizing exposed message surface here. + #[cfg(any(target_os = "linux", target_os = "macos"))] + if postfix == crate::POSTFIX_SERVICE { + if matches!(&data, Data::SyncConfig(_)) { + handle(data, &mut stream).await; + } else { + log::warn!( + "Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}", + postfix, + std::mem::discriminant(&data), + stream.peer_uid() + ); + // Close the connection to avoid keeping a protected channel + // alive while repeatedly receiving invalid traffic. + break; + } + continue; + } handle(data, &mut stream).await; } - _ => {} + Ok(None) => { + // `Ok(None)` means a complete frame arrived but did not + // deserialize into `Data`. Peer close/reset is returned as + // `Err` by `ConnectionTmpl::next()`. Keep the historical + // ignore behavior except on the protected `_service` channel. + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + if postfix == crate::POSTFIX_SERVICE { + break; + } + } + } } } }); @@ -436,20 +530,77 @@ pub async fn start(postfix: &str) -> ResultType<()> { pub async fn new_listener(postfix: &str) -> ResultType { let path = Config::ipc_path(postfix); - #[cfg(not(any(windows, target_os = "android", target_os = "ios")))] - check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let existing_listener_alive = check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + if should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries, + existing_listener_alive, + ) { + scrub_secure_ipc_parent_dir(&path, postfix)?; + } let mut endpoint = Endpoint::new(path.clone()); - match SecurityAttributes::allow_everyone_create() { + let security_attrs = { + #[cfg(windows)] + { + if postfix == "_portable_service" { + portable_service_listener_security_attributes() + } else if should_allow_everyone_create_on_windows(postfix) { + SecurityAttributes::allow_everyone_create() + } else { + Ok(SecurityAttributes::empty()) + } + } + #[cfg(not(windows))] + { + SecurityAttributes::allow_everyone_create() + } + }; + match security_attrs { Ok(attr) => endpoint.set_security_attributes(attr), - Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err), + Err(err) => { + log::error!("Failed to set ipc{} security: {}", postfix, err); + #[cfg(windows)] + if postfix == "_portable_service" { + // Fail closed for `_portable_service` when SDDL construction fails. + // This endpoint is security-critical and must not start with default ACLs. + return Err(err.into()); + } + } }; match endpoint.incoming() { Ok(incoming) => { - log::info!("Started ipc{} server at path: {}", postfix, &path); - #[cfg(not(windows))] + if postfix == crate::POSTFIX_SERVICE { + log::info!("Started protected ipc service server: postfix={}", postfix); + } else { + log::info!("Started ipc{} server at path: {}", postfix, &path); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); + // NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable + // (0666) so the active (non-root) user process can connect. Authorization is + // enforced at accept-time for these channels, and the protected `_service` + // channel is further restricted by an explicit message allowlist (SyncConfig + // only). + let socket_mode = if config::is_service_ipc_postfix(postfix) { + 0o0666 + } else { + 0o0600 + }; + if let Err(err) = + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode)) + { + log::error!( + "Failed to set permissions on ipc{} socket at path {}: {}", + postfix, + &path, + err + ); + std::fs::remove_file(&path).ok(); + return Err(err.into()); + } write_pid(postfix); } Ok(incoming) @@ -953,15 +1104,116 @@ async fn handle(data: Data, stream: &mut Connection) { ); } _ => {} - } + }; } pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { let path = Config::ipc_path(postfix); - let client = timeout(ms_timeout, Endpoint::connect(&path)).await??; + connect_with_path(ms_timeout, &path).await +} + +pub(crate) fn generate_one_time_ipc_token() -> ResultType { + use hbb_common::rand::{rngs::OsRng, RngCore as _}; + use std::fmt::Write as _; + + let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut random_bytes).map_err(|err| { + hbb_common::anyhow::anyhow!( + "failed to generate portable service ipc token from OsRng: {}", + err + ) + })?; + + let mut token = String::with_capacity(IPC_TOKEN_LEN); + for byte in random_bytes { + let _ = write!(token, "{:02x}", byte); + } + Ok(token) +} + +pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { + if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { + return false; + } + expected + .as_bytes() + .iter() + .zip(candidate.as_bytes().iter()) + .fold(0u8, |diff, (left, right)| diff | (*left ^ *right)) + == 0 +} + +pub(crate) async fn portable_service_ipc_handshake_as_client( + stream: &mut ConnectionTmpl, + token: &str, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, +{ + stream + .send(&Data::DataPortableService(DataPortableService::AuthToken( + token.to_owned(), + ))) + .await?; + match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()), + Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => { + bail!("portable service ipc handshake was rejected by server") + } + Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"), + } +} + +pub(crate) async fn portable_service_ipc_handshake_as_server( + stream: &mut ConnectionTmpl, + mut validate_token: F, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, + // Token validators must use `constant_time_ipc_token_eq` or an equivalent + // fixed-length comparison; this handshake is part of the privilege boundary. + F: FnMut(&str) -> bool, +{ + let authorized = match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => { + validate_token(&token) + } + Some(_) | None => false, + }; + stream + .send(&Data::DataPortableService(DataPortableService::AuthResult( + authorized, + ))) + .await?; + if !authorized { + bail!("portable service ipc handshake failed") + } + Ok(()) +} + +#[inline] +async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType> { + let client = timeout(ms_timeout, Endpoint::connect(path)).await??; Ok(ConnectionTmpl::new(client)) } +#[cfg(target_os = "linux")] +pub async fn connect_for_uid( + ms_timeout: u64, + uid: u32, + postfix: &str, +) -> ResultType> { + let path = Config::ipc_path_for_uid(uid, postfix); + connect_with_path(ms_timeout, &path).await +} + #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn start_pa() { @@ -1039,54 +1291,6 @@ pub async fn start_pa() { } } -#[inline] -#[cfg(not(windows))] -fn get_pid_file(postfix: &str) -> String { - let path = Config::ipc_path(postfix); - format!("{}.pid", path) -} - -#[cfg(not(any(windows, target_os = "android", target_os = "ios")))] -async fn check_pid(postfix: &str) { - let pid_file = get_pid_file(postfix); - if let Ok(mut file) = File::open(&pid_file) { - let mut content = String::new(); - file.read_to_string(&mut content).ok(); - let pid = content.parse::().unwrap_or(0); - if pid > 0 { - use hbb_common::sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(); - if let Some(p) = sys.process(pid.into()) { - if let Some(current) = sys.process((std::process::id() as usize).into()) { - if current.name() == p.name() { - // double check with connect - if connect(1000, postfix).await.is_ok() { - return; - } - } - } - } - } - } - // if not remove old ipc file, the new ipc creation will fail - // if we remove a ipc file, but the old ipc process is still running, - // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive - std::fs::remove_file(&Config::ipc_path(postfix)).ok(); -} - -#[inline] -#[cfg(not(windows))] -fn write_pid(postfix: &str) { - let path = get_pid_file(postfix); - if let Ok(mut file) = File::create(&path) { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); - file.write_all(&std::process::id().to_string().into_bytes()) - .ok(); - } -} - pub struct ConnectionTmpl { inner: Framed, } @@ -1550,9 +1754,10 @@ pub fn close_all_instances() -> ResultType { } } +#[cfg(windows)] #[tokio::main(flavor = "current_thread")] pub async fn connect_to_user_session(usid: Option) -> ResultType<()> { - let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?; + let mut stream = crate::ipc::connect_service(1000).await?; timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??; Ok(()) } @@ -1678,13 +1883,76 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> { #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn get_terminal_session_count() -> ResultType { - let ms_timeout = 1_000; - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::TerminalSessionCount(0)).await?; - if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? { - return Ok(c); + let timeout_ms = 1_000; + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let candidate_uids = terminal_count_candidate_uids(effective_uid); + let mut last_err: Option = None; + for candidate_uid in candidate_uids { + let socket_path = Config::ipc_path_for_uid(candidate_uid, ""); + let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path)) + .await + .map_err(|err| { + anyhow::anyhow!( + "Timeout connecting to terminal ipc at {}: {}", + socket_path, + err + ) + }); + let connection = match connect_result { + Ok(Ok(connection)) => connection, + Ok(Err(err)) => { + last_err = Some(anyhow::anyhow!( + "Failed to connect to terminal ipc at {}: {}", + socket_path, + err + )); + continue; + } + Err(err) => { + last_err = Some(err); + continue; + } + }; + let mut ipc_conn = ConnectionTmpl::new(connection); + if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await { + last_err = Some(anyhow::anyhow!( + "Failed to request terminal session count via ipc at {}: {}", + socket_path, + err + )); + continue; + } + match ipc_conn.next_timeout(timeout_ms).await { + Ok(Some(Data::TerminalSessionCount(session_count))) => { + return Ok(session_count); + } + Ok(None) => { + last_err = Some(anyhow::anyhow!( + "Invalid response when requesting terminal session count via ipc at {}", + socket_path + )); + } + Ok(other) => { + last_err = Some(anyhow::anyhow!( + "Unexpected response when requesting terminal session count via ipc at {}: {:?}", + socket_path, + other.map(|v| std::mem::discriminant(&v)) + )); + } + Err(err) => { + last_err = Some(anyhow::anyhow!( + "Failed to read terminal session count via ipc at {}: {}", + socket_path, + err + )); + } + } + } + if let Some(err) = last_err { + Err(err.into()) + } else { + Ok(0) } - Ok(0) } async fn handle_wayland_screencast_restore_token( @@ -1715,9 +1983,30 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> { #[cfg(test)] mod test { use super::*; + #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); assert!(std::mem::size_of::() <= 120); } + + #[cfg(target_os = "linux")] + #[test] + fn test_ipc_path_differs_by_uid_for_cm() { + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let other_uid = effective_uid.saturating_add(1); + let postfix = "_cm"; + + // Default connect path targets the current effective uid. + assert_eq!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(effective_uid, postfix) + ); + // A different uid yields a different socket path - this is the root cause of the + // cross-user regression when root spawns a user process but still connects as uid 0. + assert_ne!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(other_uid, postfix) + ); + } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs new file mode 100644 index 000000000..746a32eed --- /dev/null +++ b/src/ipc/auth.rs @@ -0,0 +1,1036 @@ +use crate::ipc::{Connection, ConnectionTmpl}; +#[cfg(all(windows, not(feature = "flutter")))] +use hbb_common::sha2::{Digest, Sha256}; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use hbb_common::{anyhow, bail, log, ResultType}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use hbb_common::{ + libc, + tokio::io::{AsyncRead, AsyncWrite}, +}; +#[cfg(windows)] +use parity_tokio_ipc::SecurityAttributes; +#[cfg(windows)] +use std::io; +#[cfg(all(windows, not(feature = "flutter")))] +use std::io::Read; +#[cfg(target_os = "macos")] +use std::os::unix::fs::MetadataExt; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::io::RawFd; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +use std::{ + fs, + path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, +}; +#[cfg(windows)] +use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; + +#[cfg(windows)] +#[inline] +pub(crate) fn should_allow_everyone_create_on_windows(postfix: &str) -> bool { + postfix.is_empty() || hbb_common::config::is_service_ipc_postfix(postfix) +} + +#[cfg(windows)] +#[inline] +pub(crate) fn portable_service_listener_security_attributes() -> io::Result { + let user_sid = crate::platform::windows::current_process_user_sid_string().map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!("failed to resolve current process SID: {}", err), + ) + })?; + debug_assert!( + user_sid.starts_with("S-1-") + && user_sid + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-'), + "current_process_user_sid_string returned a non-SDDL SID: {}", + user_sid + ); + // SDDL: + // - `D:P` => protected DACL (no inherited ACEs) + // - `(A;;GA;;;SY)` => allow GENERIC_ALL to LocalSystem + // - `(A;;GA;;;{user_sid})` => allow GENERIC_ALL to current process user SID + // References: + // - Security Descriptor String Format: https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-string-format + // - ACE strings in SDDL: https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-strings + let sddl = format!("D:P(A;;GA;;;SY)(A;;GA;;;{user_sid})"); + SecurityAttributes::from_sddl(&sddl).map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to build portable service listener security attributes from SDDL '{}': {}", + sddl, err + ), + ) + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn macos_service_ipc_allows_gui_and_service_binaries( + peer_exe: &Path, + current_exe: &Path, + postfix: &str, +) -> bool { + if postfix != crate::POSTFIX_SERVICE { + return false; + } + let Some(peer_dir) = peer_exe.parent() else { + return false; + }; + let Some(current_dir) = current_exe.parent() else { + return false; + }; + if !executable_paths_match(peer_dir, current_dir) { + return false; + } + + // On installed macOS builds, `_service` is listened by the `service` binary while the GUI + // process connects from the app executable within the same app bundle. + let gui_exe_name = std::ffi::OsString::from(crate::get_app_name()); + let gui_exe = gui_exe_name.as_os_str(); + let service_exe = std::ffi::OsStr::new("service"); + let allowed_exe = [Some(gui_exe), Some(service_exe)]; + let peer_name = peer_exe.file_name(); + let current_name = current_exe.file_name(); + allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(peer_name, *name)) + && allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(current_name, *name)) +} + +#[cfg(target_os = "windows")] +#[inline] +fn windows_portable_service_ipc_allows_logon_helper_executable( + _peer_exe: &Path, + postfix: &str, +) -> bool { + if postfix != "_portable_service" { + return false; + } + #[cfg(feature = "flutter")] + { + false + } + #[cfg(not(feature = "flutter"))] + { + let Some((_, expected)) = crate::platform::windows::portable_service_logon_helper_paths() + else { + return false; + }; + let Ok(expected) = fs::canonicalize(expected) else { + return false; + }; + let Ok(current_exe) = current_exe_canonical_path() else { + return false; + }; + portable_service_helper_is_trusted(_peer_exe, &expected, ¤t_exe) + } +} + +#[cfg(windows)] +#[inline] +pub(crate) fn is_allowed_windows_session_scoped_peer( + client_is_system: bool, + client_session_id: Option, + expected_session_id: Option, +) -> bool { + client_is_system + || matches!( + (client_session_id, expected_session_id), + (Some(client), Some(expected)) if client == expected + ) +} + +#[cfg(windows)] +#[inline] +fn is_allowed_windows_portable_service_peer( + client_is_system: Option, + _client_session_id: Option, + _expected_session_id: Option, +) -> bool { + // Portable-service listener DACL includes SYSTEM and current-process SID. + // In the portable-service path, current process is expected to run as SYSTEM, + // and the higher-layer peer policy stays SYSTEM-only. + matches!(client_is_system, Some(true)) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[inline] +pub(crate) fn is_allowed_service_peer_uid(peer_uid: u32, active_uid: Option) -> bool { + // Root is allowed at the UID gate because the service side may run as root. + // Callers still enforce executable matching before accepting service-scoped peers. + peer_uid == 0 || active_uid.is_some_and(|uid| uid == peer_uid) +} + +#[cfg(target_os = "macos")] +#[inline] +fn console_owner_uid() -> Option { + fs::metadata("/dev/console") + .ok() + .map(|metadata| metadata.uid()) +} + +#[cfg(target_os = "macos")] +#[inline] +fn active_uid_strict() -> Option { + // Prefer the filesystem metadata over parsing external command output. + console_owner_uid() +} + +#[cfg(target_os = "linux")] +#[inline] +fn active_uid_strict() -> Option { + let reported_uid_raw = crate::platform::linux::get_active_userid(); + let trimmed = reported_uid_raw.trim(); + if let Ok(uid) = trimmed.parse::() { + return Some(uid); + } + if trimmed.is_empty() { + log::debug!("Failed to resolve active user uid on linux: active uid is empty"); + } else { + log::warn!("Failed to parse active user uid on linux: '{}'", trimmed); + } + None +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn active_uid() -> Option { + active_uid_strict() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).map(|cred| cred.uid as u32); + } + #[cfg(target_os = "macos")] + { + let mut uid = 0; + let mut gid = 0; + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } == 0 { + Some(uid as u32) + } else { + None + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn peer_pid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).and_then(|cred| (cred.pid > 0).then_some(cred.pid as u32)); + } + #[cfg(target_os = "macos")] + { + let mut pid = 0; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + &mut pid as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 && pid > 0 { + Some(pid as _) + } else { + None + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_cred_from_fd(fd: RawFd) -> Option { + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 { + Some(cred) + } else { + None + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn current_exe_canonical_path() -> ResultType { + let current = std::env::current_exe() + .map_err(|err| anyhow::anyhow!("Failed to resolve current executable path: {}", err))?; + fs::canonicalize(¤t).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize current executable path '{}': {}", + current.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let proc_exe = PathBuf::from(format!("/proc/{peer_pid}/exe")); + let peer_exe = fs::read_link(&proc_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to read peer executable link '{}': {}", + proc_exe.display(), + err + ) + })?; + fs::canonicalize(&peer_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + peer_exe.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + const PROC_PIDPATH_BUF_SIZE: usize = libc::PROC_PIDPATHINFO_MAXSIZE as _; + let mut buffer = vec![0u8; PROC_PIDPATH_BUF_SIZE]; + let length = unsafe { + libc::proc_pidpath( + peer_pid as _, + buffer.as_mut_ptr() as _, + PROC_PIDPATH_BUF_SIZE as _, + ) + }; + if length <= 0 { + bail!("Failed to query peer process path from pid {}", peer_pid); + } + buffer.truncate(length as _); + let path = PathBuf::from(String::from_utf8_lossy(&buffer).to_string()); + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "windows")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let path = crate::platform::windows::get_process_executable_path(peer_pid)?; + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn executable_paths_match(left: &Path, right: &Path) -> bool { + #[cfg(target_os = "windows")] + { + // Callers pass paths resolved through fs::canonicalize() first, so NT + // namespace paths and 8.3 short names are expected to be resolved before + // this check. Keep this normalization limited to remaining Win32 spelling + // differences. + fn normalize(path: &Path) -> String { + let mut normalized = path.to_string_lossy().replace('/', "\\"); + if let Some(stripped) = normalized.strip_prefix(r"\\?\") { + normalized = stripped.to_owned(); + } + normalized.to_ascii_lowercase() + } + return normalize(left) == normalize(right); + } + #[cfg(target_os = "macos")] + { + return paths_refer_to_same_file(left, right); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + left == right + } +} + +#[cfg(target_os = "macos")] +#[inline] +fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) else { + return false; + }; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(target_os = "macos")] +#[inline] +fn os_str_eq_ignore_ascii_case( + left: Option<&std::ffi::OsStr>, + right: Option<&std::ffi::OsStr>, +) -> bool { + let (Some(left), Some(right)) = (left, right) else { + return false; + }; + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn file_sha256(path: &Path) -> ResultType<[u8; 32]> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + let read_bytes = file.read(&mut buffer)?; + if read_bytes == 0 { + break; + } + hasher.update(&buffer[..read_bytes]); + } + Ok(hasher.finalize().into()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn portable_service_helper_is_trusted( + peer_exe: &Path, + expected_exe: &Path, + current_exe: &Path, +) -> bool { + if !executable_paths_match(peer_exe, expected_exe) { + return false; + } + let peer_hash = match file_sha256(peer_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash peer portable helper executable '{}': {}", + peer_exe.display(), + err + ); + return false; + } + }; + let current_hash = match file_sha256(current_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash current executable '{}' for portable helper trust check: {}", + current_exe.display(), + err + ); + return false; + } + }; + peer_hash == current_hash +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn ensure_peer_executable_matches_current_by_pid(peer_pid: u32, postfix: &str) -> ResultType<()> { + let peer_exe = peer_exe_canonical_path_by_pid(peer_pid)?; + let current_exe = current_exe_canonical_path()?; + if executable_paths_match(&peer_exe, ¤t_exe) { + return Ok(()); + } + #[cfg(target_os = "macos")] + if macos_service_ipc_allows_gui_and_service_binaries(&peer_exe, ¤t_exe, postfix) { + return Ok(()); + } + #[cfg(target_os = "windows")] + if windows_portable_service_ipc_allows_logon_helper_executable(&peer_exe, postfix) { + return Ok(()); + } + bail!( + "Peer executable path mismatch on ipc channel '{}': peer_pid={}, peer_exe='{}', current_exe='{}'", + postfix, + peer_pid, + peer_exe.display(), + current_exe.display() + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_pid_opt( + peer_pid: Option, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid.ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_fd( + fd: RawFd, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid_from_fd(fd).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[derive(Default)] +struct UnauthorizedIpcLogThrottle { + last_log_at: Option, + suppressed: u64, +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +impl UnauthorizedIpcLogThrottle { + #[inline] + fn on_reject(&mut self, now: std::time::Instant) -> Option { + if let Some(last) = self.last_log_at { + if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL { + self.suppressed += 1; + return None; + } + } + self.last_log_at = Some(now); + Some(std::mem::take(&mut self.suppressed)) + } +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[inline] +fn throttled_unauthorized_ipc_log( + throttle_cell: &OnceLock>, + emit: impl FnOnce(u64), +) { + let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default())); + let should_log = match throttle.lock() { + Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()), + Err(_) => Some(0), + }; + if let Some(suppressed) = should_log { + emit(suppressed); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn log_rejected_service_connection(postfix: &str, peer_uid: Option, active_uid: Option) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn log_rejected_uinput_connection( + postfix: &str, + peer_uid: Option, + active_uid: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(windows)] +#[inline] +pub(crate) fn log_rejected_windows_ipc_connection( + postfix: &str, + peer_pid: Option, + peer_session_id: Option, + expected_session_id: Option, + peer_is_system: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?} (suppressed {} similar events)", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system + ); + } + }); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let peer_pid = stream.peer_pid(); + let (authorized, peer_uid, active_uid) = stream.service_authorization_status(); + if !authorized { + log_rejected_service_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let (authorized, peer_pid, peer_session_id, server_session_id, peer_is_system) = + stream.server_authorization_status(); + if !authorized { + log_rejected_windows_ipc_connection( + postfix, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + ); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on ipc channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_portable_service_ipc_connection( + stream: &Connection, + postfix: &str, +) -> bool { + // Portable service IPC policy: + // - only SYSTEM peers are authorized by is_allowed_windows_portable_service_peer() + // - expected_session_id is still collected for diagnostics and identity checks + // - final privilege boundary is enforced by named-pipe ACL + one-time token handshake + // - when peer identity is unavailable on some hosts, executable verification remains + // best-effort telemetry (not fail-closed) to avoid breaking valid SYSTEM bootstrap + // flows that cannot be fully introspected + let expected_session_id = crate::platform::windows::get_current_process_session_id(); + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.portable_service_authorization_status_for_session(expected_session_id); + if !authorized { + // Session lookup may succeed while SYSTEM identity lookup fails, so only the + // SYSTEM identity result determines whether peer identity is unavailable here. + // Don't use `peer_pid.is_some() && peer_session_id.is_none() && peer_is_system.is_none();` here. + let identity_unavailable = peer_pid.is_some() && peer_is_system.is_none(); + if identity_unavailable { + // In portable-service startup, resolving SYSTEM peer identity may fail on some hosts. + // `ProcessIdToSessionId` can still succeed while `OpenProcessToken(TOKEN_QUERY)` is + // denied by the peer token DACL or missing privileges. Treat that partial identity + // failure as unavailable and defer final authorization to pipe ACL + token handshake. + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Portable service ipc peer identity unavailable and executable verification failed; continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + } else { + log::warn!( + "Portable service ipc peer identity unavailable; executable verification matched, continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, expected_session_id={:?}", + postfix, + peer_pid, + expected_session_id + ); + } + return true; + } + log::warn!( + "Rejected unauthorized connection on portable service ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system + ); + return false; + } + true +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl ConnectionTmpl +where + T: AsyncRead + AsyncWrite + std::marker::Unpin + std::os::unix::io::AsRawFd, +{ + pub(super) fn peer_uid(&self) -> Option { + peer_uid_from_fd(self.inner.get_ref().as_raw_fd()) + } + + fn service_authorization_status(&self) -> (bool, Option, Option) { + let peer_uid = self.peer_uid(); + // On Linux, `_service` can use the cached active UID from the service loop for + // stable config sync. Uinput does a fresh active-UID lookup in its own authorizer. + let active_uid = active_uid(); + let authorized = peer_uid.is_some_and(|uid| is_allowed_service_peer_uid(uid, active_uid)); + (authorized, peer_uid, active_uid) + } + + pub(super) fn peer_pid(&self) -> Option { + peer_pid_from_fd(self.inner.get_ref().as_raw_fd()) + } +} + +#[cfg(windows)] +impl ConnectionTmpl { + fn peer_pid(&self) -> Option { + let pipe_handle = self.inner.get_ref().as_raw_handle(); + if pipe_handle.is_null() { + return None; + } + let mut pid = 0u32; + let ok = unsafe { GetNamedPipeClientProcessId(HANDLE(pipe_handle), &mut pid as *mut u32) } + .is_ok(); + if ok && pid != 0 { + Some(pid) + } else { + None + } + } + + fn server_authorization_status( + &self, + ) -> (bool, Option, Option, Option, Option) { + let peer_pid = self.peer_pid(); + let server_session_id = crate::platform::windows::get_current_process_session_id(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + if server_session_id.is_none() && !peer_is_system.unwrap_or(false) { + // When the server session id cannot be determined, the session-id allow-path is + // disabled and only SYSTEM peers can be authorized. + log::debug!( + "IPC authorization: server session id unavailable; rejecting non-SYSTEM peer, peer_pid={:?}, peer_session_id={:?}", + peer_pid, + peer_session_id + ); + } + let authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + server_session_id, + ); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + } + ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + ) + } + + pub(crate) fn service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + let peer_pid = self.peer_pid(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + let authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + expected_active_session_id, + ); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + } + (authorized, peer_pid, peer_session_id, peer_is_system) + } + + pub(crate) fn portable_service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + // Portable-service policy: + // only SYSTEM peers are allowed. + let (_service_authorized, peer_pid, peer_session_id, peer_is_system) = + self.service_authorization_status_for_session(expected_active_session_id); + ( + is_allowed_windows_portable_service_peer( + peer_is_system, + peer_session_id, + expected_active_session_id, + ), + peer_pid, + peer_session_id, + peer_is_system, + ) + } +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn test_service_peer_uid_policy() { + assert!(super::is_allowed_service_peer_uid(0, None)); + assert!(super::is_allowed_service_peer_uid(501, Some(501))); + assert!(!super::is_allowed_service_peer_uid(502, Some(501))); + assert!(!super::is_allowed_service_peer_uid(501, None)); + } + + #[test] + #[cfg(windows)] + fn test_windows_server_peer_policy() { + assert!(super::is_allowed_windows_session_scoped_peer( + true, None, None + )); + assert!(super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + None, + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_windows_portable_service_peer_policy() { + assert!(super::is_allowed_windows_portable_service_peer( + Some(true), + None, + None + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + None, + Some(1), + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_should_allow_everyone_create_on_windows_policy() { + assert!(super::should_allow_everyone_create_on_windows("")); + assert!(super::should_allow_everyone_create_on_windows("_service")); + assert!(!super::should_allow_everyone_create_on_windows( + "_portable_service" + )); + } + + #[test] + #[cfg(windows)] + fn test_executable_paths_match_windows_normalization() { + let left = std::path::PathBuf::from(r"\\?\C:\Program Files\RustDesk\RustDesk.exe"); + let right = std::path::PathBuf::from(r"c:\program files\rustdesk\rustdesk.exe"); + assert!(super::executable_paths_match(&left, &right)); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_os_str_eq_ignore_ascii_case_for_process_names() { + assert!(super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("rustdesk")) + )); + assert!(!super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("service")) + )); + } + + #[cfg(all(windows, not(feature = "flutter")))] + struct TempDirGuard(std::path::PathBuf); + + #[cfg(all(windows, not(feature = "flutter")))] + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_requires_content_match() { + let unique = format!( + "rustdesk-portable-helper-trust-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"tampered-binary").unwrap(); + + assert!( + !super::portable_service_helper_is_trusted(&helper_exe, &helper_exe, ¤t_exe), + "helper trust check must reject path-match-only binaries with mismatched content" + ); + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_accepts_matching_content() { + let unique = format!( + "rustdesk-portable-helper-trust-match-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"trusted-binary").unwrap(); + + assert!(super::portable_service_helper_is_trusted( + &helper_exe, + &helper_exe, + ¤t_exe + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_console_owner_uid_matches_get_active_userid() { + let console_uid = + super::console_owner_uid().expect("/dev/console must have a resolvable uid"); + let raw_uid = crate::platform::macos::get_active_userid(); + let parsed_uid: u32 = raw_uid + .trim() + .parse() + .unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'")); + assert_eq!(parsed_uid, console_uid); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs new file mode 100644 index 000000000..e0157f3a9 --- /dev/null +++ b/src/ipc/fs.rs @@ -0,0 +1,951 @@ +#[cfg(target_os = "linux")] +use super::ipc_auth::active_uid; +use crate::ipc::{connect, Data}; +use hbb_common::{config, log, ResultType}; +use std::{ + ffi::CString, + io::{Error, ErrorKind}, + os::unix::ffi::OsStrExt, + path::Path, +}; + +struct FdGuard(i32); +impl Drop for FdGuard { + fn drop(&mut self) { + unsafe { + hbb_common::libc::close(self.0); + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn terminal_count_candidate_uids(effective_uid: u32) -> Vec { + if effective_uid != 0 { + return vec![effective_uid]; + } + let mut candidates = Vec::with_capacity(2); + if let Some(uid) = active_uid().filter(|uid| *uid != 0) { + candidates.push(uid); + } + candidates.push(0); + candidates +} + +#[inline] +fn expected_ipc_parent_mode(postfix: &str) -> u32 { + if config::is_service_ipc_postfix(postfix) { + 0o0711 + } else { + 0o0700 + } +} + +fn open_ipc_parent_dir_fd(parent_c: &CString) -> std::io::Result { + let fd = unsafe { + hbb_common::libc::open( + parent_c.as_ptr(), + hbb_common::libc::O_RDONLY + | hbb_common::libc::O_DIRECTORY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +// Remove one preexisting IPC artifact via an already-opened parent directory FD. +// +// Security intent: +// - Bind cleanup to the exact parent inode that passed O_NOFOLLOW + fstat checks. +// - Avoid path-based TOCTOU during scrub (e.g., parent path rename/swap race). +// +// Flow: +// 1) fstatat(..., AT_SYMLINK_NOFOLLOW) to inspect the target entry under parent_fd. +// 2) Decide file vs directory from st_mode. +// 3) unlinkat relative to parent_fd (AT_REMOVEDIR for directories). +// +// Error policy: +// - NotFound is treated as benign (already removed / raced away). +// - Other errors are surfaced explicitly. +fn remove_parent_entry_via_fd( + parent_fd: i32, + parent_dir: &Path, + entry_name: &str, +) -> ResultType<()> { + if entry_name.contains('/') { + return Err(Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name (contains '/'): parent={}, entry={}", + parent_dir.display(), + entry_name + ), + ) + .into()); + } + let entry_c = CString::new(entry_name.as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + })?; + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + let stat_rc = unsafe { + hbb_common::libc::fstatat( + parent_fd, + entry_c.as_ptr(), + &mut stat, + hbb_common::libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to stat preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + + let is_dir = (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + == hbb_common::libc::S_IFDIR; + let unlink_flags = if is_dir { + hbb_common::libc::AT_REMOVEDIR + } else { + 0 + }; + let unlink_rc = + unsafe { hbb_common::libc::unlinkat(parent_fd, entry_c.as_ptr(), unlink_flags) }; + if unlink_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to remove preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + Ok(()) +} + +fn scrub_preexisting_ipc_parent_entries( + parent_fd: i32, + parent_dir: &Path, + postfix: &str, +) -> ResultType<()> { + let ipc_basename = format!("ipc{}", postfix); + remove_parent_entry_via_fd(parent_fd, parent_dir, &ipc_basename)?; + remove_parent_entry_via_fd(parent_fd, parent_dir, &format!("{}.pid", ipc_basename))?; + Ok(()) +} + +fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { + let path = config::Config::ipc_path(postfix); + let parent_dir = Path::new(&path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + if open_err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + }; + let _fd_guard = FdGuard(fd); + remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) +} + +// Purpose: +// - Harden the IPC parent directory before creating/listening socket files. +// - Prevent symlink/path-race abuse and reject unsafe owner/mode. +// +// Approach: +// - Open parent dir with O_NOFOLLOW/O_DIRECTORY and operate on that fd. +// - Validate inode type/owner/mode via fstat. +// - For protected service postfix, optionally adopt owner (root only), then scrub stale +// rustdesk IPC artifacts when directory trust boundary changed. +// +// Main steps: +// 1) Resolve parent path and open/create directory securely. +// 2) Verify directory inode type and owner uid. +// 3) Enforce expected mode via fchmod on opened fd. +// 4) Scrub stale IPC artifacts when owner/mode was unsafe before hardening. +// +// References: +// - open(2): O_NOFOLLOW/O_DIRECTORY/O_CLOEXEC +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type/metadata on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchown(2): adopt ownership when running as root +// https://man7.org/linux/man-pages/man2/chown.2.html +// - fchmod(2): enforce exact mode on opened fd +// https://man7.org/linux/man-pages/man2/fchmod.2.html +pub(crate) fn ensure_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType { + let parent_dir = Path::new(path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + // Harden against common TOCTOU by opening the parent directory with O_NOFOLLOW (so the parent + // itself cannot be a symlink) and then operating on its FD (fstat/fchown/fchmod). This ensures + // we mutate the inode we opened, though it does not protect against symlinks in ancestor path + // components. + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + // If the directory doesn't exist yet, create it with the expected mode. The parent + // dir is intended to be a single-level /tmp path, so mkdir is sufficient here. + if open_err.raw_os_error() == Some(hbb_common::libc::ENOENT) { + let expected_mode = expected_ipc_parent_mode(postfix); + let rc = unsafe { + hbb_common::libc::mkdir( + parent_c.as_ptr(), + expected_mode as hbb_common::libc::mode_t, + ) + }; + if rc != 0 { + let mkdir_err = std::io::Error::last_os_error(); + // Handle a race where another process created the directory first. + if mkdir_err.raw_os_error() != Some(hbb_common::libc::EEXIST) { + return Err(Error::new( + mkdir_err.kind(), + format!( + "failed to mkdir ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + mkdir_err + ), + ) + .into()); + } + } + match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(err) => { + return Err(Error::new( + err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + .into()); + } + } + } else { + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + } + }; + let _fd_guard = FdGuard(fd); + + let mut st: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st as *mut _) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to stat ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + let mode = st.st_mode as u32; + let is_dir = (mode & (hbb_common::libc::S_IFMT as u32)) == (hbb_common::libc::S_IFDIR as u32); + if !is_dir { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "ipc parent is not directory: postfix={}, parent={}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let mut owner_uid = st.st_uid as u32; + let mut adopted_foreign_service_parent = false; + // Service-scoped IPC may be created by different privilege contexts historically. + // If running as root on protected service postfix, try adopting ownership first. + if owner_uid != expected_uid && expected_uid == 0 && config::is_service_ipc_postfix(postfix) { + let rc = unsafe { + hbb_common::libc::fchown( + fd, + expected_uid as hbb_common::libc::uid_t, + hbb_common::libc::gid_t::MAX, + ) + }; + if rc == 0 { + let mut st2: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st2 as *mut _) } == 0 { + owner_uid = st2.st_uid as u32; + st = st2; + adopted_foreign_service_parent = true; + } + } else { + // Keep behavior unchanged; capture errno to ease diagnosing why chown failed. + let err = std::io::Error::last_os_error(); + log::warn!( + "Failed to chown ipc parent dir, parent={}, postfix={}, expected_uid={}, rc={}, err={:?}", + parent_dir.display(), + postfix, + expected_uid, + rc, + err + ); + } + } + if owner_uid != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "unsafe ipc parent owner, postfix={}, expected uid {expected_uid}, got {owner_uid}: {}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_mode = expected_ipc_parent_mode(postfix); + // Include special bits (setuid/setgid/sticky) to ensure the directory is hardened to the exact + // expected mode. + let current_mode = (st.st_mode as u32) & 0o7777; + let repaired_parent_mode = current_mode != expected_mode; + let had_untrusted_parent_mode = (current_mode & 0o022) != 0; + if repaired_parent_mode { + // Use fchmod on the opened fd to avoid path-race between check and chmod. + if unsafe { hbb_common::libc::fchmod(fd, expected_mode as hbb_common::libc::mode_t) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to chmod ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + } + let should_scrub = + repaired_parent_mode || adopted_foreign_service_parent || had_untrusted_parent_mode; + Ok(should_scrub) +} + +pub(crate) fn scrub_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<()> { + let parent_dir = Path::new(path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = open_ipc_parent_dir_fd(&parent_c).map_err(|err| { + Error::new( + err.kind(), + format!( + "failed to open ipc parent dir for scrub (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + })?; + let _fd_guard = FdGuard(fd); + scrub_preexisting_ipc_parent_entries(fd, parent_dir, postfix) +} + +#[inline] +pub(crate) fn get_pid_file(postfix: &str) -> String { + let path = config::Config::ipc_path(postfix); + format!("{}.pid", path) +} + +// Purpose: +// - Write current process pid to pid file without following attacker-controlled symlinks. +// - Ensure the pid file is a regular file owned by the opened inode path. +// +// Approach: +// - Use libc open/fstat/write syscalls (FFI) so flags and inode validation are explicit. +// - Open file with O_NOFOLLOW/O_CLOEXEC and verify S_IFREG with fstat before write. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file (without truncation). +// 2) Validate opened inode is a regular file owned by current euid. +// 3) Enforce pid file mode to 0600 and truncate via ftruncate after validation. +// 4) Write process id bytes through fd. +// +// Why not plain std::fs::write? +// - std::fs helpers cannot enforce this exact open-time hardening sequence +// (especially "open with O_NOFOLLOW, then fstat the same opened inode"). +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchmod(2): enforce secure mode on reused pid file +// https://man7.org/linux/man-pages/man2/fchmod.2.html +// - ftruncate(2): truncate after validation +// https://man7.org/linux/man-pages/man2/ftruncate.2.html +// - write(2): write bytes via fd +// https://man7.org/linux/man-pages/man2/write.2.html +fn write_pid_file(path: &Path) -> ResultType<()> { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!("invalid pid file path '{}': {}", path.display(), err), + ) + })?; + let flags = hbb_common::libc::O_WRONLY + | hbb_common::libc::O_CREAT + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags, 0o0600) }; + if fd < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to open pid file with no-follow '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + let _fd_guard = FdGuard(fd); + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to stat pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!("pid file path is not a regular file: '{}'", path.display()), + ) + .into()); + } + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + if stat.st_uid as u32 != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "pid file owner mismatch: expected uid {}, got {} for '{}'", + expected_uid, + stat.st_uid, + path.display() + ), + ) + .into()); + } + if unsafe { hbb_common::libc::fchmod(fd, 0o600) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to chmod pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if unsafe { hbb_common::libc::ftruncate(fd, 0) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to truncate pid file '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + + let bytes = std::process::id().to_string(); + let buf = bytes.as_bytes(); + // `write(2)` is allowed to return a short write even for regular files. + // PID content is tiny and usually written in one shot, but we still loop + // until all bytes are persisted so this path is semantically correct. + let mut written = 0usize; + while written < buf.len() { + let rc = unsafe { + hbb_common::libc::write( + fd, + buf[written..].as_ptr() as *const hbb_common::libc::c_void, + buf.len() - written, + ) + }; + if rc < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to write pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if rc == 0 { + return Err(Error::new( + ErrorKind::WriteZero, + format!( + "failed to write pid file '{}': write returned 0 bytes", + path.display() + ), + ) + .into()); + } + written += rc as usize; + } + Ok(()) +} + +#[inline] +pub(crate) fn write_pid(postfix: &str) { + let path = std::path::PathBuf::from(get_pid_file(postfix)); + if let Err(err) = write_pid_file(&path) { + log::warn!( + "Failed to write pid file for postfix '{}', path='{}', err={}", + postfix, + path.display(), + err + ); + } +} + +// Purpose: +// - Read pid file safely and avoid trusting symlink/non-regular files. +// +// Approach: +// - Use libc open/fstat/read syscalls (FFI) to control flags and inode checks. +// - Open path with O_NOFOLLOW, validate opened fd via fstat, then read and parse. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file read-only. +// 2) Ensure fd points to regular file. +// 3) Read bytes and parse usize pid. +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): validate S_IFREG on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - read(2): read bytes via fd +// https://man7.org/linux/man-pages/man2/read.2.html +#[inline] +fn read_pid_file_secure(path: &Path) -> Option { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).ok()?; + let flags = hbb_common::libc::O_RDONLY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags) }; + if fd < 0 { + return None; + } + let _fd_guard = FdGuard(fd); + + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + return None; + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return None; + } + + let mut buffer = [0u8; 64]; + let read_len = unsafe { + hbb_common::libc::read( + fd, + buffer.as_mut_ptr() as *mut hbb_common::libc::c_void, + buffer.len(), + ) + }; + if read_len <= 0 { + return None; + } + let content = String::from_utf8_lossy(&buffer[..read_len as usize]).to_string(); + content.trim().parse::().ok() +} + +#[inline] +async fn probe_existing_listener(postfix: &str) -> bool { + let Ok(mut stream) = connect(1000, postfix).await else { + return false; + }; + if postfix != crate::POSTFIX_SERVICE { + return true; + } + if stream.send(&Data::SyncConfig(None)).await.is_err() { + return false; + } + matches!( + stream.next_timeout(1000).await, + Ok(Some(Data::SyncConfig(Some(_)))) + ) +} + +pub(crate) async fn check_pid(postfix: &str) -> bool { + let pid_file = std::path::PathBuf::from(get_pid_file(postfix)); + if let Some(pid) = read_pid_file_secure(&pid_file) { + if pid > 0 { + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + if let Some(p) = sys.process(pid.into()) { + if let Some(current) = sys.process((std::process::id() as usize).into()) { + if current.name() == p.name() && probe_existing_listener(postfix).await { + return true; + } + } + } + } + } + if probe_existing_listener(postfix).await { + return true; + } + // if not remove old ipc file, the new ipc creation will fail + // if we remove a ipc file, but the old ipc process is still running, + // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive + if let Err(err) = remove_ipc_socket_via_secure_parent_fd(postfix) { + log::debug!( + "Failed to remove stale ipc socket via secure parent fd: postfix={}, err={}", + postfix, + err + ); + } + false +} + +#[inline] +pub(crate) fn should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries: bool, + existing_listener_alive: bool, +) -> bool { + should_scrub_parent_entries && !existing_listener_alive +} + +#[cfg(test)] +mod tests { + #[test] + fn test_write_pid_file_rejects_symlink() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-pid-file-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let target = base.join("target_pid"); + std::fs::write(&target, b"origin").unwrap(); + let link = base.join("pid_link"); + symlink(&target, &link).unwrap(); + + let res = super::write_pid_file(&link); + assert!(res.is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "origin"); + + std::fs::remove_file(&link).ok(); + std::fs::remove_file(&target).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_rejects_symlink_parent() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-secure-dir-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + let real_dir = base.join("real"); + let link_dir = base.join("link"); + std::fs::create_dir_all(&real_dir).unwrap(); + symlink(&real_dir, &link_dir).unwrap(); + let ipc_path = link_dir.join("ipc_service"); + let res = + super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "_service"); + assert!(res.is_err()); + std::fs::remove_file(&link_dir).ok(); + std::fs::remove_dir_all(&real_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_creates_parent_with_expected_mode() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-create-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + // Intentionally choose a parent that does not exist to exercise the ENOENT -> mkdir branch. + let parent_dir = base.join("parent"); + assert!(!parent_dir.exists()); + let ipc_path = parent_dir.join("ipc"); + + let res = super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), ""); + // Restrictive umask can make mkdir create a stricter initial mode. In that case + // ensure_secure_ipc_parent_dir repairs it with fchmod and may request a scrub. + res.unwrap(); + + let md = std::fs::metadata(&parent_dir).unwrap(); + assert!(md.is_dir()); + let mode = md.permissions().mode() & 0o777; + assert_eq!(mode, 0o0700); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_only_removes_target_postfix_artifacts() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let ipc_file = base.join("ipc_service"); + let ipc_pid_file = base.join("ipc_service.pid"); + let ipc_other_postfix_file = base.join("ipc_uinput_1"); + let keep_file = base.join("keep.txt"); + let keep_dir = base.join("keep_dir"); + + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + std::fs::write(&ipc_other_postfix_file, b"other-postfix").unwrap(); + std::fs::write(&keep_file, b"keep").unwrap(); + std::fs::create_dir_all(&keep_dir).unwrap(); + + let base_c = std::ffi::CString::new(base.as_os_str().as_bytes().to_vec()).unwrap(); + let base_fd = super::open_ipc_parent_dir_fd(&base_c).unwrap(); + let _base_guard = super::FdGuard(base_fd); + super::scrub_preexisting_ipc_parent_entries(base_fd, &base, "_service").unwrap(); + + assert!(!ipc_file.exists()); + assert!(!ipc_pid_file.exists()); + assert!(ipc_other_postfix_file.exists()); + assert!(keep_file.exists()); + assert!(keep_dir.exists()); + + std::fs::remove_file(&ipc_other_postfix_file).ok(); + std::fs::remove_file(&keep_file).ok(); + std::fs::remove_dir_all(&keep_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_should_bind_to_opened_inode_not_path() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-fd-bind-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let trusted_parent = base.join("trusted_parent"); + let trusted_parent_moved = base.join("trusted_parent_moved"); + let attacker_parent = base.join("attacker_parent"); + std::fs::create_dir_all(&trusted_parent).unwrap(); + std::fs::create_dir_all(&attacker_parent).unwrap(); + + let trusted_ipc_file = trusted_parent.join("ipc_service"); + let attacker_ipc_file = attacker_parent.join("ipc_service"); + std::fs::write(&trusted_ipc_file, b"trusted").unwrap(); + std::fs::write(&attacker_ipc_file, b"attacker").unwrap(); + + let trusted_parent_c = + std::ffi::CString::new(trusted_parent.as_os_str().as_bytes().to_vec()).unwrap(); + let trusted_parent_fd = super::open_ipc_parent_dir_fd(&trusted_parent_c).unwrap(); + let _trusted_parent_guard = super::FdGuard(trusted_parent_fd); + + // Swap the path after the trusted inode has been opened. + std::fs::rename(&trusted_parent, &trusted_parent_moved).unwrap(); + std::fs::rename(&attacker_parent, &trusted_parent).unwrap(); + + super::scrub_preexisting_ipc_parent_entries(trusted_parent_fd, &trusted_parent, "_service") + .unwrap(); + + // Expected secure behavior: scrub should target the inode that was opened before path swap. + assert!( + !trusted_parent_moved.join("ipc_service").exists(), + "trusted inode artifact should be removed even after path swap" + ); + assert!( + trusted_parent.join("ipc_service").exists(), + "path-swapped attacker directory should not be scrubbed" + ); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_keeps_service_artifacts_before_liveness_probe() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-order-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let parent_dir = base.join("service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + // Trigger "had_untrusted_service_parent_mode". + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + + let ipc_file = parent_dir.join("ipc_service"); + let ipc_pid_file = parent_dir.join("ipc_service.pid"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + + let res = + super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "_service"); + assert_eq!(res.unwrap(), true); + + // Parent hardening should run first; artifacts should stay until liveness probe completes. + assert!(ipc_file.exists(), "ipc socket marker should be preserved"); + assert!(ipc_pid_file.exists(), "pid marker should be preserved"); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-nonservice-mode-repair-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + + let parent_dir = base.join("non_service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let ipc_file = parent_dir.join("ipc"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + + let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), ""); + assert_eq!(res.unwrap(), true); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() { + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, true + )); + assert!(super::should_scrub_parent_entries_after_check_pid( + true, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + true, true + )); + } +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7157da760..9a4bb37ec 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -29,6 +29,12 @@ use wallpaper; pub const PA_SAMPLE_RATE: u32 = 48000; static mut UNMODIFIED: bool = true; +#[derive(Clone, Debug)] +struct ActiveUserLookupCache { + uid: String, + username: String, +} + const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"]; const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"]; @@ -50,6 +56,8 @@ lazy_static::lazy_static! { } } }; + static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = + std::sync::Mutex::new(None); // https://github.com/rustdesk/rustdesk/issues/13705 // Check if `sudo -E` actually preserves environment. // @@ -82,6 +90,27 @@ lazy_static::lazy_static! { }; } +#[inline] +fn update_active_user_lookup_cache(desktop: &Desktop) { + if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() { + if desktop.uid.is_empty() || desktop.username.is_empty() { + *cache = None; + } else { + *cache = Some(ActiveUserLookupCache { + uid: desktop.uid.clone(), + username: desktop.username.clone(), + }); + } + } +} + +#[inline] +fn get_active_user_id_name_from_cache() -> Option<(String, String)> { + let cache = ACTIVE_USER_LOOKUP_CACHE.lock().ok()?; + let entry = cache.as_ref()?; + Some((entry.uid.clone(), entry.username.clone())) +} + thread_local! { // XDO context - created via libxdo-sys (which uses dynamic loading stub). // If libxdo is not available, xdo will be null and xdo-based functions become no-ops. @@ -789,6 +818,7 @@ pub fn start_os_service() { let mut last_restart = Instant::now(); while running.load(Ordering::SeqCst) { desktop.refresh(); + update_active_user_lookup_cache(&desktop); // Duplicate logic here with should_start_server // Login wayland will try to start a headless --server. @@ -861,13 +891,29 @@ pub fn start_os_service() { } #[inline] +/// Returns the cached active `(uid, username)` snapshot when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_user_id_name() -> (String, String) { + if let Some(id_name) = get_active_user_id_name_from_cache() { + return id_name; + } let vec_id_name = get_values_of_seat0(&[1, 2]); (vec_id_name[0].clone(), vec_id_name[1].clone()) } #[inline] +/// Returns the cached active uid when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_userid() -> String { + if let Some((uid, _)) = get_active_user_id_name_from_cache() { + return uid; + } + get_values_of_seat0(&[1])[0].clone() +} + +#[inline] +/// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. +pub fn get_active_userid_fresh() -> String { get_values_of_seat0(&[1])[0].clone() } @@ -922,7 +968,12 @@ fn _get_display_manager() -> String { } #[inline] +/// Returns the cached active username when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_username() -> String { + if let Some((_, username)) = get_active_user_id_name_from_cache() { + return username; + } get_values_of_seat0(&[2])[0].clone() } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 4c09bbe9f..a755714f9 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -73,10 +73,19 @@ use winapi::{ }; use windows::Win32::{ Foundation::{CloseHandle as WinCloseHandle, HANDLE as WinHANDLE}, + Security::{ + GetTokenInformation as WinGetTokenInformation, IsWellKnownSid, TokenUser, + WinLocalSystemSid, TOKEN_QUERY as WIN_TOKEN_QUERY, TOKEN_USER, + }, System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, }, + System::Threading::{ + OpenProcess as WinOpenProcess, OpenProcessToken as WinOpenProcessToken, + QueryFullProcessImageNameW as WinQueryFullProcessImageNameW, + PROCESS_QUERY_LIMITED_INFORMATION as WIN_PROCESS_QUERY_LIMITED_INFORMATION, + }, }; use windows_service::{ define_windows_service, @@ -88,6 +97,14 @@ use windows_service::{ }; use winreg::{enums::*, RegKey}; +mod acl; +pub(crate) use acl::current_process_user_sid_string; +pub use acl::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, +}; + pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW"; @@ -565,6 +582,55 @@ pub fn get_current_session_id(share_rdp: bool) -> DWORD { unsafe { get_current_session(if share_rdp { TRUE } else { FALSE }) } } +#[inline] +fn resolve_expected_active_session_id_for_service(session_id: u32) -> Option { + let share_rdp_enabled = is_share_rdp(); + if get_available_sessions(false) + .iter() + .any(|e| e.sid == session_id) + { + return Some(session_id); + } + let current_active_session = + unsafe { get_current_session(if share_rdp_enabled { TRUE } else { FALSE }) }; + if current_active_session == u32::MAX { + None + } else { + Some(current_active_session) + } +} + +#[inline] +fn authorize_service_scoped_ipc_connection( + stream: &ipc::Connection, + expected_active_session_id: Option, +) -> bool { + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.service_authorization_status_for_session(expected_active_session_id); + if !authorized { + ipc::log_rejected_windows_ipc_connection( + crate::POSTFIX_SERVICE, + peer_pid, + peer_session_id, + expected_active_session_id, + peer_is_system, + ); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_pid_opt(peer_pid, crate::POSTFIX_SERVICE) + { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + crate::POSTFIX_SERVICE, + peer_pid, + err + ); + return false; + } + true +} + extern "system" { fn BlockInput(v: BOOL) -> BOOL; } @@ -631,6 +697,15 @@ async fn run_service(_arguments: Vec) -> ResultType<()> { Ok(res) => match res { Some(Ok(stream)) => { let mut stream = ipc::Connection::new(stream); + // Keep IPC authorization consistent with the session we are currently serving. + // Recompute expected session right before authorization to avoid using a stale + // session_id after awaiting incoming.next(). + let expected_active_session_id = + resolve_expected_active_session_id_for_service(session_id); + if !authorize_service_scoped_ipc_connection(&stream, expected_active_session_id) + { + continue; + } if let Ok(Some(data)) = stream.next_timeout(1000).await { match data { ipc::Data::Close => { @@ -1141,6 +1216,22 @@ pub fn get_active_user_home() -> Option { None } +#[cfg(not(feature = "flutter"))] +#[inline] +pub fn portable_service_logon_helper_paths() -> Option<(PathBuf, PathBuf)> { + // Keep parity with history for now: derive LocalAppData from user profile path. + // If users report redirected/non-standard LocalAppData issues, switch to: + // `BaseDirs::new()?.data_local_dir()` for Known Folder-based resolution. + let user_dir = hbb_common::directories_next::UserDirs::new()?; + let dir = user_dir + .home_dir() + .join("AppData") + .join("Local") + .join("rustdesk-sciter"); + let dst = dir.join("rustdesk.exe"); + Some((dir, dst)) +} + pub fn is_prelogin() -> bool { let Some(username) = get_current_session_username() else { return false; @@ -2327,16 +2418,33 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst is_run_as_system, crate::username(), ); - let arg_elevate = if is_setup { + let mut arg_elevate = if is_setup { "--noinstall --elevate" } else { "--elevate" - }; - let arg_run_as_system = if is_setup { + } + .to_owned(); + let mut arg_run_as_system = if is_setup { "--noinstall --run-as-system" } else { "--run-as-system" - }; + } + .to_owned(); + let shmem_name_from_args = crate::portable_service::portable_service_shmem_name_from_args(); + if shmem_name_from_args.is_none() && crate::portable_service::has_portable_service_shmem_arg() { + log::error!("Invalid portable service shared memory argument, aborting elevation flow"); + // This is a malformed bootstrap argument in a privilege-sensitive path. + // Keep fail-closed process termination here to avoid continuing elevation + // with inconsistent shared-memory contract. + std::process::exit(1); + } + if let Some(shmem_name) = shmem_name_from_args { + let shmem_arg = crate::portable_service::portable_service_shmem_arg(&shmem_name); + arg_elevate.push(' '); + arg_elevate.push_str(&shmem_arg); + arg_run_as_system.push(' '); + arg_run_as_system.push_str(&shmem_arg); + } if is_root() { if is_run_as_system { log::info!("run portable service"); @@ -2347,7 +2455,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst Ok(elevated) => { if elevated { if !is_run_as_system { - if run_as_system(arg_run_as_system).is_ok() { + if run_as_system(arg_run_as_system.as_str()).is_ok() { std::process::exit(0); } else { log::error!( @@ -2358,7 +2466,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst } } else { if !is_elevate { - if let Ok(true) = elevate(arg_elevate) { + if let Ok(true) = elevate(arg_elevate.as_str()) { std::process::exit(0); } else { log::error!("Failed to elevate, error {}", io::Error::last_os_error()); @@ -2416,6 +2524,115 @@ pub fn is_elevated(process_id: Option) -> ResultType { } } +#[inline] +unsafe fn read_token_user_buffer(token: WinHANDLE, subject: &str) -> ResultType> { + let mut token_user_size = 0u32; + let get_info_result = WinGetTokenInformation(token, TokenUser, None, 0, &mut token_user_size); + match get_info_result { + Ok(()) => { + if token_user_size == 0 { + bail!( + "Failed to get {} token user size: unexpected zero buffer size", + subject + ); + } + } + Err(e) => { + // Allow expected size-probe failures if Windows still returns required size. + let is_insufficient_buffer = + e.code() == windows::core::HRESULT::from_win32(ERROR_INSUFFICIENT_BUFFER as u32); + let is_bad_length = + e.code() == windows::core::HRESULT::from_win32(ERROR_BAD_LENGTH as u32); + if (!is_insufficient_buffer && !is_bad_length) || token_user_size == 0 { + bail!("Failed to get {} token user size: {}", subject, e); + } + } + } + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .map_err(|e| anyhow!("Failed to get {} token user: {}", subject, e))?; + + let min_size = std::mem::size_of::(); + if buffer.len() < min_size { + bail!( + "Failed to parse {} token user: buffer too small (got {}, need >= {})", + subject, + buffer.len(), + min_size + ); + } + Ok(buffer) +} + +/// Similar to `is_root()` / `is_local_system()` but for an arbitrary process. +/// +/// Returns `true` if the target process is running as LocalSystem (SID: S-1-5-18). +/// +/// TODO: After a few releases of real-world validation, consider replacing +/// the legacy `is_local_system()` with this implementation. +pub fn is_process_running_as_system(process_id: DWORD) -> ResultType { + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let mut token = WinHANDLE::default(); + let result = (|| -> ResultType { + WinOpenProcessToken(process, WIN_TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open process {} token: {}", process_id, e))?; + + let token_subject = format!("process {}", process_id); + let buffer = read_token_user_buffer(token, token_subject.as_str())?; + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + Ok(IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool()) + })(); + + if !token.is_invalid() { + let _ = WinCloseHandle(token); + } + let _ = WinCloseHandle(process); + result + } +} + +pub fn get_process_executable_path(process_id: DWORD) -> ResultType { + const PROCESS_IMAGE_PATH_BUFFER_LEN: usize = 32 * 1024; + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let result = (|| -> ResultType { + let mut buffer = vec![0u16; PROCESS_IMAGE_PATH_BUFFER_LEN]; + let mut length = PROCESS_IMAGE_PATH_BUFFER_LEN as u32; + WinQueryFullProcessImageNameW( + process, + windows::Win32::System::Threading::PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut length, + ) + .map_err(|e| anyhow!("Failed to query process {} image path: {}", process_id, e))?; + if length == 0 { + bail!( + "Failed to query process {} image path: empty result", + process_id + ); + } + buffer.truncate(length as usize); + Ok(PathBuf::from(OsString::from_wide(&buffer))) + })(); + + let _ = WinCloseHandle(process); + result + } +} + pub fn is_foreground_window_elevated() -> ResultType { unsafe { let mut process_id: DWORD = 0; @@ -2708,16 +2925,6 @@ pub fn create_process_with_logon(user: &str, pwd: &str, exe: &str, arg: &str) -> return Ok(()); } -pub fn set_path_permission(dir: &Path, permission: &str) -> ResultType<()> { - std::process::Command::new("icacls") - .arg(dir.as_os_str()) - .arg("/grant") - .arg(format!("*S-1-1-0:(OI)(CI){}", permission)) - .arg("/T") - .spawn()?; - Ok(()) -} - #[inline] fn str_to_device_name(name: &str) -> [u16; 32] { let mut device_name: Vec = wide_string(name); @@ -4281,6 +4488,87 @@ pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( #[cfg(test)] mod tests { use super::*; + + // Test-only reusable Win32 HANDLE RAII helper. + // If a future non-test path needs the same pattern, move it out of this test module. + // + // This struct is similar to `hbb_common::platform::windows::RAIIHandle`, + // but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate. + struct HandleGuard(WinHANDLE); + + impl HandleGuard { + #[inline] + fn new(handle: WinHANDLE) -> Self { + Self(handle) + } + + #[inline] + fn get(&self) -> WinHANDLE { + self.0 + } + } + + impl Drop for HandleGuard { + fn drop(&mut self) { + unsafe { + if !self.0.is_invalid() { + let _ = WinCloseHandle(self.0); + } + } + } + } + + #[test] + fn test_is_process_running_as_system_invalid_pid_errors() { + assert!(is_process_running_as_system(u32::MAX).is_err()); + } + + #[test] + fn test_is_process_running_as_system_matches_current_process_token_user() { + let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + let actual = is_process_running_as_system(pid).unwrap(); + + let expected = unsafe { + // Keep this test consistent: use only the `windows` crate APIs/types. + let process = HandleGuard::new( + WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + .expect("WinOpenProcess should succeed for current process"), + ); + let mut token = WinHANDLE::default(); + WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token) + .expect("WinOpenProcessToken should succeed for current process"); + let token = HandleGuard::new(token); + + let mut token_user_size = 0u32; + let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size); + assert_ne!(token_user_size, 0, "TokenUser size should be non-zero"); + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token.get(), + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .expect("WinGetTokenInformation(TokenUser) should succeed for current process"); + + let min_size = std::mem::size_of::(); + assert!( + buffer.len() >= min_size, + "TokenUser buffer too small (got {}, need >= {})", + buffer.len(), + min_size + ); + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool(); + expected + }; + + assert_eq!(actual, expected); + } + #[test] fn test_uninstall_cert() { println!("uninstall driver certs: {:?}", cert::uninstall_cert()); diff --git a/src/platform/windows/acl.rs b/src/platform/windows/acl.rs new file mode 100644 index 000000000..682e66fed --- /dev/null +++ b/src/platform/windows/acl.rs @@ -0,0 +1,903 @@ +// https://learn.microsoft.com/en-us/windows/win32/secgloss/security-glossary + +use super::{read_token_user_buffer, wide_string, ResultType}; +use hbb_common::{anyhow::anyhow, bail}; +use std::{ + fs, io, + os::windows::{ffi::OsStrExt, fs::MetadataExt}, + path::Path, +}; +use windows::{ + core::{PCWSTR, PWSTR}, + Win32::{ + Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSidToSidW, GetNamedSecurityInfoW, + SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, + SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }, + ACE_FLAGS, ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, NO_INHERITANCE, + OBJECT_INHERIT_ACE, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + TOKEN_QUERY, TOKEN_USER, + }, + Storage::FileSystem::{FILE_ALL_ACCESS, FILE_GENERIC_WRITE}, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }, +}; + +const FILE_ATTRIBUTE_REPARSE_POINT_U32: u32 = 0x400; + +#[inline] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_U32) != 0 +} + +fn apply_grant_sid_allow_ace_to_path( + path: &Path, + sid_ptr: *mut std::ffi::c_void, + access_mask: u32, + is_group: bool, + is_dir: bool, +) -> ResultType<()> { + // Merge mode: read existing DACL and append/replace ACE via SetEntriesInAclW. + // https://learn.microsoft.com/en-us/windows/win32/secauthz/modifying-the-acls-of-an-object-in-c-- + let mut old_dacl: *mut ACL = std::ptr::null_mut(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let get_named_result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut old_dacl), + None, + &mut security_descriptor, + ) + }; + if get_named_result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + get_named_result.0 + ); + } + let _sd_guard = LocalAllocGuard(security_descriptor.0); + + let inherit_flags = if is_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let explicit_access = [make_sid_trustee_entry( + sid_ptr, + access_mask, + inherit_flags, + is_group, + )]; + let old_acl_option = if old_dacl.is_null() { + None + } else { + Some(old_dacl as *const ACL) + }; + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = unsafe { + SetEntriesInAclW( + Some(explicit_access.as_slice()), + old_acl_option, + &mut new_acl, + ) + }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +/// Grants `Everyone` on `dir` recursively for helper/runtime files that must be +/// readable/executable across user contexts. +/// +/// `access_mask` is the Win32 file access mask to grant recursively. +pub fn set_path_permission(dir: &Path, access_mask: u32) -> ResultType<()> { + let metadata = fs::symlink_metadata(dir).map_err(|e| { + anyhow!( + "Failed to inspect ACL target directory '{}': {}", + dir.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "ACL target directory is a reparse point and is rejected: '{}'", + dir.display() + ); + } + if !metadata.file_type().is_dir() { + bail!("ACL target is not a directory: '{}'", dir.display()); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0")?; + let mut stack = vec![dir.to_path_buf()]; + while let Some(path) = stack.pop() { + let metadata = fs::symlink_metadata(&path) + .map_err(|e| anyhow!("Failed to inspect ACL target '{}': {}", path.display(), e))?; + if is_reparse_point(&metadata) { + continue; + } + let is_dir = metadata.file_type().is_dir(); + apply_grant_sid_allow_ace_to_path( + &path, + everyone_sid.as_sid_ptr(), + access_mask, + true, + is_dir, + )?; + if !is_dir { + continue; + } + for entry in fs::read_dir(&path) + .map_err(|e| anyhow!("Failed to list ACL target dir '{}': {}", path.display(), e))? + { + let entry = entry.map_err(|e| { + anyhow!( + "Failed to read ACL target dir entry under '{}': {}", + path.display(), + e + ) + })?; + stack.push(entry.path()); + } + } + Ok(()) +} + +/// Returns the current process user SID as a standard SID string +/// (for example: `S-1-5-18`). +/// +/// Source: +/// - Official SID-to-string API (`ConvertSidToStringSidW`): +/// https://learn.microsoft.com/en-us/windows/win32/api/sddl/nf-sddl-convertsidtostringsidw +pub(crate) fn current_process_user_sid_string() -> ResultType { + let mut token = HANDLE::default(); + let result = (|| -> ResultType { + unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open current process token: {}", e))?; + } + + let buffer = unsafe { read_token_user_buffer(token, "current process")? }; + let token_user: TOKEN_USER = + unsafe { std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER) }; + if token_user.User.Sid.0.is_null() { + bail!("Token SID is null"); + } + + let mut sid_string_ptr = PWSTR::null(); + unsafe { + ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string_ptr).map_err(|e| { + anyhow!( + "ConvertSidToStringSidW failed for current process token SID: {}", + e + ) + })?; + } + if sid_string_ptr.is_null() { + bail!("ConvertSidToStringSidW returned null SID string pointer"); + } + let _sid_string_guard = LocalAllocGuard(sid_string_ptr.0 as *mut std::ffi::c_void); + unsafe { + sid_string_ptr + .to_string() + .map_err(|e| anyhow!("Failed to decode SID string as UTF-16: {}", e)) + } + })(); + + if !token.is_invalid() { + unsafe { + let _ = CloseHandle(token); + } + } + result +} + +/// Hardens ACLs for portable-service shared-memory path (directory or file). +/// +/// Why: +/// - Shared memory used by portable service carries runtime control/data and must not inherit +/// broad/default ACLs. +/// - We explicitly grant only trusted principals and remove broad groups to reduce local +/// privilege-boundary bypass risk. +/// +/// ACL policy applied via Win32 ACL APIs (`SetEntriesInAclW` + `SetNamedSecurityInfoW`): +/// - common (directory + file): +/// - `S-1-5-18` (LocalSystem): full control +/// - `S-1-5-32-544` (Built-in Administrators): full control +/// - `current_process_user_sid_string()` result: full control +/// - directory (`portable_service_shmem` parent): +/// - keep `Authenticated Users` directory-level write so other local accounts can +/// create their own runtime shmem files after account switching +/// - `FILE_GENERIC_WRITE + NO_INHERITANCE` means write/create on this directory itself; +/// it is intentionally not inherited by children. +/// Reference: +/// - File access rights: +/// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants +/// - ACE inheritance rules: +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules +/// - remove `Everyone` and `Users` grants +/// - file (`shared_memory*` flink): +/// - remove broad grants: +/// - `S-1-1-0` (Everyone) +/// - `S-1-5-11` (Authenticated Users) +/// - `S-1-5-32-545` (Users) +/// +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids +pub fn set_path_permission_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, true) +} + +#[inline] +pub fn validate_path_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + validate_portable_service_shmem_dir_target(path) +} + +#[inline] +pub fn set_path_permission_for_portable_service_shmem_file(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, false) +} + +#[derive(Debug)] +pub(super) struct LocalAllocGuard(*mut std::ffi::c_void); + +impl LocalAllocGuard { + #[inline] + pub(super) fn as_sid_ptr(&self) -> *mut std::ffi::c_void { + self.0 + } +} + +impl Drop for LocalAllocGuard { + fn drop(&mut self) { + if self.0.is_null() { + return; + } + // Buffers returned by ConvertStringSidToSidW / SetEntriesInAclW / + // ConvertSidToStringSidW are LocalAlloc-owned and must be LocalFree'ed. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0))); + } + } +} + +#[inline] +pub(super) fn sid_string_to_local_alloc_guard(sid: &str) -> ResultType { + let sid_utf16 = wide_string(sid); + let mut sid_ptr = PSID::default(); + unsafe { + ConvertStringSidToSidW(PCWSTR::from_raw(sid_utf16.as_ptr()), &mut sid_ptr) + .map_err(|e| anyhow!("ConvertStringSidToSidW failed for '{}': {}", sid, e))?; + } + if sid_ptr.0.is_null() { + bail!("ConvertStringSidToSidW returned null SID for '{}'", sid); + } + Ok(LocalAllocGuard(sid_ptr.0)) +} + +#[inline] +fn make_sid_trustee_entry( + sid_ptr: *mut std::ffi::c_void, + access_permissions: u32, + inheritance: ACE_FLAGS, + is_group: bool, +) -> EXPLICIT_ACCESS_W { + // `is_group` is explicitly provided by the caller from the concrete SID semantic + // (e.g. Administrators/Authenticated Users => group, LocalSystem/current user => user). + EXPLICIT_ACCESS_W { + grfAccessPermissions: access_permissions, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: Default::default(), + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: if is_group { + TRUSTEE_IS_GROUP + } else { + TRUSTEE_IS_USER + }, + // SAFETY: With TrusteeForm=TRUSTEE_IS_SID, ptstrName is interpreted as PSID. + ptstrName: PWSTR::from_raw(sid_ptr as *mut u16), + }, + } +} + +fn validate_portable_service_shmem_dir_target(path: &Path) -> ResultType<()> { + let metadata = fs::symlink_metadata(path).map_err(|e| { + anyhow!( + "Failed to inspect portable service shared-memory ACL directory '{}': {}", + path.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL directory target is a reparse point and is rejected: '{}'", + path.display() + ); + } + if !metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is not a directory: '{}'", + path.display() + ); + } + Ok(()) +} + +fn set_path_permission_for_portable_service_shmem_impl( + path: &Path, + expect_dir: bool, +) -> ResultType<()> { + if expect_dir { + validate_portable_service_shmem_dir_target(path)?; + } else { + let metadata_result = fs::symlink_metadata(path); + match metadata_result { + Ok(metadata) => { + if metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is a directory, expected file-like path: '{}'", + path.display() + ); + } + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL file target is a reparse point and is rejected: '{}'", + path.display() + ); + } + } + Err(e) + if e.kind() == io::ErrorKind::NotFound + || e.kind() == io::ErrorKind::PermissionDenied => + { + // Keep going and let Win32 ACL APIs return the final OS error. + // `Path::exists()/is_file()` and metadata can collapse ACL-denied paths into + // a false "not found" signal under restricted directory ACLs. + } + Err(e) => { + bail!( + "Failed to inspect portable service shared-memory ACL target '{}': {}", + path.display(), + e + ); + } + } + } + + let user_sid = current_process_user_sid_string()?; + let local_system_sid = sid_string_to_local_alloc_guard("S-1-5-18")?; + let administrators_sid = sid_string_to_local_alloc_guard("S-1-5-32-544")?; + let current_user_sid = sid_string_to_local_alloc_guard(&user_sid)?; + let authenticated_users_sid = if expect_dir { + Some(sid_string_to_local_alloc_guard("S-1-5-11")?) + } else { + None + }; + + let inherit_flags = if expect_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let mut entries = vec![ + make_sid_trustee_entry( + local_system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + make_sid_trustee_entry( + administrators_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + true, + ), + make_sid_trustee_entry( + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + ]; + if let Some(auth_sid) = authenticated_users_sid.as_ref() { + // Keep the shared parent directory multi-user writable at directory level. + entries.push(make_sid_trustee_entry( + auth_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0, + NO_INHERITANCE, + true, + )); + } + + // Rebuild mode: build a fresh DACL (old ACL not merged) and apply as protected. + // This avoids carrying over broad legacy ACEs from inherited/default ACLs. + // Reference: + // - SetEntriesInAclW: + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw + // - SetNamedSecurityInfoW (PROTECTED_DACL_SECURITY_INFORMATION): + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setnamedsecurityinfow + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = + unsafe { SetEntriesInAclW(Some(entries.as_slice()), None, &mut new_acl) }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let security_info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION; + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + security_info, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + current_process_user_sid_string, set_path_permission, + set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, sid_string_to_local_alloc_guard, + LocalAllocGuard, ResultType, + }; + use hbb_common::bail; + use std::{ + fs, + os::windows::{ffi::OsStrExt, fs::symlink_dir, fs::symlink_file}, + path::{Path, PathBuf}, + }; + use windows::{ + core::PCWSTR, + Win32::{ + Security::{ + AclSizeInformation, + Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, + EqualSid as WinEqualSid, GetAce, GetAclInformation, GetSecurityDescriptorControl, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, + DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + }, + Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }, + }, + }; + + const ACCESS_ALLOWED_ACE_TYPE_U8: u8 = 0; + + fn unique_acl_test_path(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rustdesk_acl_{}_{}_{}", + prefix, + std::process::id(), + hbb_common::rand::random::() + )) + } + + fn try_create_dir_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_dir(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create directory reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn try_create_file_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_file(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create file reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn get_file_dacl(path: &Path) -> ResultType<(*mut ACL, LocalAllocGuard)> { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut dacl), + None, + &mut sd, + ) + }; + if result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + result.0 + ); + } + if dacl.is_null() || sd.0.is_null() { + bail!("DACL/security descriptor missing for '{}'", path.display()); + } + Ok((dacl, LocalAllocGuard(sd.0))) + } + + fn has_allow_ace_with_mask( + dacl: *const ACL, + sid_ptr: *mut std::ffi::c_void, + mask: u32, + ) -> bool { + let mut info = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + dacl, + &mut info as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } + .is_err() + { + return false; + } + for index in 0..info.AceCount { + let mut ace_ptr: *mut std::ffi::c_void = std::ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace_ptr) }.is_err() || ace_ptr.is_null() { + continue; + } + let header = unsafe { &*(ace_ptr as *const ACE_HEADER) }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE_U8 { + continue; + } + let allowed = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; + let ace_sid = PSID((&allowed.SidStart as *const u32) as *mut std::ffi::c_void); + if unsafe { WinEqualSid(PSID(sid_ptr), ace_sid) }.is_ok() + && (allowed.Mask & mask) == mask + { + return true; + } + } + false + } + + fn has_any_allow_ace_for_sid(dacl: *const ACL, sid_ptr: *mut std::ffi::c_void) -> bool { + has_allow_ace_with_mask(dacl, sid_ptr, 0) + } + + fn is_dacl_protected(sd: PSECURITY_DESCRIPTOR) -> bool { + let mut control: u16 = 0; + let mut revision: u32 = 0; + if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) }.is_err() { + return false; + } + (control & SE_DACL_PROTECTED.0) != 0 + } + + #[test] + fn test_portable_service_shmem_dir_acl_policy() { + let dir = unique_acl_test_path("dir"); + fs::create_dir_all(&dir).unwrap(); + set_path_permission_for_portable_service_shmem_dir(&dir).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&dir).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + auth_users_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0 + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_policy() { + let dir = unique_acl_test_path("file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("shared_memory_portable_service_test"); + fs::write(&file, b"x").unwrap(); + set_path_permission_for_portable_service_shmem_file(&file).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&file).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(!has_any_allow_ace_for_sid( + dacl, + auth_users_sid.as_sid_ptr() + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_set_path_permission_rx_applies_recursively() { + let root = unique_acl_test_path("set_path_permission"); + let child_dir = root.join("child"); + let child_file = child_dir.join("helper.exe"); + fs::create_dir_all(&child_dir).unwrap(); + fs::write(&child_file, b"x").unwrap(); + + if let Err(err) = set_path_permission(&root, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) { + let text = err.to_string(); + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + if text.contains("win32_error=5") || text.contains("Access is denied") { + eprintln!( + "skip test_set_path_permission_rx_applies_recursively: insufficient WRITE_DAC in current environment: {}", + text + ); + return; + } + panic!("set_path_permission failed unexpectedly: {}", text); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let rx_mask = FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0; + for target in [&root, &child_dir, &child_file] { + let (dacl, _sd_guard) = get_file_dacl(target).unwrap(); + assert!( + has_allow_ace_with_mask(dacl, everyone_sid.as_sid_ptr(), rx_mask), + "Everyone RX grant missing on '{}'", + target.display() + ); + } + + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_file_target() { + let dir = unique_acl_test_path("dir_target_file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("target.txt"); + fs::write(&file, b"x").unwrap(); + let result = set_path_permission_for_portable_service_shmem_dir(&file); + assert!(result.is_err()); + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_dir_target() { + let dir = unique_acl_test_path("file_target_dir"); + fs::create_dir_all(&dir).unwrap(); + let result = set_path_permission_for_portable_service_shmem_file(&dir); + assert!(result.is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_missing_target() { + let path = unique_acl_test_path("missing").join("shared_memory_missing"); + let result = set_path_permission_for_portable_service_shmem_file(&path); + assert!(result.is_err()); + } + + #[test] + fn test_set_path_permission_rejects_reparse_entrypoint() { + let root = unique_acl_test_path("reparse_entry"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_set_path_permission_rejects_reparse_entrypoint", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission(&link_dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_dir"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_portable_service_shmem_dir_acl_rejects_reparse_target", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_dir(&link_dir); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_file"); + let real_file = root.join("real.txt"); + let link_file = root.join("link.txt"); + fs::create_dir_all(&root).unwrap(); + fs::write(&real_file, b"x").unwrap(); + if !try_create_file_reparse_point( + &real_file, + &link_file, + "test_portable_service_shmem_file_acl_rejects_reparse_target", + ) { + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_file(&link_file); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_file(&link_file); + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/server.rs b/src/server.rs index dddc762bf..e11003faa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -731,7 +731,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { if !synced { if conn.send(&Data::SyncConfig(None)).await.is_ok() { @@ -772,6 +772,12 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { log::error!("sync config to root failed: {}", e); - match crate::ipc::connect(1000, "_service").await { + match crate::ipc::connect_service(1000).await { Ok(mut _conn) => { conn = _conn; log::info!("reconnected to ipc_service"); diff --git a/src/server/connection.rs b/src/server/connection.rs index a960daac1..f5019e447 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -22,8 +22,6 @@ use crate::{ #[cfg(any(target_os = "android", target_os = "ios"))] use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel}; use cidr_utils::cidr::IpCidr; -#[cfg(target_os = "linux")] -use hbb_common::platform::linux::run_cmds; #[cfg(target_os = "android")] use hbb_common::protobuf::EnumOrUnknown; use hbb_common::{ @@ -4983,6 +4981,9 @@ pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { } #[cfg(not(any(target_os = "android", target_os = "ios")))] +// IPC bootstrap summary: +// - Resolve target CM socket (headless/non-headless, optional UID-scoped path on Linux). +// - Start CM when missing, then bridge bidirectional messages between this task and CM IPC. async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, @@ -4997,10 +4998,19 @@ async fn start_ipc( } sleep(1.).await; } + #[cfg(target_os = "linux")] + let headless_cm = crate::is_server() + && crate::platform::is_headless_allowed() + && linux_desktop_manager::is_headless(); + #[cfg(not(target_os = "linux"))] + let headless_cm = false; let mut stream = None; - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); - } else { + if !headless_cm { + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + } + } + if stream.is_none() { #[allow(unused_mut)] #[allow(unused_assignments)] let mut args = vec!["--cm"]; @@ -5010,75 +5020,123 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] - if crate::platform::is_headless_allowed() && linux_desktop_manager::is_headless() { + if headless_cm { let mut username = linux_desktop_manager::get_username(); loop { if !username.is_empty() { break; } + // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes + // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. + // TODO: + // When `_rx_desktop_ready` is closed, `recv()` returns + // `None` immediately and this loop may spin if `username` remains empty. + // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by + // breaking/returning to avoid hot-looping. let _res = timeout(1_000, _rx_desktop_ready.recv()).await; username = linux_desktop_manager::get_username(); } let uid = { - let output = run_cmds(&format!("id -u {}", &username))?; + let username_for_cmd = username.clone(); + let mut uid_cmd = hbb_common::tokio::process::Command::new("id"); + // TODO: + // Keep current behavior for now to minimize change risk. + // If usernames starting with '-' are observed in the field, prefer: + // `id -u -- ` to avoid option-parsing ambiguity. + // Already verified that `id -u -- ` works as expected on macOS and Ubuntu 24.04. + uid_cmd.arg("-u").arg(&username_for_cmd).kill_on_drop(true); + let output = timeout(10_000, uid_cmd.output()) + .await + .map_err(|_| anyhow!("Timed out querying uid for {}", username))? + .map_err(|e| anyhow!("Failed to run `id -u {}`: {}", username, e))?; + if !output.status.success() { + bail!("Failed to query uid for {}", username); + } + let output = String::from_utf8_lossy(&output.stdout); let output = output.trim(); - if output.is_empty() || !output.parse::().is_ok() { - bail!("Invalid username {}", &username); + if output.parse::().is_err() { + bail!("Invalid uid {}", output); } output.to_string() }; user = Some((uid, username)); args = vec!["--cm-no-ui"]; } - let run_done; - if crate::platform::is_root() { - let mut res = Ok(None); - for _ in 0..10 { - #[cfg(not(any(target_os = "linux")))] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user(args.clone()); - } - #[cfg(target_os = "linux")] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user( - args.clone(), - user.clone(), - None::<(&str, &str)>, - ); - } - if res.is_ok() { - break; - } - log::error!("Failed to run cm: {res:?}"); - sleep(1.).await; - } - if let Some(task) = res? { - super::CHILD_PROCESS.lock().unwrap().push(task); - } - run_done = true; - } else { - run_done = false; - } - if !run_done { - log::debug!("Start cm"); - super::CHILD_PROCESS - .lock() - .unwrap() - .push(crate::run_me(args)?); - } - for _ in 0..20 { - sleep(0.3).await; - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + #[cfg(target_os = "linux")] + let cm_uid: Option = match &user { + Some((uid, _)) => Some( + uid.parse::() + .map_err(|_| anyhow!("Invalid uid {}", uid))?, + ), + None => None, + }; + #[cfg(target_os = "linux")] + if let Some(uid) = cm_uid { + if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { stream = Some(s); - break; } } if stream.is_none() { - bail!("Failed to connect to connection manager"); + let run_done; + if crate::platform::is_root() { + let mut res = Ok(None); + for _ in 0..10 { + #[cfg(not(any(target_os = "linux")))] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone()); + } + #[cfg(target_os = "linux")] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user( + args.clone(), + user.clone(), + None::<(&str, &str)>, + ); + } + if res.is_ok() { + break; + } + log::error!("Failed to run cm: {res:?}"); + sleep(1.).await; + } + if let Some(task) = res? { + super::CHILD_PROCESS.lock().unwrap().push(task); + } + run_done = true; + } else { + run_done = false; + } + if !run_done { + log::debug!("Start cm"); + super::CHILD_PROCESS + .lock() + .unwrap() + .push(crate::run_me(args)?); + } + for _ in 0..20 { + sleep(0.3).await; + #[cfg(target_os = "linux")] + { + if let Some(uid) = cm_uid { + if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { + stream = Some(s); + break; + } + continue; + } + } + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + break; + } + } } } + if stream.is_none() { + bail!("Failed to connect to connection manager"); + } let _res = tx_stream_ready.send(()).await; let mut stream = stream.ok_or(anyhow!("none stream"))?; diff --git a/src/server/portable_service.rs b/src/server/portable_service.rs index 6f5695046..23b69a70c 100644 --- a/src/server/portable_service.rs +++ b/src/server/portable_service.rs @@ -1,3 +1,11 @@ +use crate::{ + ipc::{self, new_listener, Connection, Data, DataPortableService, IPC_TOKEN_LEN}, + platform::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, + }, +}; use core::slice; use hbb_common::{ allow_err, @@ -15,26 +23,26 @@ use shared_memory::*; use std::{ mem::size_of, ops::{Deref, DerefMut}, - path::Path, - sync::{Arc, Mutex}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, time::Duration, }; use winapi::{ shared::minwindef::{BOOL, FALSE, TRUE}, um::winuser::{self, CURSORINFO, PCURSORINFO}, }; - -use crate::{ - ipc::{self, new_listener, Connection, Data, DataPortableService}, - platform::set_path_permission, -}; +use windows::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; use super::video_qos; const SIZE_COUNTER: usize = size_of::() * 2; const FRAME_ALIGN: usize = 64; -const ADDR_CURSOR_PARA: usize = 0; +const ADDR_IPC_TOKEN: usize = 0; +const ADDR_CURSOR_PARA: usize = ADDR_IPC_TOKEN + IPC_TOKEN_LEN; const ADDR_CURSOR_COUNTER: usize = ADDR_CURSOR_PARA + size_of::(); const ADDR_CAPTURER_PARA: usize = ADDR_CURSOR_COUNTER + SIZE_COUNTER; @@ -44,12 +52,186 @@ const ADDR_CAPTURE_FRAME_COUNTER: usize = ADDR_CAPTURE_WOULDBLOCK + size_of:: bool { + !name.is_empty() + && name.len() <= SHMEM_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +#[inline] +pub fn portable_service_shmem_arg(name: &str) -> String { + format!("{SHMEM_ARG_PREFIX}{name}") +} + +#[inline] +fn is_valid_portable_service_ipc_token(token: &str) -> bool { + token.len() == IPC_TOKEN_LEN + && token + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[inline] +fn read_ipc_token_from_shmem(shmem: &SharedMemory) -> Option { + if shmem.len() < ADDR_IPC_TOKEN + IPC_TOKEN_LEN { + log::error!( + "Portable service shared memory too small: len={}, need>={}", + shmem.len(), + ADDR_IPC_TOKEN + IPC_TOKEN_LEN + ); + return None; + } + unsafe { + let ptr = shmem.as_ptr().add(ADDR_IPC_TOKEN); + let bytes = slice::from_raw_parts(ptr, IPC_TOKEN_LEN); + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(IPC_TOKEN_LEN); + if end == 0 { + return None; + } + let token = std::str::from_utf8(&bytes[..end]).ok()?.to_owned(); + if is_valid_portable_service_ipc_token(&token) { + Some(token) + } else { + None + } + } +} + +#[inline] +fn validate_runtime_shmem_layout(shmem: &SharedMemory) -> ResultType<()> { + if shmem.len() < MIN_RUNTIME_SHMEM_LEN { + bail!( + "Portable service shared memory too small for runtime layout: len={}, need>={}", + shmem.len(), + MIN_RUNTIME_SHMEM_LEN + ); + } + Ok(()) +} + +#[inline] +fn is_valid_capture_frame_length(shmem_len: usize, frame_len: usize) -> bool { + let frame_capacity = shmem_len.saturating_sub(ADDR_CAPTURE_FRAME); + frame_len > 0 && frame_len <= frame_capacity +} + +#[inline] +fn shared_memory_flink_path_by_name(name: &str) -> ResultType { + let mut dir = crate::platform::user_accessible_folder()?; + dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); + dir = dir.join(SHMEM_PARENT_DIR); + Ok(dir.join(format!("shared_memory{}", name))) +} + +#[inline] +fn remove_shared_memory_flink_once(name: &str, log_on_error: bool, log_context: &str) -> bool { + let flink = match shared_memory_flink_path_by_name(name) { + Ok(path) => path, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to resolve portable service shared-memory flink path for '{}': {}", + log_context, + name, + err + ); + } + return false; + } + }; + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "{} removed portable service shared-memory flink artifact: {:?}", + log_context, + flink + ); + true + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to remove portable service shared-memory flink artifact {:?}: {}", + log_context, + flink, + err + ); + } + false + } + } +} + +#[inline] +fn write_ipc_token_to_shmem(shmem: &SharedMemory, token: &str) -> ResultType<()> { + if !is_valid_portable_service_ipc_token(token) { + bail!("Invalid portable service ipc token"); + } + shmem.write(ADDR_IPC_TOKEN, token.as_bytes()); + Ok(()) +} + +#[inline] +fn clear_ipc_token_in_shmem(shmem: &SharedMemory) { + shmem.write(ADDR_IPC_TOKEN, &[0u8; IPC_TOKEN_LEN]); +} + +#[inline] +fn portable_service_arg_value_candidate_from_arg<'a>( + arg: &'a str, + prefix: &str, +) -> Option<&'a str> { + let mut value = arg.strip_prefix(prefix)?; + value = value.trim_start(); + value = value + .strip_prefix('"') + .or_else(|| value.strip_prefix('\'')) + .unwrap_or(value); + value = value.split_whitespace().next().unwrap_or_default(); + value = value.trim_matches(|c| c == '"' || c == '\''); + Some(value) +} + +#[inline] +pub fn portable_service_shmem_name_from_args() -> Option { + for arg in std::env::args() { + if let Some(value) = portable_service_arg_value_candidate_from_arg(&arg, SHMEM_ARG_PREFIX) { + if is_valid_portable_service_shmem_name(value) { + return Some(value.to_owned()); + } + log::error!( + "Invalid portable service shared memory name argument: '{}'", + value + ); + return None; + } + } + None +} + +#[inline] +pub fn has_portable_service_shmem_arg() -> bool { + std::env::args().any(|arg| arg.starts_with(SHMEM_ARG_PREFIX)) +} + pub struct SharedMemory { inner: Shmem, } @@ -92,7 +274,27 @@ impl SharedMemory { } }; log::info!("Create shared memory, size: {}, flink: {}", size, flink); - set_path_permission(Path::new(&flink), "F").ok(); + if let Err(err) = set_path_permission_for_portable_service_shmem_file(Path::new(&flink)) { + // Release shmem handle first so best-effort flink cleanup has a chance to succeed. + drop(shmem); + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "Create cleanup removed portable service shared-memory flink artifact: {}", + flink + ); + } + Err(remove_err) if remove_err.kind() == std::io::ErrorKind::NotFound => {} + Err(remove_err) => { + log::warn!( + "Create cleanup failed to remove portable service shared-memory flink artifact {}: {}", + flink, + remove_err + ); + } + } + return Err(err); + } Ok(SharedMemory { inner: shmem }) } @@ -120,9 +322,18 @@ impl SharedMemory { fn flink(name: String) -> ResultType { let mut dir = crate::platform::user_accessible_folder()?; dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); - if !dir.exists() { - std::fs::create_dir(&dir)?; - set_path_permission(&dir, "F").ok(); + dir = dir.join(SHMEM_PARENT_DIR); + let parent_created = !dir.exists(); + if parent_created { + std::fs::create_dir_all(&dir)?; + } + if parent_created || crate::platform::is_root() { + // Harden parent ACL on first provisioning and periodically on SYSTEM path. + set_path_permission_for_portable_service_shmem_dir(&dir)?; + } else { + // Existing parents still need type/reparse validation. Non-SYSTEM callers may lack + // WRITE_DAC on a valid parent, so avoid rebuilding the ACL here. + validate_path_for_portable_service_shmem_dir(&dir)?; } Ok(dir .join(format!("shared_memory{}", name)) @@ -232,16 +443,45 @@ pub mod server { lazy_static::lazy_static! { static ref EXIT: Arc> = Default::default(); + static ref FORCE_EXIT_ARMED: AtomicBool = AtomicBool::new(false); } pub fn run_portable_service() { - let shmem = match SharedMemory::open_existing(SHMEM_NAME) { + let shmem_name = match portable_service_shmem_name_from_args() { + Some(name) => name, + None => { + if has_portable_service_shmem_arg() { + log::error!( + "Invalid portable service shared memory argument, aborting startup" + ); + } else { + log::error!( + "Missing portable service shared memory argument, aborting startup" + ); + } + return; + } + }; + let shmem = match SharedMemory::open_existing(&shmem_name) { Ok(shmem) => Arc::new(shmem), Err(e) => { log::error!("Failed to open existing shared memory: {:?}", e); return; } }; + if let Err(e) = validate_runtime_shmem_layout(shmem.as_ref()) { + log::error!("{}", e); + return; + } + let ipc_token = match read_ipc_token_from_shmem(shmem.as_ref()) { + Some(token) => token, + None => { + log::error!( + "Missing portable service ipc token in shared memory, aborting startup" + ); + return; + } + }; let shmem1 = shmem.clone(); let shmem2 = shmem.clone(); let mut threads = vec![]; @@ -251,17 +491,24 @@ pub mod server { threads.push(std::thread::spawn(|| { run_capture(shmem2); })); - threads.push(std::thread::spawn(|| { - run_ipc_client(); + threads.push(std::thread::spawn(move || { + run_ipc_client(ipc_token); })); - threads.push(std::thread::spawn(|| { + // Detached shutdown watchdog: + // - gives graceful shutdown/cleanup a short window + // - force-exits the process if workers are still stuck + std::thread::spawn(|| { run_exit_check(); - })); + }); let record_pos_handle = crate::input_service::try_start_record_cursor_pos(); + // Arm forced-exit watchdog only for worker join phase. + // Once join phase completes, cleanup should not be interrupted by forced exit. + FORCE_EXIT_ARMED.store(true, Ordering::SeqCst); for th in threads.drain(..) { th.join().ok(); log::info!("thread joined"); } + FORCE_EXIT_ARMED.store(false, Ordering::SeqCst); crate::input_service::try_stop_record_cursor_pos(); if let Some(handle) = record_pos_handle { @@ -270,16 +517,47 @@ pub mod server { Err(e) => log::error!("record_pos_handle join error {:?}", &e), } } + drop(shmem); + remove_shared_memory_flink_with_retry(&shmem_name); } fn run_exit_check() { + const FORCED_EXIT_DELAY: Duration = Duration::from_secs(3); loop { if EXIT.lock().unwrap().clone() { - std::thread::sleep(Duration::from_millis(50)); - std::process::exit(0); + break; } std::thread::sleep(Duration::from_millis(50)); } + // Fallback only: normal shutdown path should complete and process should exit naturally. + // This forced exit is a last resort when worker threads are stuck and graceful teardown + // does not finish in time. + std::thread::sleep(FORCED_EXIT_DELAY); + if FORCE_EXIT_ARMED.load(Ordering::SeqCst) { + log::warn!( + "Portable service shutdown watchdog fallback triggered: forcing process exit after {:?}", + FORCED_EXIT_DELAY + ); + std::process::exit(0); + } + } + + fn remove_shared_memory_flink_with_retry(name: &str) { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for attempt in 0..MAX_RETRY { + let is_last_attempt = attempt + 1 == MAX_RETRY; + if remove_shared_memory_flink_once(name, is_last_attempt, "SYSTEM cleanup") { + return; + } + if !is_last_attempt { + std::thread::sleep(RETRY_INTERVAL); + } + } + log::warn!( + "SYSTEM cleanup failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); } fn run_get_cursor_info(shmem: Arc) { @@ -386,6 +664,17 @@ pub mod server { match c.as_mut().map(|f| f.frame(spf)) { Some(Ok(f)) => match f { Frame::PixelBuffer(f) => { + let frame_capacity = shmem.len().saturating_sub(ADDR_CAPTURE_FRAME); + if f.data().len() > frame_capacity { + log::error!( + "Portable service capture frame exceeds shared memory capacity: frame_len={}, capacity={}, shmem_len={}", + f.data().len(), + frame_capacity, + shmem.len() + ); + *EXIT.lock().unwrap() = true; + return; + } utils::set_frame_info( &shmem, FrameInfo { @@ -436,17 +725,33 @@ pub mod server { } #[tokio::main(flavor = "current_thread")] - async fn run_ipc_client() { + async fn run_ipc_client(ipc_token: String) { use DataPortableService::*; let postfix = IPC_SUFFIX; match ipc::connect(1000, postfix).await { Ok(mut stream) => { + if let Err(err) = + ipc::portable_service_ipc_handshake_as_client(&mut stream, &ipc_token).await + { + log::error!("portable service ipc handshake failed: {}", err); + *EXIT.lock().unwrap() = true; + return; + } let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; loop { + if *EXIT.lock().unwrap() { + log::info!("Portable service EXIT signaled, closing ipc client loop"); + stream + .send(&Data::DataPortableService(WillClose)) + .await + .ok(); + break; + } + tokio::select! { res = stream.next() => { match res { @@ -526,7 +831,11 @@ pub mod client { lazy_static::lazy_static! { static ref RUNNING: Arc> = Default::default(); + static ref STARTING: Arc> = Default::default(); + static ref STARTING_TOKEN: AtomicU64 = AtomicU64::new(0); static ref SHMEM: Arc>> = Default::default(); + static ref SHMEM_RUNTIME_NAME: Arc>> = Default::default(); + static ref IPC_RUNTIME_TOKEN: Arc>> = Default::default(); static ref SENDER : Mutex> = Mutex::new(client::start_ipc_server()); static ref QUICK_SUPPORT: Arc> = Default::default(); } @@ -536,12 +845,176 @@ pub mod client { Logon(String, String), } + fn has_running_portable_service_process() -> bool { + let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase()); + !crate::platform::get_pids_of_process_with_first_arg(&app_exe, "--portable-service") + .is_empty() + } + + #[inline] + fn next_portable_service_shmem_name() -> String { + format!( + "{}_{}_{:08x}", + crate::portable_service::SHMEM_NAME, + std::process::id(), + hbb_common::rand::random::() + ) + } + + #[inline] + fn set_runtime_ipc_token(token: String) { + *IPC_RUNTIME_TOKEN.lock().unwrap() = Some(token); + } + + #[inline] + fn schedule_remove_runtime_shmem_flink_retry(name: String) { + std::thread::spawn(move || { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for _ in 0..MAX_RETRY { + std::thread::sleep(RETRY_INTERVAL); + if remove_shared_memory_flink_once(&name, false, "Client cleanup") { + return; + } + } + log::warn!( + "Failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); + }); + } + + #[inline] + fn clear_runtime_shmem_state() { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + let mut shmem_lock = SHMEM.lock().unwrap(); + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + *shmem_lock = None; + let runtime_name = SHMEM_RUNTIME_NAME.lock().unwrap().take(); + *runtime_token = None; + drop(runtime_token); + drop(shmem_lock); + if let Some(name) = runtime_name.as_deref() { + if !remove_shared_memory_flink_once(name, true, "Client cleanup") { + schedule_remove_runtime_shmem_flink_retry(name.to_owned()); + } + } + } + + #[inline] + fn consume_runtime_ipc_token_if_match(candidate: &str) -> (bool, Option) { + let mut token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if !token + .as_deref() + .is_some_and(|expected| ipc::constant_time_ipc_token_eq(expected, candidate)) + { + return (false, None); + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let matched_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + *token = None; + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + (true, matched_shmem_name) + } + + #[inline] + fn restore_runtime_ipc_token_after_failed_handshake( + token: &str, + expected_shmem_name: Option<&str>, + ) { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if let Some(current) = runtime_token.as_deref() { + if current != token { + log::debug!( + "Skip restoring portable service ipc token after handshake failure: runtime token has changed to a newer value" + ); + return; + } + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let current_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + if current_shmem_name.as_deref() != expected_shmem_name { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::debug!( + "Skip restoring portable service ipc token after handshake failure: shared-memory instance has changed" + ); + return; + } + let shmem_write_error = if let Some(shmem) = shmem_lock.as_mut() { + write_ipc_token_to_shmem(shmem, token) + .err() + .map(|err| err.to_string()) + } else { + Some("shared memory unavailable".to_owned()) + }; + if let Some(err) = shmem_write_error { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::warn!( + "Failed to restore portable service ipc token after handshake failure: {}", + err + ); + return; + } + *runtime_token = Some(token.to_owned()); + } + + #[inline] + fn schedule_starting_timeout_reset(launch_token: u64) { + std::thread::spawn(move || { + std::thread::sleep(PORTABLE_SERVICE_STARTUP_TIMEOUT); + let should_reset = { + // Guard against stale watchdogs from previous launches: + // only the watchdog that matches the latest STARTING_TOKEN may reset STARTING. + let current_token = STARTING_TOKEN.load(Ordering::SeqCst); + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let starting = { *STARTING.lock().unwrap() }; + let running = { *RUNNING.lock().unwrap() }; + current_token == launch_token && starting && !running + }; + if should_reset { + log::warn!( + "Portable service startup timeout before IPC ready, reset STARTING state" + ); + *STARTING.lock().unwrap() = false; + } + }); + } + + // Launch flow summary: + // 1) Prepare/reset runtime shared memory + IPC token. + // 2) Start helper process (direct or logon) with shmem argument. + // 3) Keep STARTING=true until IPC ping/pong marks RUNNING, or timeout watchdog resets it. pub(crate) fn start_portable_service(para: StartPara) -> ResultType<()> { log::info!("start portable service"); - if RUNNING.lock().unwrap().clone() { - bail!("already running"); - } - if SHMEM.lock().unwrap().is_none() { + let launch_token = { + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let running = { *RUNNING.lock().unwrap() }; + let mut starting = STARTING.lock().unwrap(); + if *starting && !running && !has_running_portable_service_process() { + log::warn!( + "Detected stale portable service STARTING state without running process, reset it" + ); + *starting = false; + } + if *starting || running { + bail!("already running"); + } + *starting = true; + STARTING_TOKEN.fetch_add(1, Ordering::SeqCst) + 1 + }; + let start_result = (|| -> ResultType<()> { + clear_runtime_shmem_state(); + let mut shmem_lock = SHMEM.lock().unwrap(); let displays = scrap::Display::all()?; if displays.is_empty() { bail!("no display available!"); @@ -558,84 +1031,153 @@ pub mod client { } } } - let shmem_size = utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align); + let shmem_size = + utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align).max(MIN_RUNTIME_SHMEM_LEN); + let shmem_name = next_portable_service_shmem_name(); + if !is_valid_portable_service_shmem_name(&shmem_name) { + bail!("Generated invalid portable service shared memory name"); + } + let ipc_token = ipc::generate_one_time_ipc_token()?; // os error 112, no enough space - *SHMEM.lock().unwrap() = Some(crate::portable_service::SharedMemory::create( - crate::portable_service::SHMEM_NAME, + *shmem_lock = Some(crate::portable_service::SharedMemory::create( + &shmem_name, shmem_size, )?); + *SHMEM_RUNTIME_NAME.lock().unwrap() = Some(shmem_name); shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory); - } - if let Some(shmem) = SHMEM.lock().unwrap().as_mut() { - unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); - } - } - match para { - StartPara::Direct => { - if let Err(e) = crate::platform::run_background( - &std::env::current_exe()?.to_string_lossy().to_string(), - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + let shmem_name = SHMEM_RUNTIME_NAME + .lock() + .unwrap() + .clone() + .ok_or_else(|| anyhow!("portable service shared memory name is unavailable"))?; + let init_token_result = if let Some(shmem) = shmem_lock.as_mut() { + unsafe { + libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); } + write_ipc_token_to_shmem(shmem, &ipc_token) + } else { + Ok(()) + }; + if let Err(e) = init_token_result { + drop(shmem_lock); + clear_runtime_shmem_state(); + bail!( + "Failed to initialize portable service ipc token in shared memory: {}", + e + ); + }; + drop(shmem_lock); + set_runtime_ipc_token(ipc_token.clone()); + let portable_service_arg = format!( + "--portable-service {}", + crate::portable_service::portable_service_shmem_arg(&shmem_name) + ); + { + let _sender = SENDER.lock().unwrap(); } - StartPara::Logon(username, password) => { - #[allow(unused_mut)] - let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); - #[cfg(feature = "flutter")] - { - if let Some(dir) = Path::new(&exe).parent() { - if set_path_permission(Path::new(dir), "RX").is_err() { - *SHMEM.lock().unwrap() = None; - bail!("Failed to set permission of {:?}", dir); + match para { + StartPara::Direct => { + match crate::platform::run_background( + &std::env::current_exe()?.to_string_lossy().to_string(), + &portable_service_arg, + ) { + Ok(true) => {} + Ok(false) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process"); + } + Err(e) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); } } } - #[cfg(not(feature = "flutter"))] - match hbb_common::directories_next::UserDirs::new() { - Some(user_dir) => { - let dir = user_dir - .home_dir() - .join("AppData") - .join("Local") - .join("rustdesk-sciter"); - if std::fs::create_dir_all(&dir).is_ok() { - let dst = dir.join("rustdesk.exe"); - if std::fs::copy(&exe, &dst).is_ok() { - if dst.exists() { - if set_path_permission(&dir, "RX").is_ok() { - exe = dst.to_string_lossy().to_string(); - } - } + StartPara::Logon(username, password) => { + #[allow(unused_mut)] + let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); + #[cfg(feature = "flutter")] + { + if let Some(dir) = Path::new(&exe).parent() { + if let Err(err) = set_path_permission( + Path::new(dir), + FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0, + ) { + clear_runtime_shmem_state(); + bail!("Failed to set permission of {:?}: {}", dir, err); } } } - None => {} - } - if let Err(e) = crate::platform::windows::create_process_with_logon( - username.as_str(), - password.as_str(), - &exe, - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + #[cfg(not(feature = "flutter"))] + if let Some((dir, dst)) = + crate::platform::windows::portable_service_logon_helper_paths() + { + let cleanup_helper_artifacts = || { + if Path::new(&exe) != dst { + std::fs::remove_file(&dst).ok(); + } + std::fs::remove_dir(&dir).ok(); + }; + let mut use_logon_helper_exe = false; + if let Err(err) = std::fs::create_dir_all(&dir) { + log::warn!( + "Failed to create portable service logon helper dir {:?}: {}", + dir, + err + ); + } else if let Err(err) = std::fs::copy(&exe, &dst) { + log::warn!( + "Failed to copy portable service logon helper binary from '{}' to {:?}: {}", + exe, + dst, + err + ); + cleanup_helper_artifacts(); + } else if !dst.exists() { + log::warn!( + "Portable service logon helper binary missing after copy: {:?}", + dst + ); + cleanup_helper_artifacts(); + } else if let Err(err) = + set_path_permission(&dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) + { + log::warn!( + "Failed to set portable service logon helper path permission for {:?}: {}", + dir, + err + ); + cleanup_helper_artifacts(); + } else { + use_logon_helper_exe = true; + } + if use_logon_helper_exe { + exe = dst.to_string_lossy().to_string(); + } + } + if let Err(e) = crate::platform::windows::create_process_with_logon( + username.as_str(), + password.as_str(), + &exe, + &portable_service_arg, + ) { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); + } } } + schedule_starting_timeout_reset(launch_token); + Ok(()) + })(); + if start_result.is_err() { + *STARTING.lock().unwrap() = false; } - let _sender = SENDER.lock().unwrap(); - Ok(()) + start_result } pub extern "C" fn drop_portable_service_shared_memory() { // https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout // Please make sure there is no print in the call stack - let mut lock = SHMEM.lock().unwrap(); - if lock.is_some() { - *lock = None; - } + clear_runtime_shmem_state(); } pub fn set_quick_support(v: bool) { @@ -655,7 +1197,11 @@ pub mod client { let mut option = SHMEM.lock().unwrap(); if let Some(shmem) = option.as_mut() { unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); + libc::memset( + shmem.as_ptr().add(ADDR_CURSOR_PARA) as _, + 0, + shmem.len().saturating_sub(ADDR_CURSOR_PARA) as _, + ); } utils::set_para( shmem, @@ -702,6 +1248,19 @@ pub mod client { if utils::counter_ready(base.add(ADDR_CAPTURE_FRAME_COUNTER)) { let frame_info_ptr = shmem.as_ptr().add(ADDR_CAPTURE_FRAME_INFO); let frame_info = frame_info_ptr as *const FrameInfo; + let frame_len = (*frame_info).length; + if !is_valid_capture_frame_length(shmem.len(), frame_len) { + log::error!( + "Portable service frame length exceeds shared memory capacity: frame_len={}, shmem_len={}, frame_addr={}", + frame_len, + shmem.len(), + ADDR_CAPTURE_FRAME + ); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid portable service frame length".to_string(), + )); + } if (*frame_info).width != self.width || (*frame_info).height != self.height { log::info!( "skip frame, ({},{}) != ({},{})", @@ -716,7 +1275,7 @@ pub mod client { )); } let frame_ptr = base.add(ADDR_CAPTURE_FRAME); - let data = slice::from_raw_parts(frame_ptr, (*frame_info).length); + let data = slice::from_raw_parts(frame_ptr, frame_len); Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA( data, self.width, @@ -778,10 +1337,49 @@ pub mod client { Some(result) = incoming.next() => { match result { Ok(stream) => { + let mut stream = Connection::new(stream); + if !ipc::authorize_windows_portable_service_ipc_connection( + &stream, postfix, + ) { + continue; + } + let mut consumed_token: Option = None; + let mut consumed_token_shmem_name: Option = None; + let handshake_result = + ipc::portable_service_ipc_handshake_as_server( + &mut stream, + |token| { + let (matched, matched_shmem_name) = + consume_runtime_ipc_token_if_match(token); + if matched { + consumed_token = Some(token.to_owned()); + consumed_token_shmem_name = matched_shmem_name; + true + } else { + false + } + }, + ) + .await; + if let Err(err) = handshake_result { + if let Some(token) = consumed_token.as_deref() { + restore_runtime_ipc_token_after_failed_handshake( + token, + consumed_token_shmem_name.as_deref(), + ); + *STARTING.lock().unwrap() = false; + } + log::warn!( + "Rejected portable service ipc connection due to token handshake failure: postfix={}, err={}", + postfix, + err + ); + continue; + } log::info!("Got portable service ipc connection"); let rx_clone = rx.clone(); tokio::spawn(async move { - let mut stream = Connection::new(stream); + let mut stream = stream; let postfix = postfix.to_owned(); let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; @@ -805,6 +1403,7 @@ pub mod client { Pong => { nack = 0; *RUNNING.lock().unwrap() = true; + *STARTING.lock().unwrap() = false; }, ConnCount(None) => { if !quick_support { @@ -841,6 +1440,7 @@ pub mod client { } } *RUNNING.lock().unwrap() = false; + *STARTING.lock().unwrap() = false; }); } Err(err) => { @@ -990,3 +1590,23 @@ pub struct FrameInfo { width: usize, height: usize, } + +#[cfg(test)] +mod tests { + use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME}; + + #[test] + fn test_is_valid_capture_frame_length_rejects_zero_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0)); + } + + #[test] + fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17)); + } + + #[test] + fn test_is_valid_capture_frame_length_accepts_in_bounds_length() { + assert!(is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 16)); + } +} diff --git a/src/server/uinput.rs b/src/server/uinput.rs index a808b4aaa..a1947d79f 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -185,9 +185,13 @@ pub mod client { pub mod service { use super::*; use hbb_common::lazy_static; + #[cfg(target_os = "linux")] + use parity_tokio_ipc::Connection as RawIpcConnection; use scrap::wayland::{ pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop, }; + #[cfg(target_os = "linux")] + use std::os::unix::io::AsRawFd; use std::{collections::HashMap, sync::Mutex}; lazy_static::lazy_static! { @@ -602,7 +606,10 @@ pub mod service { } DataKeyboard::KeyDown(enigo::Key::Raw(code)) => { if *code < 8 { - log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); } else { let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); allow_err!(keyboard.emit(&[down_event])); @@ -610,7 +617,10 @@ pub mod service { } DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { if *code < 8 { - log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); } else { let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); allow_err!(keyboard.emit(&[up_event])); @@ -909,6 +919,35 @@ pub mod service { }); } + #[cfg(target_os = "linux")] + fn authorize_uinput_peer(postfix: &str, stream: &RawIpcConnection) -> bool { + if !hbb_common::config::is_service_ipc_postfix(postfix) { + return true; + } + let peer_uid = ipc::peer_uid_from_fd(stream.as_raw_fd()); + let active_uid = crate::platform::linux::get_active_userid_fresh() + .trim() + .parse::() + .ok(); + let authorized = + peer_uid.is_some_and(|uid| ipc::is_allowed_service_peer_uid(uid, active_uid)); + if !authorized { + crate::ipc::log_rejected_uinput_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_fd(stream.as_raw_fd(), postfix) + { + log::warn!( + "Rejected connection on protected uinput ipc channel due to executable mismatch: postfix={}, err={}", + postfix, + err + ); + return false; + } + true + } + /// Start uinput service. async fn start_service(postfix: &str, handler: F) { match new_listener(postfix).await { @@ -916,6 +955,10 @@ pub mod service { while let Some(result) = incoming.next().await { match result { Ok(stream) => { + #[cfg(target_os = "linux")] + if !authorize_uinput_peer(postfix, &stream) { + continue; + } log::debug!("Got new connection of uinput ipc {}", postfix); handler(Connection::new(stream)); }