mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-17 19:44:49 +03:00
* 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>
952 lines
34 KiB
Rust
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
|
|
));
|
|
}
|
|
}
|