Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions SECURITY.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<a id="privilege-drop-and-job-object-sandboxing-windows"></a>

在 Windows 上,RustNet 在初始化后从进程令牌中移除危险特权,并应用 Job Object 阻止子进程创建。
Expand Down
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion USAGE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、macOSFreeBSD
-h, --help 打印帮助
-V, --version 打印版本
```
Expand Down
48 changes: 36 additions & 12 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,26 +801,31 @@ 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<std::sync::mpsc::Receiver<()>> {
/// 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)
let tracker = Arc::clone(&self.tracker);

// 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);

// 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,
Expand Down Expand Up @@ -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<std::sync::mpsc::Receiver<()>> {
// Create packet channel — sender batches packets, receiver gets Vec<CapturedPacket> per batch
let (packet_tx, packet_rx) = channel::bounded::<Vec<CapturedPacket>>(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
Expand All @@ -910,8 +918,15 @@ impl App {
Ok(())
}

/// Start packet capture thread
fn start_capture_thread(&self, packet_tx: Sender<Vec<CapturedPacket>>) -> 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<Vec<CapturedPacket>>,
) -> Result<std::sync::mpsc::Receiver<()>> {
// Validate interface exists before spawning thread (fail fast)
crate::network::capture::validate_interface(&self.config.interface)?;

Expand All @@ -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 || {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -1156,7 +1180,7 @@ impl App {
})
.expect("Failed to spawn pcap_tx thread");

Ok(())
Ok(capture_ready_rx)
}

/// Start a packet processor thread
Expand Down
12 changes: 12 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
48 changes: 40 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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);
}
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<network::platform::privdrop::DropTarget>,
Expand Down
1 change: 1 addition & 0 deletions src/network/platform/freebsd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
// Process attribution (sockstat) lives in the rustnet-host crate.

mod interface_stats;
pub mod privdrop;

pub use interface_stats::FreeBSDStatsProvider;
Loading
Loading