Files
rustdesk/src/ipc/fs.rs
fufesou 9df486a689 fix(ipc): harden local IPC authorization and portable-service bootstrap flow (#14671)
* fix(ipc): harden ipc access

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

* fix(ipc): full cmd path, comments, simple refactor

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

* fix(ipc): portable service, ipc exit

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

* fix(ipc): Remove unused logs

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

* fix(ipc): Use SetEntriesInAclW instead of icacls

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

* fix(ipc): Comments

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

* fix(ipc): check is_reparse_point

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

* fix(ipc): shmem name, no fallback

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

* fix(ipc): Simple refactor

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

* fix(ipc): better exit and clear

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

* fix(ipc): portable service, better exit

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

* fix(ipc): comments, id -u

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

* fix: comments linux headless, rx desktop ready

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

* fix(ipc): magic number

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

* fix(ipc): update deps

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

* Update Cargo.lock

* Update Cargo.lock

* fix(ipc): harden ipc, test `identity_unavailable`

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

* fix(ipc): portable service, check dir of shmem

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

* fix(ipc): macos, better check exe allowed

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

* fix(ipc): update hbb_common

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

* fix(ipc): update hbb_common

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

* fix(ipc): harden ipc, better active uid for uinput

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

* 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 <linlong1266@gmail.com>

* fix(ipc): simple refactor

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

* 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 <linlong1266@gmail.com>

* fix(ipc): comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-05-09 18:15:00 +08:00

952 lines
34 KiB
Rust

#[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<u32> {
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<i32> {
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<bool> {
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<usize> {
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::<usize>().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
));
}
}