diff --git a/SECURITY.md b/SECURITY.md index 0666fa02..0d19489b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -149,6 +149,17 @@ On Linux, clipboard requires access to Wayland sockets (`/run/user/UID/wayland-0 FreeBSD does not currently have sandboxing enabled. A full Capsicum sandbox using `cap_enter()` with `libcasper` for privileged process lookup is planned — see [ROADMAP.md](ROADMAP.md) for details. +### Root Uid Drop + +Until Capsicum lands, the primary containment on FreeBSD is a root privilege drop: when started as root (e.g. `sudo rustnet`), the process drops to the invoking user (`SUDO_UID`/`SUDO_GID`) or `nobody` after the BPF capture devices are open, via `setresuid`/`setresgid`. Already-open capture and log/export descriptors keep working, and pre-created export files are handed over to the target user. Note that `doas` does not set `SUDO_UID`, so doas users get the `nobody` fallback. + +Trade-off: process attribution uses `sockstat`, which as a non-root user only sees the target user's sockets. Use `--no-uid-drop` if you need attribution for other users' processes. + +``` +--no-uid-drop Keep running as root instead of dropping to + SUDO_UID/SUDO_GID (or nobody) after initialization +``` + ## Privilege Drop and Job Object Sandboxing (Windows) On Windows, RustNet removes dangerous privileges from the process token and applies a Job Object to prevent child process creation after initialization. diff --git a/SECURITY.zh-CN.md b/SECURITY.zh-CN.md index 4718f52f..945ad230 100644 --- a/SECURITY.zh-CN.md +++ b/SECURITY.zh-CN.md @@ -146,6 +146,17 @@ fd 带内到达),但 lsof 回退路径(PKTAP 不可用时启用,例如 FreeBSD 当前未启用沙箱。计划使用 `cap_enter()` 配合 `libcasper` 实现完整的 Capsicum 沙箱,用于特权进程查找——详见 [ROADMAP.md](ROADMAP.md)。 +### root uid 降权 + +在 Capsicum 落地之前,FreeBSD 上的主要隔离手段是 root 降权:以 root 启动时(如 `sudo rustnet`),BPF 捕获设备打开后,进程通过 `setresuid`/`setresgid` 降权到调用用户(`SUDO_UID`/`SUDO_GID`)或 `nobody`。已打开的捕获和日志/导出文件描述符继续有效,预创建的导出文件会移交给目标用户。注意 `doas` 不设置 `SUDO_UID`,因此 doas 用户会回退到 `nobody`。 + +权衡:进程归属使用 `sockstat`,非 root 用户只能看到目标用户的 socket。如需归属其他用户的进程,请使用 `--no-uid-drop`。 + +``` +--no-uid-drop 初始化后保持 root 运行, + 不降权到 SUDO_UID/SUDO_GID(或 nobody) +``` + ## 权限剥离与 Job Object 沙箱(Windows) 在 Windows 上,RustNet 在初始化后从进程令牌中移除危险特权,并应用 Job Object 阻止子进程创建。 diff --git a/USAGE.md b/USAGE.md index 2d39c187..9cc03070 100644 --- a/USAGE.md +++ b/USAGE.md @@ -104,7 +104,7 @@ Options: --no-sandbox Disable Landlock sandboxing (Linux only) --sandbox-strict Require full sandbox enforcement or exit (Linux only) --no-uid-drop Keep running as root instead of dropping to - SUDO_UID/SUDO_GID (or nobody) after initialization (Linux and macOS) + SUDO_UID/SUDO_GID (or nobody) after initialization (Linux, macOS, and FreeBSD) -h, --help Print help -V, --version Print version ``` diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index cf942232..5c3633fb 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -104,7 +104,7 @@ Options: --no-sandbox 禁用 Landlock 沙箱(仅限 Linux) --sandbox-strict 要求完整沙箱强制执行,否则退出(仅限 Linux) --no-uid-drop 初始化后保持 root 运行,不降权到 - SUDO_UID/SUDO_GID(或 nobody)(仅限 Linux 和 macOS) + SUDO_UID/SUDO_GID(或 nobody)(仅限 Linux、macOS 和 FreeBSD) -h, --help 打印帮助 -V, --version 打印版本 ``` diff --git a/src/app.rs b/src/app.rs index fabb86e1..58d24fa7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -801,10 +801,15 @@ impl App { /// parser threads in the worker phase is what places the untrusted-input DPI /// code inside the sandbox — even when rustnet runs as root. /// - /// Returns a receiver that signals when process detection initialization - /// (including eBPF loading) is complete. The caller should wait on this - /// before applying the sandbox so eBPF has finished loading. - pub fn start(&mut self) -> Result> { + /// Returns two receivers that signal when privileged initialization is + /// complete: the first when process detection (including eBPF loading) is + /// ready, the second when the capture thread has opened the capture + /// device (or failed to). The caller should wait on both before applying + /// the sandbox or dropping root: the capture open runs on a background + /// thread and still needs the privileges. + pub fn start( + &mut self, + ) -> Result<(std::sync::mpsc::Receiver<()>, std::sync::mpsc::Receiver<()>)> { info!("Starting network monitor application"); // Shared connection tracker (active + historic tables, RTT, QUIC) @@ -812,7 +817,7 @@ impl App { // Phase 1: privileged init. Start the capture pipeline (opens the raw // socket and stashes the packet receiver for the worker phase). - self.start_packet_capture_pipeline()?; + let capture_ready_rx = self.start_packet_capture_pipeline()?; // Create channel to signal when process detection (incl. eBPF) is ready let (process_ready_tx, process_ready_rx) = std::sync::mpsc::sync_channel(1); @@ -820,7 +825,7 @@ impl App { // Start process enrichment thread (but delay for PKTAP detection on macOS) self.start_process_enrichment_conditional(tracker.clone(), process_ready_tx)?; - Ok(process_ready_rx) + Ok((process_ready_rx, capture_ready_rx)) } /// Start the worker threads: the DPI packet processors plus enrichment, @@ -873,17 +878,20 @@ impl App { /// capture thread (which opens the raw socket). The receiver is stashed in /// `self.packet_rx` for [`App::start_packet_processors`], which runs in the /// worker phase after the sandbox has been applied. - fn start_packet_capture_pipeline(&mut self) -> Result<()> { + /// + /// Returns a receiver that fires once the capture thread has finished its + /// privileged setup (capture device opened, or setup failed). + fn start_packet_capture_pipeline(&mut self) -> Result> { // Create packet channel — sender batches packets, receiver gets Vec per batch let (packet_tx, packet_rx) = channel::bounded::>(MAX_PACKET_QUEUE); // Start capture thread - self.start_capture_thread(packet_tx)?; + let capture_ready_rx = self.start_capture_thread(packet_tx)?; // Stash the receiver; the processor threads are spawned post-sandbox. self.packet_rx = Some(packet_rx); - Ok(()) + Ok(capture_ready_rx) } /// Spawn the DPI packet-processor threads, draining the channel stashed by @@ -910,8 +918,15 @@ impl App { Ok(()) } - /// Start packet capture thread - fn start_capture_thread(&self, packet_tx: Sender>) -> Result<()> { + /// Start packet capture thread. + /// + /// Returns a receiver that fires once the capture device has been opened + /// (or the open failed). Callers use it to keep root privileges alive + /// until the open, which needs them, has actually happened. + fn start_capture_thread( + &self, + packet_tx: Sender>, + ) -> Result> { // Validate interface exists before spawning thread (fail fast) crate::network::capture::validate_interface(&self.config.interface)?; @@ -930,6 +945,10 @@ impl App { let pcap_export_file = self.config.pcap_export_file.clone(); capture_failed.store(false, Ordering::Relaxed); + // Fires once the privileged part of capture setup is done (device + // opened or open failed), so the main thread can drop privileges. + let (capture_ready_tx, capture_ready_rx) = std::sync::mpsc::sync_channel::<()>(1); + thread::Builder::new() .name("pcap_tx".to_string()) .spawn(move || { @@ -1008,6 +1027,10 @@ impl App { None }; + // Privileged setup is complete; the main thread may now + // drop root / apply the sandbox. + let _ = capture_ready_tx.send(()); + let mut reader = PacketReader::new(capture); let mut packets_read = 0u64; let mut last_log = Instant::now(); @@ -1134,6 +1157,7 @@ impl App { } Err(e) => { capture_failed.store(true, Ordering::Relaxed); + let _ = capture_ready_tx.send(()); let error_msg = format!("{}", e); // Check if this is a privilege error @@ -1156,7 +1180,7 @@ impl App { }) .expect("Failed to spawn pcap_tx thread"); - Ok(()) + Ok(capture_ready_rx) } /// Start a packet processor thread diff --git a/src/cli.rs b/src/cli.rs index c4436e1d..6aaf0f52 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -211,5 +211,17 @@ pub fn build_cli() -> Command { .action(clap::ArgAction::SetTrue), ); + #[cfg(target_os = "freebsd")] + let cmd = cmd.arg( + Arg::new("no-uid-drop") + .long("no-uid-drop") + .help( + "Keep running as root instead of dropping to SUDO_UID/SUDO_GID (or nobody) \ + after initialization. Keeping root lets sockstat attribute other users' \ + processes", + ) + .action(clap::ArgAction::SetTrue), + ); + cmd } diff --git a/src/main.rs b/src/main.rs index 25c2fc00..d2f973a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,13 +134,14 @@ fn main() -> Result<()> { info!("Kubernetes attribution mode: {}", mode); } - // Resolve the identity to drop root to after privileged init (Linux and - // macOS): the invoking sudo user, or nobody when started as plain root. + // Resolve the identity to drop root to after privileged init (Linux, + // macOS, and FreeBSD): the invoking sudo user, or nobody when started as + // plain root. // Resolved before the export files are pre-created so they can be chowned // to the target user: they are created root-owned 0600 and are reopened by // path at runtime (libpcap savefile, sidecar JSONL appends), which would // fail after the drop if they stayed owned by root. - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] let uid_drop_target = if matches.get_flag("no-uid-drop") { info!("Root uid drop disabled by --no-uid-drop"); None @@ -170,9 +171,9 @@ fn main() -> Result<()> { let file = precreate_private_file(path).map_err(|e| { anyhow::anyhow!("Failed to pre-create {} file '{}': {}", label, path, e) })?; - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] chown_to_uid_drop_target(&file, uid_drop_target, label, path); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))] let _ = file; } } @@ -182,7 +183,7 @@ fn main() -> Result<()> { let file = precreate_private_file(pcapng_path).map_err(|e| { anyhow::anyhow!("Failed to pre-create PCAPNG file '{}': {}", pcapng_path, e) })?; - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] chown_to_uid_drop_target(&file, uid_drop_target, "PCAPNG", pcapng_path); output_handles.pcapng_export = Some(file); } @@ -194,7 +195,7 @@ fn main() -> Result<()> { // Create and start the application let mut app = app::App::new_with_output_handles(config.clone(), output_handles)?; - let process_ready_rx = app.start()?; + let (process_ready_rx, capture_ready_rx) = app.start()?; info!("Application started"); // Wait for process detection (including eBPF loading) to complete before @@ -211,6 +212,21 @@ fn main() -> Result<()> { } } + // Also wait for the capture thread to finish opening the capture device. + // The open runs on a background thread and needs the startup privileges; + // without this synchronization the uid drop (Linux/FreeBSD) or sandbox + // could win the race and the open would fail with EPERM, leaving the UI + // running with no traffic. + match capture_ready_rx.recv_timeout(std::time::Duration::from_secs(10)) { + Ok(()) => info!("Packet capture initialized, safe to apply sandbox"), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + warn!("Timed out waiting for packet capture init, applying sandbox anyway"); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + warn!("Capture thread exited early, applying sandbox anyway"); + } + } + // Apply Landlock sandbox (Linux only) // This must be done AFTER process detection is initialized because: // - eBPF programs need to be loaded first (requires CAP_BPF + CAP_PERFMON) @@ -382,6 +398,22 @@ fn main() -> Result<()> { #[cfg(all(target_os = "macos", not(feature = "macos-sandbox")))] let _ = uid_dropped; + // Drop root privileges (FreeBSD only). Done after process detection init, + // when the BPF capture fds are open and nothing needs root anymore. There + // is no sandbox on FreeBSD yet (Capsicum is planned), so until then this + // is the primary containment. + #[cfg(target_os = "freebsd")] + if let Some(target) = uid_drop_target { + match network::platform::privdrop::drop_to(target) { + Ok(()) => info!( + "Dropped root privileges to uid {} gid {} (verified); sockstat process \ + attribution is now limited to that user's processes", + target.uid, target.gid + ), + Err(e) => warn!("Failed to drop root uid/gid: {}", e), + } + } + // Apply Seatbelt sandbox (macOS only) // This must be done AFTER app.start() because: // - Packet capture handles need to be opened first (BPF/PKTAP fds survive the sandbox) @@ -643,7 +675,7 @@ fn setup_logging(level: LevelFilter) -> Result<()> { /// be reopened by path after root privileges are dropped. Best-effort: on /// failure the drop still happens and the affected export degrades with a /// runtime warning when the reopen fails. -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] fn chown_to_uid_drop_target( file: &fs::File, target: Option, diff --git a/src/network/platform/freebsd/mod.rs b/src/network/platform/freebsd/mod.rs index 382dd86f..6d23a7c1 100644 --- a/src/network/platform/freebsd/mod.rs +++ b/src/network/platform/freebsd/mod.rs @@ -2,5 +2,6 @@ // Process attribution (sockstat) lives in the rustnet-host crate. mod interface_stats; +pub mod privdrop; pub use interface_stats::FreeBSDStatsProvider; diff --git a/src/network/platform/freebsd/privdrop.rs b/src/network/platform/freebsd/privdrop.rs new file mode 100644 index 00000000..1d75c6eb --- /dev/null +++ b/src/network/platform/freebsd/privdrop.rs @@ -0,0 +1,186 @@ +//! Root privilege drop (setuid) for FreeBSD +//! +//! `sudo rustnet` keeps euid 0 for its whole lifetime, even though root is +//! only needed during initialization to open the BPF capture devices. FreeBSD +//! has no sandbox layer yet (a Capsicum sandbox is planned, see ROADMAP.md), +//! so until then this drop is the primary containment: a compromise of the +//! packet-parsing code no longer runs as root. +//! +//! The process drops to the invoking sudo user (`SUDO_UID`/`SUDO_GID`), or to +//! `nobody` (65534) when started as plain root. `doas` does not set these +//! variables, so doas users get the `nobody` fallback. Already-open file +//! descriptors (capture devices, log and export files) remain valid across +//! the drop. +//! +//! # Trust model +//! +//! `SUDO_UID`/`SUDO_GID` are environment variables and thus caller-controlled, +//! but they can only ever select which unprivileged identity we become: +//! uid 0 and unparseable values are rejected and fall back to `nobody`, so a +//! forged value cannot retain or regain privilege. +//! +//! # Trade-offs +//! +//! Process attribution on FreeBSD execs `sockstat`, which as a non-root user +//! only sees the target user's sockets, so attribution for other users' +//! processes is lost after the drop. `--no-uid-drop` keeps the old keep-root +//! behavior. + +use anyhow::{Result, anyhow}; + +/// The uid/gid pair to drop to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DropTarget { + pub uid: libc::uid_t, + pub gid: libc::gid_t, +} + +/// FreeBSD `nobody` user and group, both 65534. Used when rustnet runs as +/// plain root (no sudo, or doas) or SUDO_UID/SUDO_GID are unusable. +const NOBODY: DropTarget = DropTarget { + uid: 65534, + gid: 65534, +}; + +/// Resolve the identity to drop to, or `None` when not running as root +/// (nothing to drop; the bpf-group path never has euid 0). +pub fn resolve_drop_target() -> Option { + // SAFETY: geteuid() has no failure mode and takes no pointers. + if unsafe { libc::geteuid() } != 0 { + return None; + } + Some(target_from_env( + std::env::var("SUDO_UID").ok().as_deref(), + std::env::var("SUDO_GID").ok().as_deref(), + )) +} + +/// Pick the target from SUDO_UID/SUDO_GID; both must parse to a nonzero id, +/// otherwise fall back to `nobody`. Split out from `resolve_drop_target` for +/// testability (no process-global env manipulation in tests). +fn target_from_env(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> DropTarget { + let parse = |v: Option<&str>| v.and_then(|s| s.parse::().ok()).filter(|&id| id != 0); + match (parse(sudo_uid), parse(sudo_gid)) { + (Some(uid), Some(gid)) => DropTarget { uid, gid }, + _ => NOBODY, + } +} + +/// Change the file's owner to the drop target. +/// +/// Used on pre-created export files (0600, root-owned) so they can still be +/// reopened by name after the uid drop, e.g. libpcap's `pcap_dump_open` and +/// the per-event sidecar JSONL appends. Operates on the already-open fd +/// (`fchown`), never on the path, so it cannot be redirected by a +/// symlink/rename swap after the O_NOFOLLOW create. +pub fn chown_to_target(file: &std::fs::File, target: DropTarget) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + // SAFETY: fchown on a valid owned fd; no pointers involved. + let rc = unsafe { libc::fchown(file.as_raw_fd(), target.uid, target.gid) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +/// Irreversibly drop root to `target`: supplementary groups first, then gid, +/// then uid (all three of real/effective/saved so root cannot be regained). +/// +/// Must be called while euid is 0. Process credentials are shared by all +/// threads on FreeBSD, so threads spawned before the drop (capture, +/// enrichment) are covered too. +pub fn drop_to(target: DropTarget) -> Result<()> { + if target.uid == 0 || target.gid == 0 { + return Err(anyhow!("refusing to 'drop' to uid 0 / gid 0")); + } + + // SAFETY: setgroups reads `ngroups` gid_t values from the pointer; we pass + // exactly one element and its valid address. + let gids = [target.gid]; + if unsafe { libc::setgroups(1, gids.as_ptr()) } != 0 { + return Err(anyhow!( + "setgroups failed: {}", + std::io::Error::last_os_error() + )); + } + + // gid before uid: once uid 0 is gone, setresgid would fail. + // SAFETY: setresgid/setresuid take plain integers. + if unsafe { libc::setresgid(target.gid, target.gid, target.gid) } != 0 { + return Err(anyhow!( + "setresgid({}) failed: {}", + target.gid, + std::io::Error::last_os_error() + )); + } + if unsafe { libc::setresuid(target.uid, target.uid, target.uid) } != 0 { + return Err(anyhow!( + "setresuid({}) failed: {}", + target.uid, + std::io::Error::last_os_error() + )); + } + + // Verify the drop stuck and root cannot be regained. + let (mut ruid, mut euid, mut suid) = (0, 0, 0); + let (mut rgid, mut egid, mut sgid) = (0, 0, 0); + // SAFETY: getresuid/getresgid write to the three provided out-pointers. + unsafe { + libc::getresuid(&mut ruid, &mut euid, &mut suid); + libc::getresgid(&mut rgid, &mut egid, &mut sgid); + } + if [ruid, euid, suid] != [target.uid; 3] || [rgid, egid, sgid] != [target.gid; 3] { + return Err(anyhow!( + "uid/gid drop verification failed (uids {ruid}/{euid}/{suid}, gids {rgid}/{egid}/{sgid})" + )); + } + // SAFETY: setuid takes a plain integer. With ruid/euid/suid all nonzero + // this must fail; success means the drop is unsound. + if unsafe { libc::setuid(0) } == 0 { + return Err(anyhow!("uid drop verification failed: setuid(0) succeeded")); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_from_env_uses_sudo_ids() { + assert_eq!( + target_from_env(Some("1001"), Some("1001")), + DropTarget { + uid: 1001, + gid: 1001 + } + ); + } + + #[test] + fn test_target_from_env_rejects_root_and_garbage() { + // uid 0 must never be a drop target + assert_eq!(target_from_env(Some("0"), Some("0")), NOBODY); + // unparseable values fall back to nobody + assert_eq!(target_from_env(Some("abc"), Some("1001")), NOBODY); + assert_eq!(target_from_env(Some("-1"), Some("1001")), NOBODY); + // both must be present (doas sets neither) + assert_eq!(target_from_env(Some("1001"), None), NOBODY); + assert_eq!(target_from_env(None, None), NOBODY); + } + + #[test] + fn test_drop_to_rejects_root_target() { + assert!(drop_to(DropTarget { uid: 0, gid: 0 }).is_err()); + } + + #[test] + fn test_resolve_drop_target_none_when_not_root() { + // Tests don't run as root in CI; as non-root there is nothing to drop. + if unsafe { libc::geteuid() } != 0 { + assert!(resolve_drop_target().is_none()); + } + } +} diff --git a/src/network/platform/mod.rs b/src/network/platform/mod.rs index 53f9b7cf..55509247 100644 --- a/src/network/platform/mod.rs +++ b/src/network/platform/mod.rs @@ -29,6 +29,10 @@ mod windows; // Re-export interface-stats providers and the sandbox entry points. #[cfg(target_os = "freebsd")] pub use freebsd::FreeBSDStatsProvider; +// FreeBSD has no sandbox module yet (Capsicum planned); the uid drop is the +// containment layer in the meantime. +#[cfg(target_os = "freebsd")] +pub use freebsd::privdrop; #[cfg(target_os = "linux")] pub use linux::LinuxStatsProvider; // Not gated on the `landlock` feature: the sandbox module always compiles on diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs index 2e1e310c..3e0e2d31 100644 --- a/src/ui/terminal.rs +++ b/src/ui/terminal.rs @@ -20,9 +20,6 @@ pub fn setup_terminal(backend: B) -> Result::Error: Send + Sync + 'static, { - let mut terminal = RatatuiTerminal::new(backend)?; - terminal.clear()?; - terminal.hide_cursor()?; crossterm::terminal::enable_raw_mode()?; crossterm::execute!( std::io::stdout(), @@ -30,6 +27,15 @@ where crossterm::event::EnableMouseCapture )?; install_panic_hook(); + let mut terminal = RatatuiTerminal::new(backend)?; + // Clear via the backend, not `Terminal::clear()`: since ratatui 0.30 the + // latter queries the cursor position (ESC[6n) and errors out after a 2s + // timeout on terminals that never reply (e.g. the FreeBSD vt console), + // aborting startup. The backend clear is a plain clear-screen write. It + // also runs after entering the alternate screen, so the user's primary + // screen is left untouched. + terminal.backend_mut().clear()?; + terminal.hide_cursor()?; Ok(terminal) }