Compare commits

...

3 Commits

Author SHA1 Message Date
rustdesk
9dd427d49f test(linux): pin the exe-check wiring, not just the pure decision
The matrix test covers `should_defer_exe_check_to_uid_gate` in isolation, so
rerouting the authorization path (e.g. degrading without consulting the
server's privilege) would still pass CI. Add an end-to-end test over
`ensure_peer_executable_matches_current_by_pid` using PID 1 as a real peer
whose executable differs from the test binary: an unprivileged server cannot
read its /proc/<pid>/exe and must defer to the uid gate, while a server that
can read it must still reject the mismatch. Both outcomes are asserted, so
the test is meaningful whether CI runs as root or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lq6xFoeEmjcuKwRx1GfdQ2
2026-09-06 09:24:58 +08:00
rustdesk
ee01e389df refactor(linux): make the exe-check degrade decision pure and tested
Address review feedback on #16088:

- Extract the privilege-dependent decision into a pure
  `should_defer_exe_check_to_uid_gate(err, server_is_unprivileged)` so both
  branches are unit-testable, and add a matrix test asserting a root server
  stays fail-closed while only an unprivileged server degrades on a
  permission error (a non-permission error never degrades).
- Reword the comment: a root server does not "always" read the peer's exe
  (a dropped CAP_SYS_PTRACE or cross-namespace peer can still fail); the
  invariant that matters is that a root server stays fail-closed regardless.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lq6xFoeEmjcuKwRx1GfdQ2
2026-09-06 09:17:31 +08:00
rustdesk
3f280f4759 fix(linux): don't reject IPC peers when /proc/<pid>/exe is unreadable
The service-scoped `_service` and `_uinput_*` IPC channels verify a peer is
the same RustDesk binary by reading /proc/<pid>/exe. That read was
fail-closed: any error, including EACCES from being unable to
ptrace-introspect the peer, was treated the same as a genuine binary
mismatch, so the peer was rejected. On non-systemd Linux (OpenRC, runit)
there is no root service, the IPC server runs unprivileged and cannot read
the peer's exe link, and remote keyboard/mouse were silently rejected while
screen capture kept working (#16073).

Distinguish "could not read the peer's exe" (EACCES/EPERM) from "read it and
it differs", and degrade only when the IPC server is itself unprivileged:
treat identity as unavailable and defer to the uid gate the callers already
enforce, mirroring the Windows portable-service "identity unavailable" path.
A root server can always read a same-namespace peer, so it stays fully
fail-closed; a readable but mismatched binary is still rejected; macOS and
Windows behavior is unchanged.

Fixes #16073

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lq6xFoeEmjcuKwRx1GfdQ2
2026-09-06 09:02:34 +08:00

View File

@@ -306,11 +306,14 @@ fn current_exe_canonical_path() -> ResultType<PathBuf> {
fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType<PathBuf> { fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType<PathBuf> {
let proc_exe = PathBuf::from(format!("/proc/{peer_pid}/exe")); let proc_exe = PathBuf::from(format!("/proc/{peer_pid}/exe"));
let peer_exe = fs::read_link(&proc_exe).map_err(|err| { let peer_exe = fs::read_link(&proc_exe).map_err(|err| {
anyhow::anyhow!( // Keep the io::Error as the source (not just its text) so the read's error kind survives for
// `peer_exe_read_permission_denied`, while the context preserves the same logged message.
let msg = format!(
"Failed to read peer executable link '{}': {}", "Failed to read peer executable link '{}': {}",
proc_exe.display(), proc_exe.display(),
err err
) );
anyhow::Error::new(err).context(msg)
})?; })?;
fs::canonicalize(&peer_exe).map_err(|err| { fs::canonicalize(&peer_exe).map_err(|err| {
anyhow::anyhow!( anyhow::anyhow!(
@@ -467,10 +470,57 @@ fn portable_service_helper_is_trusted(
peer_hash == current_hash peer_hash == current_hash
} }
// EACCES/EPERM from reading /proc/<pid>/exe (both map to `PermissionDenied`) means the peer could
// not be introspected, which is distinct from a successfully read but mismatched executable path.
#[cfg(target_os = "linux")]
#[inline]
fn peer_exe_read_permission_denied(err: &anyhow::Error) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::PermissionDenied)
}
// The degrade is confined to a NON-root server. In a standard install the IPC server is the root
// service, which normally can read a same-user-namespace peer's /proc/<pid>/exe; a root server that
// nonetheless fails the read (e.g. dropped CAP_SYS_PTRACE, or a cross-namespace peer) is anomalous
// and stays fail-closed. Only when the server itself is unprivileged (the non-systemd case where the
// service could not register as root and runs as the active user) can it not introspect ANY peer;
// there the executable check has no information to act on, so deferring to the uid gate is both the
// best achievable and the pre-1.4.7 behavior, while the hardened root-service path is untouched.
#[cfg(target_os = "linux")]
#[inline]
fn ipc_server_is_unprivileged() -> bool {
unsafe { libc::geteuid() != 0 }
}
// The privilege-dependent authorization decision, pure so both branches are unit-testable: defer to
// the uid gate only when an unprivileged server hit a permission error introspecting the peer. A root
// server (`server_is_unprivileged == false`) always stays fail-closed, and a non-permission error
// (e.g. the peer vanished) never degrades.
#[cfg(target_os = "linux")]
#[inline]
fn should_defer_exe_check_to_uid_gate(err: &anyhow::Error, server_is_unprivileged: bool) -> bool {
server_is_unprivileged && peer_exe_read_permission_denied(err)
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
#[inline] #[inline]
fn ensure_peer_executable_matches_current_by_pid(peer_pid: u32, postfix: &str) -> ResultType<()> { 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 peer_exe = match peer_exe_canonical_path_by_pid(peer_pid) {
Ok(peer_exe) => peer_exe,
Err(err) => {
#[cfg(target_os = "linux")]
if should_defer_exe_check_to_uid_gate(&err, ipc_server_is_unprivileged()) {
log::warn!(
"Peer executable link not introspectable on ipc channel '{}' by an unprivileged server (peer_pid={}): {}; identity unavailable, deferring to the uid gate",
postfix,
peer_pid,
err
);
return Ok(());
}
return Err(err);
}
};
let current_exe = current_exe_canonical_path()?; let current_exe = current_exe_canonical_path()?;
if executable_paths_match(&peer_exe, &current_exe) { if executable_paths_match(&peer_exe, &current_exe) {
return Ok(()); return Ok(());
@@ -1034,4 +1084,97 @@ mod tests {
.unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'")); .unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'"));
assert_eq!(parsed_uid, console_uid); assert_eq!(parsed_uid, console_uid);
} }
#[cfg(target_os = "linux")]
#[test]
fn test_peer_exe_read_permission_denied_classification() {
// EACCES/EPERM reading /proc/<pid>/exe (both map to `PermissionDenied`) means the peer is
// not introspectable and must be classified as such even after `context()` is attached.
for errno in [hbb_common::libc::EACCES, hbb_common::libc::EPERM] {
let err = hbb_common::anyhow::Error::new(std::io::Error::from_raw_os_error(errno))
.context("Failed to read peer executable link '/proc/1/exe'");
assert!(
super::peer_exe_read_permission_denied(&err),
"errno {errno} must classify as permission denied"
);
}
// A vanished peer (NotFound) or a non-io error must NOT classify as permission denied.
let not_found =
hbb_common::anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound))
.context("Failed to read peer executable link '/proc/1/exe'");
assert!(!super::peer_exe_read_permission_denied(&not_found));
assert!(!super::peer_exe_read_permission_denied(
&hbb_common::anyhow::anyhow!("canonicalize failure text")
));
}
#[cfg(target_os = "linux")]
#[test]
fn test_should_defer_exe_check_to_uid_gate_matrix() {
let eacces = || {
hbb_common::anyhow::Error::new(std::io::Error::from_raw_os_error(
hbb_common::libc::EACCES,
))
.context("Failed to read peer executable link '/proc/1/exe'")
};
let not_found = || {
hbb_common::anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound))
.context("Failed to read peer executable link '/proc/1/exe'")
};
// Only an unprivileged server that hit a permission error defers to the uid gate.
assert!(super::should_defer_exe_check_to_uid_gate(&eacces(), true));
// A root server stays fail-closed even on a permission error.
assert!(!super::should_defer_exe_check_to_uid_gate(&eacces(), false));
// A non-permission error never degrades, regardless of privilege.
assert!(!super::should_defer_exe_check_to_uid_gate(
&not_found(),
true
));
assert!(!super::should_defer_exe_check_to_uid_gate(
&not_found(),
false
));
}
// The matrix test above pins the pure decision; this one pins the WIRING, so rerouting the
// authorization path (e.g. degrading without consulting the server's privilege) cannot pass CI.
// PID 1 is a real peer whose executable differs from the test binary: an unprivileged server
// cannot read its /proc/<pid>/exe and must defer to the uid gate, while a server that can read
// it must still reject the mismatch.
#[cfg(target_os = "linux")]
#[test]
fn test_ensure_peer_executable_honors_server_privilege() {
let peer_exe_link = std::fs::read_link("/proc/1/exe");
let result = super::ensure_peer_executable_matches_current_by_pid(1, "_uinput_mouse");
match peer_exe_link {
Ok(peer_exe) => {
let peer = std::fs::canonicalize(&peer_exe).ok();
let current = std::env::current_exe()
.ok()
.and_then(|exe| std::fs::canonicalize(exe).ok());
// Only assert when the peer really is a different binary than this test.
if peer.is_some() && peer != current {
assert!(
result.is_err(),
"a readable but mismatched peer executable must be rejected"
);
}
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
if super::ipc_server_is_unprivileged() {
assert!(
result.is_ok(),
"an unprivileged server must defer to the uid gate when the peer executable is unreadable"
);
} else {
assert!(
result.is_err(),
"a root server must stay fail-closed when the peer executable is unreadable"
);
}
}
// No /proc, or PID 1 not inspectable for another reason: nothing to pin here.
Err(_) => {}
}
}
} }