From 11a8a040bd9bdd1e9dda96730933ce7229b8e998 Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Sun, 23 Aug 2026 13:32:34 +0200 Subject: [PATCH 1/3] fix(linux): keep the privileged startup socket snapshot for pre-existing connections --- CHANGELOG.md | 5 + .../src/network/types/identity.rs | 8 + crates/rustnet-host/src/linux/process.rs | 327 +++++++++++++++++- 3 files changed, 338 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 731fd01e..0f770d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Default Npcap Installations on Windows**: RustNet now finds Npcap in its standard `System32\Npcap` directory, so WinPcap API-compatible mode is no longer required. `--help` and `--version` also work without Npcap installed +- **Attribution of Pre-Existing Connections on Linux**: connections that were + already open before RustNet started (other users' daemons, root services) + kept their process name past the first cache refresh after the uid drop. + The privileged startup scan now serves as a validated fallback, shown as + the "startup snapshot" match quality ### Removed - **Ubuntu 25.10 (Questing) PPA**: the series reached end of life and diff --git a/crates/rustnet-core/src/network/types/identity.rs b/crates/rustnet-core/src/network/types/identity.rs index a7b2f14c..ee1db1b6 100644 --- a/crates/rustnet-core/src/network/types/identity.rs +++ b/crates/rustnet-core/src/network/types/identity.rs @@ -30,6 +30,10 @@ pub enum MatchQuality { ProcfsExact, /// procfs match that needed a relaxed key. ProcfsRelaxed, + /// Matched the privileged socket-table snapshot taken at startup. The + /// connection predates the run; the owner was read before privileges + /// were dropped and may since have exited. + ProcfsSnapshot, /// The backend reported an owner but could not report match provenance. Unspecified, } @@ -48,6 +52,7 @@ impl MatchQuality { Self::ListenerSocket => "listener socket", Self::ProcfsExact => "procfs exact", Self::ProcfsRelaxed => "procfs relaxed", + Self::ProcfsSnapshot => "startup snapshot", Self::Unspecified => "unspecified", } } @@ -65,6 +70,7 @@ impl MatchQuality { Self::ListenerSocket => "listener-socket", Self::ProcfsExact => "procfs-exact", Self::ProcfsRelaxed => "procfs-relaxed", + Self::ProcfsSnapshot => "procfs-snapshot", Self::Unspecified => "unspecified", } } @@ -379,6 +385,7 @@ mod tests { (MatchQuality::ListenerSocket, "listener-socket"), (MatchQuality::ProcfsExact, "procfs-exact"), (MatchQuality::ProcfsRelaxed, "procfs-relaxed"), + (MatchQuality::ProcfsSnapshot, "procfs-snapshot"), (MatchQuality::Unspecified, "unspecified"), ]; @@ -391,6 +398,7 @@ mod tests { assert!(MatchQuality::ExactTuple.is_exact()); assert!(MatchQuality::ProcfsExact.is_exact()); assert!(!MatchQuality::ProcfsRelaxed.is_exact()); + assert!(!MatchQuality::ProcfsSnapshot.is_exact()); assert!(!MatchQuality::Unspecified.is_exact()); } diff --git a/crates/rustnet-host/src/linux/process.rs b/crates/rustnet-host/src/linux/process.rs index 411a1b5e..5c56c45a 100644 --- a/crates/rustnet-host/src/linux/process.rs +++ b/crates/rustnet-host/src/linux/process.rs @@ -155,6 +155,71 @@ type PidNameMap = (); /// Map of connection key to (PID, process name) type ConnectionProcessMap = HashMap; +/// Owner recorded by the privileged startup scan for one pre-existing +/// socket, keyed by its exact 4-tuple. The inode pins the attribution to +/// the socket object itself, not merely the tuple. +#[derive(Debug, Clone)] +struct SnapshotOwner { + pid: u32, + name: String, + inode: u64, +} + +/// Build the startup fallback table from the startup socket inventory, +/// pairing each owner with the inode of its own row so the two can never +/// come from different sockets. A tuple occupied by more than one socket +/// (SO_REUSEPORT plus connected UDP makes exact duplicates legal) is +/// dropped entirely, even when only one of its rows resolved an owner: +/// observed traffic cannot be assigned to either socket. Rows without an +/// inode or a resolved owner contribute no entry of their own. +fn build_startup_snapshot(sockets: &SocketSnapshot) -> HashMap { + let key_of = |socket: &HostSocket| { + socket.remote_addr.map(|remote_addr| ConnectionKey { + protocol: socket.protocol, + local_addr: socket.local_addr, + remote_addr, + }) + }; + + let mut occupancy: HashMap = HashMap::new(); + for socket in sockets.sockets.iter() { + if let Some(key) = key_of(socket) { + *occupancy.entry(key).or_insert(0) += 1; + } + } + + let mut snapshot = HashMap::new(); + for socket in sockets.sockets.iter() { + let (Some(key), Some(inode), Some(owner)) = + (key_of(socket), socket.native_id, socket.owner.as_ref()) + else { + continue; + }; + if occupancy.get(&key) != Some(&1) { + continue; + } + snapshot.insert( + key, + SnapshotOwner { + pid: owner.pid, + name: owner.name.clone(), + inode, + }, + ); + } + snapshot +} + +/// Whether a startup-snapshot entry's recorded owner is still the same +/// process: `/proc//comm` (world-readable even after the uid drop) must +/// still exist and match the name captured at startup. This rejects owners +/// that have exited and PIDs since reused by a different program. +fn snapshot_owner_still_matches(pid: u32, name: &str) -> bool { + fs::read_to_string(format!("/proc/{pid}/comm")) + .map(|comm| comm.trim() == name) + .unwrap_or(false) +} + fn parse_proc_tcp_state(value: &str) -> HostTcpState { match value { "01" => HostTcpState::Established, @@ -175,6 +240,14 @@ fn parse_proc_tcp_state(value: &str) -> HostTcpState { pub(super) struct LinuxProcessLookup { // Cache: ConnectionKey -> (pid, process_name) cache: RwLock, + // The socket table as scanned at startup, while the process still had + // its full privileges. After the uid drop, rescans only see the drop + // target's own /proc//fd entries, so connections that already + // existed at launch (other users' daemons, root services) would lose + // their owner on the first refresh. This immutable snapshot keeps them + // attributable. The live cache always wins; the snapshot only fills + // holes, so a reused 4-tuple visible to the rescan is never shadowed. + startup_snapshot: HashMap, // Cache: PID -> process_name (for resolving eBPF thread names to main process names) #[cfg(feature = "ebpf")] pid_names: RwLock>, @@ -191,6 +264,7 @@ impl LinuxProcessLookup { let (process_map, _pid_names, socket_snapshot) = Self::build_process_map()?; Ok(Self { + startup_snapshot: build_startup_snapshot(&socket_snapshot), cache: RwLock::new(process_map), #[cfg(feature = "ebpf")] pid_names: RwLock::new(_pid_names), @@ -204,6 +278,7 @@ impl LinuxProcessLookup { #[cfg(test)] fn with_socket_table(lookup: ConnectionProcessMap) -> Self { Self { + startup_snapshot: HashMap::new(), cache: RwLock::new(lookup), #[cfg(feature = "ebpf")] pid_names: RwLock::new(HashMap::new()), @@ -268,8 +343,44 @@ impl LinuxProcessLookup { // shape is deliberately collapsed into a single `ProcfsRelaxed`: what // matters downstream is that procfs needed to guess, not which of the // three wildcard shapes it guessed with. - let ((pid, name), _shape) = relaxed_lookup(&cache, &key)?; - Some((*pid, name.clone(), MatchQuality::ProcfsRelaxed)) + if let Some(((pid, name), _shape)) = relaxed_lookup(&cache, &key) { + return Some((*pid, name.clone(), MatchQuality::ProcfsRelaxed)); + } + drop(cache); + + // Last resort: the privileged startup snapshot, for connections that + // already existed at launch but whose owner the post-uid-drop rescan + // can no longer see. Exact 4-tuple hits only: relaxed matching + // against the snapshot would let a stale listener entry claim new + // inbound connections indefinitely. The hit is only trusted while + // (a) the very same socket, by inode, still occupies the tuple in + // the periodically refreshed socket inventory (which stays readable + // after the uid drop even when owners do not), so a closed-and- + // reused tuple is rejected; and (b) the recorded owner is + // verifiably still the same process. The refresh cadence bounds the + // reuse-detection window to one refresh interval. + let owner = self.startup_snapshot.get(&key)?; + if self.snapshot_socket_unchanged(&key, owner.inode) + && snapshot_owner_still_matches(owner.pid, &owner.name) + { + return Some((owner.pid, owner.name.clone(), MatchQuality::ProcfsSnapshot)); + } + None + } + + /// Whether the current socket inventory still shows the startup socket + /// (same inode) on this exact tuple. + fn snapshot_socket_unchanged(&self, key: &ConnectionKey, inode: u64) -> bool { + let snapshot = self + .socket_snapshot + .read() + .expect("socket snapshot lock poisoned"); + snapshot.sockets.iter().any(|socket| { + socket.native_id == Some(inode) + && socket.protocol == key.protocol + && socket.local_addr == key.local_addr + && socket.remote_addr == Some(key.remote_addr) + }) } /// Resolve a process's parent chain, memoized per TGID until the next @@ -586,6 +697,218 @@ mod tests { assert_eq!(parse_proc_tcp_state("FF"), HostTcpState::Unknown); } + fn own_comm() -> String { + fs::read_to_string(format!("/proc/{}/comm", own_pid())) + .expect("own comm readable") + .trim() + .to_string() + } + + /// An established TCP socket row as the refreshed inventory would list + /// it after the uid drop: tuple and inode visible, owner or not. + fn host_socket(local: &str, remote: &str, inode: u64) -> HostSocket { + let mut socket = HostSocket::new( + Protocol::Tcp, + local.parse().unwrap(), + HostSocketState::Tcp(HostTcpState::Established), + ); + socket.remote_addr = Some(remote.parse().unwrap()); + socket.native_id = Some(inode); + socket + } + + fn snapshot_lookup( + owner: (&str, &str, u32, String, u64), + inventory: Vec, + ) -> LinuxProcessLookup { + let (local, remote, pid, name, inode) = owner; + let mut startup = HashMap::new(); + startup.insert(key(local, remote), SnapshotOwner { pid, name, inode }); + LinuxProcessLookup { + startup_snapshot: startup, + cache: RwLock::new(ConnectionProcessMap::new()), + #[cfg(feature = "ebpf")] + pid_names: RwLock::new(HashMap::new()), + lineages: RwLock::new(HashMap::new()), + socket_snapshot: RwLock::new(SocketSnapshot::new(inventory)), + } + } + + fn owned_socket(local: &str, remote: &str, inode: u64, pid: u32, name: &str) -> HostSocket { + let mut socket = host_socket(local, remote, inode); + socket.owner = Some(SocketOwner::new(pid, name, None)); + socket + } + + #[test] + fn startup_snapshot_pairs_owner_and_inode_from_the_same_row() { + // Two owned sockets: each snapshot entry must carry its own row's + // inode and owner, never a mix. + let sockets = SocketSnapshot::new(vec![ + owned_socket("192.168.1.10:44444", "203.0.113.5:22", 777, 41, "sshd"), + owned_socket("192.168.1.10:55555", "203.0.113.6:443", 888, 42, "nginx"), + ]); + let snapshot = build_startup_snapshot(&sockets); + + let a = &snapshot[&key("192.168.1.10:44444", "203.0.113.5:22")]; + assert_eq!((a.pid, a.name.as_str(), a.inode), (41, "sshd", 777)); + let b = &snapshot[&key("192.168.1.10:55555", "203.0.113.6:443")]; + assert_eq!((b.pid, b.name.as_str(), b.inode), (42, "nginx", 888)); + } + + #[test] + fn startup_snapshot_drops_a_tuple_occupied_by_two_sockets() { + // SO_REUSEPORT plus connected UDP permits exact duplicate tuples. + // Even when only one row resolved an owner (the other hidden by + // permissions or a scan race), the tuple is ambiguous: the owner + // must not be paired with either inode. + let owned = owned_socket("192.168.1.10:5353", "203.0.113.5:5353", 777, 41, "resolver"); + let ownerless = host_socket("192.168.1.10:5353", "203.0.113.5:5353", 778); + let sockets = SocketSnapshot::new(vec![owned, ownerless]); + let snapshot = build_startup_snapshot(&sockets); + + assert!(snapshot.is_empty()); + } + + #[test] + fn startup_snapshot_skips_ownerless_and_inodeless_rows() { + let ownerless = host_socket("192.168.1.10:44444", "203.0.113.5:22", 777); + let mut inodeless = owned_socket("192.168.1.10:55555", "203.0.113.6:443", 0, 41, "sshd"); + inodeless.native_id = None; + let sockets = SocketSnapshot::new(vec![ownerless, inodeless]); + + assert!(build_startup_snapshot(&sockets).is_empty()); + } + + #[test] + fn startup_snapshot_attributes_when_the_rescanned_table_cannot() { + // A connection whose owner is present in the privileged startup + // scan but invisible to every post-uid-drop rescan: same socket + // (same inode) still on the tuple, owner still alive. The snapshot + // records this very test process so /proc validation has a real + // target. + let lookup = snapshot_lookup( + ( + "192.168.1.10:44444", + "203.0.113.5:22", + own_pid(), + own_comm(), + 777, + ), + vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 777)], + ); + + let conn = connection("192.168.1.10:44444", "203.0.113.5:22"); + let (got_pid, got_name, quality) = + lookup.lookup_match(&conn).expect("snapshot should match"); + assert_eq!(got_pid, own_pid()); + assert_eq!(got_name, own_comm()); + assert_eq!(quality, MatchQuality::ProcfsSnapshot); + } + + #[test] + fn startup_snapshot_rejects_a_reused_tuple() { + // The startup socket closed and another (owner-invisible) socket + // reuses the exact tuple: the inventory shows a different inode, + // so the stale owner must not attribute, even though it still runs. + let lookup = snapshot_lookup( + ( + "192.168.1.10:44444", + "203.0.113.5:22", + own_pid(), + own_comm(), + 777, + ), + vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 778)], + ); + + let conn = connection("192.168.1.10:44444", "203.0.113.5:22"); + assert!(lookup.lookup_match(&conn).is_none()); + } + + #[test] + fn startup_snapshot_rejects_a_closed_socket() { + // The tuple is gone from the current inventory entirely. + let lookup = snapshot_lookup( + ( + "192.168.1.10:44444", + "203.0.113.5:22", + own_pid(), + own_comm(), + 777, + ), + Vec::new(), + ); + + let conn = connection("192.168.1.10:44444", "203.0.113.5:22"); + assert!(lookup.lookup_match(&conn).is_none()); + } + + #[test] + fn startup_snapshot_rejects_an_owner_that_no_longer_matches() { + // Socket unchanged, but the recorded owner has exited (or its PID + // was reused): a PID above the kernel's pid_max cannot exist. + let lookup = snapshot_lookup( + ( + "192.168.1.10:44444", + "203.0.113.5:22", + u32::MAX, + "sshd".to_string(), + 777, + ), + vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 777)], + ); + + let conn = connection("192.168.1.10:44444", "203.0.113.5:22"); + assert!(lookup.lookup_match(&conn).is_none()); + } + + #[test] + fn startup_snapshot_never_matches_relaxed_shapes() { + // A stale wildcard listener entry in the snapshot must not claim + // new inbound connections; only exact 4-tuple hits are trusted. + let lookup = snapshot_lookup( + ("0.0.0.0:80", "0.0.0.0:0", own_pid(), own_comm(), 777), + vec![host_socket("192.168.1.10:80", "203.0.113.5:50000", 900)], + ); + + let conn = connection("192.168.1.10:80", "203.0.113.5:50000"); + assert!(lookup.lookup_match(&conn).is_none()); + } + + #[test] + fn live_table_wins_over_the_startup_snapshot() { + // A 4-tuple reused after startup: the rescan sees the new owner and + // must shadow the stale snapshot entry. + let k = key("192.168.1.10:44444", "203.0.113.5:443"); + let mut snapshot = HashMap::new(); + snapshot.insert( + k.clone(), + SnapshotOwner { + pid: 4242, + name: "old-owner".to_string(), + inode: 777, + }, + ); + let mut live = ConnectionProcessMap::new(); + live.insert(k, (5555, "curl".to_string())); + + let lookup = LinuxProcessLookup { + startup_snapshot: snapshot, + cache: RwLock::new(live), + #[cfg(feature = "ebpf")] + pid_names: RwLock::new(HashMap::new()), + lineages: RwLock::new(HashMap::new()), + socket_snapshot: RwLock::new(SocketSnapshot::default()), + }; + + let conn = connection("192.168.1.10:44444", "203.0.113.5:443"); + let (pid, name, quality) = lookup.lookup_match(&conn).expect("live table should match"); + assert_eq!(pid, 5555); + assert_eq!(name, "curl"); + assert_eq!(quality, MatchQuality::ProcfsExact); + } + #[test] fn truncated_comm_is_recovered_from_the_executable_name() { assert_eq!( From 0efb800db10f3cd5441aa764f84090badbe4db04 Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Fri, 28 Aug 2026 13:07:46 +0200 Subject: [PATCH 2/3] fix(linux): snapshot existing sockets with BPF --- ARCHITECTURE.md | 4 +- ARCHITECTURE.zh-CN.md | 4 +- CHANGELOG.md | 9 +- README.ja.md | 1 + README.md | 3 +- README.zh-CN.md | 3 +- SECURITY.md | 10 +- SECURITY.zh-CN.md | 8 +- .../src/network/types/identity.rs | 16 +- crates/rustnet-host/README.md | 14 +- crates/rustnet-host/build.rs | 8 +- crates/rustnet-host/src/linux/ebpf/mod.rs | 2 + .../programs/socket_tracker_task_file.bpf.c | 53 ++++++ .../rustnet-host/src/linux/ebpf/task_file.rs | 176 ++++++++++++++++++ crates/rustnet-host/src/linux/enhanced.rs | 30 ++- crates/rustnet-host/src/linux/process.rs | 174 ++++++++++++++--- 16 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c create mode 100644 crates/rustnet-host/src/linux/ebpf/task_file.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 10944696..d68a9c86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -262,6 +262,7 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T **eBPF Mode (Default on Linux):** - Uses kernel eBPF programs attached to socket syscalls - Captures socket creation events with process context +- On Linux 5.11+, runs a one-shot task-file iterator after attaching the live probes to capture owners of sockets that predate RustNet, including other users' sockets in file-capability mode - Provides lower overhead than procfs scanning - Records the group leader's TGID, the acting TID, and credentials; the name, executable path, and PPID are enriched in user space via procfs - **Limitations:** @@ -272,7 +273,8 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T - Note: CAP_NET_ADMIN is NOT required (uses read-only, non-promiscuous packet capture) **Fallback Behavior:** -- If eBPF fails to load (permissions, kernel compatibility), automatically falls back to procfs mode +- If the task-file iterator is unavailable, keeps the live eBPF tracker and uses the procfs startup inventory +- If the live eBPF tracker fails to load, automatically falls back to procfs mode - TUI Statistics panel shows active detection method #### macOS diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md index 9f3d9491..7217a614 100644 --- a/ARCHITECTURE.zh-CN.md +++ b/ARCHITECTURE.zh-CN.md @@ -257,6 +257,7 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属 **eBPF 模式(Linux 默认):** - 使用附加到 socket 系统调用的内核 eBPF 程序 - 捕获带进程上下文的 socket 创建事件 +- 在 Linux 5.11 及更高版本上,先附加实时探针,再运行一次性的 task-file 迭代器,以捕获 RustNet 启动前已存在的 socket 所有者;使用文件 capabilities 运行时也包括其他用户的 socket - 比 procfs 扫描开销更低 - 记录进程组组长的 TGID、当前线程的 TID 以及凭据;进程名、可执行路径和 PPID 在用户态通过 procfs 富化 - **局限性:** @@ -267,7 +268,8 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属 - 注意:不需要 CAP_NET_ADMIN(使用只读、非混杂包捕获) **回退行为:** -- 如果 eBPF 加载失败(权限、内核兼容性),自动回退到 procfs 模式 +- 如果 task-file 迭代器不可用,则保留实时 eBPF 追踪,并使用启动时的 procfs 清单 +- 如果实时 eBPF 追踪器加载失败,则自动回退到 procfs 模式 - TUI 统计面板显示当前使用的检测方法 #### macOS diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f770d1d..7f90117d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 standard `System32\Npcap` directory, so WinPcap API-compatible mode is no longer required. `--help` and `--version` also work without Npcap installed - **Attribution of Pre-Existing Connections on Linux**: connections that were - already open before RustNet started (other users' daemons, root services) - kept their process name past the first cache refresh after the uid drop. - The privileged startup scan now serves as a validated fallback, shown as - the "startup snapshot" match quality + already open before RustNet started keep their process name after privilege + reduction, including root services when RustNet runs with file capabilities + on Linux 5.11 and newer. A one-shot BPF task-file inventory and the + privileged procfs scan feed a validated fallback shown as the "startup + snapshot" match quality (#575) ### Removed - **Ubuntu 25.10 (Questing) PPA**: the series reached end of life and diff --git a/README.ja.md b/README.ja.md index 45fc8ac4..96a984f8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,6 +18,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ ## 主な機能 - TCP、UDP、QUIC 接続とプロセスの対応付け。詳細には PID、実行ファイル、ユーザー/グループ名、照合の信頼度、全プラットフォーム共通の親プロセスチェーン(上限あり)を表示 +- Linux 5.11 以降では、起動時の BPF task-file イテレーターにより、ファイル capabilities で実行した場合でも root や他ユーザーが所有する既存 socket を識別 - HTTP、TLS/SNI、DNS、SSH、QUIC などの深層パケット解析 - TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示 - Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示 diff --git a/README.md b/README.md index 42d6aef0..4aa9c5b8 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ RustNet uses kernel eBPF programs by default on Linux for enhanced performance a - Short-lived processes that exit before this enrichment runs keep the eBPF-recorded 16-character name **Fallback Behavior:** +- On Linux 5.11 and newer, a one-shot BPF task-file iterator inventories sockets that were already open at startup, including sockets owned by root and other users when RustNet runs with file capabilities - When eBPF fails to load or lacks sufficient permissions, RustNet automatically falls back to standard procfs-based process identification -- Standard mode resolves names the same way via procfs scanning, but with higher CPU overhead +- Older kernels and procfs-only builds resolve names through procfs scanning, which has higher CPU overhead and can only inspect socket owners visible to the RustNet user - eBPF is enabled by default; no special build flags needed To disable eBPF and use procfs-only mode, build with: diff --git a/README.zh-CN.md b/README.zh-CN.md index 4189223e..6e8ad381 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,8 +63,9 @@ RustNet 在 Linux 上默认使用内核 eBPF 程序进行进程识别,从而 - 在该富化流程运行前就已退出的短命进程,仍保留 eBPF 记录的 16 字符名称 **回退行为:** +- 在 Linux 5.11 及更高版本上,一次性的 BPF task-file 迭代器会清点 RustNet 启动前已经打开的 socket;使用文件 capabilities 运行时,也能识别 root 和其他用户拥有的 socket - 当 eBPF 加载失败或权限不足时,RustNet 会自动回退到基于 procfs 的标准进程识别方式 -- 标准模式通过 procfs 扫描以同样的方式解析进程名,但 CPU 开销更高 +- 旧版内核和纯 procfs 构建通过 procfs 扫描解析进程名,CPU 开销更高,并且只能检查当前 RustNet 用户可见的 socket 所有者 - eBPF 默认启用,无需任何特殊编译参数 如需关闭 eBPF、仅使用 procfs 模式,请这样构建: diff --git a/SECURITY.md b/SECURITY.md index 6f0bd077..cf69abd8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,10 +66,11 @@ If an attacker exploits a vulnerability in DPI/packet parsing: Trade-off of the root uid drop: the procfs fallback for process attribution can then only inspect processes owned by the target user, and Kubernetes log -directories under `/var/log/pods` may become unreadable. The eBPF fast path -(the default) is unaffected. If you rely on procfs-only attribution (e.g. a -build without eBPF) and need to attribute other users' processes, use -`--no-uid-drop`. +directories under `/var/log/pods` may become unreadable. On Linux 5.11 and +newer, the default eBPF path takes a one-shot task-file socket inventory before +the drop, so pre-existing sockets owned by other users remain attributable. If +you rely on procfs-only attribution, such as a build without eBPF or an older +kernel, and need to attribute other users' processes, use `--no-uid-drop`. ### Graceful Degradation @@ -276,6 +277,7 @@ When using eBPF for enhanced process detection (default on Linux): - Requires additional kernel capabilities (`CAP_BPF`, `CAP_PERFMON`) - eBPF programs are verified by kernel before loading - Limited to read-only operations (no packet modification) +- On Linux 5.11+, a one-shot task-file iterator inventories pre-existing socket owners - Automatically falls back to procfs if eBPF fails ## Threat Model diff --git a/SECURITY.zh-CN.md b/SECURITY.zh-CN.md index 60acadea..874fddfb 100644 --- a/SECURITY.zh-CN.md +++ b/SECURITY.zh-CN.md @@ -65,9 +65,10 @@ RustNet 处理不受信任的网络数据,因此纵深防御至关重要。本 ``` root uid 降权的权衡:降权后,procfs 回退路径的进程归属只能检查目标用户拥有的进程, -`/var/log/pods` 下的 Kubernetes 日志目录也可能不可读。eBPF 快速路径(默认)不受影响。 -如果依赖纯 procfs 归属(如未启用 eBPF 的构建)且需要归属其他用户的进程,请使用 -`--no-uid-drop`。 +`/var/log/pods` 下的 Kubernetes 日志目录也可能不可读。在 Linux 5.11 及更高版本上, +默认 eBPF 路径会在降权前通过一次性的 task-file 迭代器清点 socket,因此其他用户拥有的 +既有 socket 仍可归属。如果依赖纯 procfs 归属(如未启用 eBPF 的构建或旧版内核)且需要 +归属其他用户的进程,请使用 `--no-uid-drop`。 ### 优雅降级 @@ -271,6 +272,7 @@ RustNet 完全在本地运行: - 现代内核需要额外的 Linux capabilities(`CAP_BPF`、`CAP_PERFMON`) - eBPF 程序在加载前由内核验证 - 仅限只读操作(不修改数据包) +- 在 Linux 5.11 及更高版本上,一次性的 task-file 迭代器会清点既有 socket 的所有者 - 如果 eBPF 失败,自动回退到 procfs ## 威胁模型 diff --git a/crates/rustnet-core/src/network/types/identity.rs b/crates/rustnet-core/src/network/types/identity.rs index ee1db1b6..e17fee13 100644 --- a/crates/rustnet-core/src/network/types/identity.rs +++ b/crates/rustnet-core/src/network/types/identity.rs @@ -30,10 +30,10 @@ pub enum MatchQuality { ProcfsExact, /// procfs match that needed a relaxed key. ProcfsRelaxed, - /// Matched the privileged socket-table snapshot taken at startup. The - /// connection predates the run; the owner was read before privileges - /// were dropped and may since have exited. - ProcfsSnapshot, + /// Matched the validated socket-table snapshot taken at startup. The + /// owner came from BPF or a privileged procfs scan, and both the socket + /// inode and process identity still match their startup values. + StartupSnapshot, /// The backend reported an owner but could not report match provenance. Unspecified, } @@ -52,7 +52,7 @@ impl MatchQuality { Self::ListenerSocket => "listener socket", Self::ProcfsExact => "procfs exact", Self::ProcfsRelaxed => "procfs relaxed", - Self::ProcfsSnapshot => "startup snapshot", + Self::StartupSnapshot => "startup snapshot", Self::Unspecified => "unspecified", } } @@ -70,7 +70,7 @@ impl MatchQuality { Self::ListenerSocket => "listener-socket", Self::ProcfsExact => "procfs-exact", Self::ProcfsRelaxed => "procfs-relaxed", - Self::ProcfsSnapshot => "procfs-snapshot", + Self::StartupSnapshot => "startup-snapshot", Self::Unspecified => "unspecified", } } @@ -385,7 +385,7 @@ mod tests { (MatchQuality::ListenerSocket, "listener-socket"), (MatchQuality::ProcfsExact, "procfs-exact"), (MatchQuality::ProcfsRelaxed, "procfs-relaxed"), - (MatchQuality::ProcfsSnapshot, "procfs-snapshot"), + (MatchQuality::StartupSnapshot, "startup-snapshot"), (MatchQuality::Unspecified, "unspecified"), ]; @@ -398,7 +398,7 @@ mod tests { assert!(MatchQuality::ExactTuple.is_exact()); assert!(MatchQuality::ProcfsExact.is_exact()); assert!(!MatchQuality::ProcfsRelaxed.is_exact()); - assert!(!MatchQuality::ProcfsSnapshot.is_exact()); + assert!(!MatchQuality::StartupSnapshot.is_exact()); assert!(!MatchQuality::Unspecified.is_exact()); } diff --git a/crates/rustnet-host/README.md b/crates/rustnet-host/README.md index d530872c..e8745020 100644 --- a/crates/rustnet-host/README.md +++ b/crates/rustnet-host/README.md @@ -76,10 +76,13 @@ When a platform can't use its optimal method, `ProcessLookup::get_degradation_re reports why (e.g. missing `CAP_BPF`, no root for PKTAP) via `DegradationReason`, which front-ends can surface to the user. -Both Linux BPF objects use CO-RE for safe socket field access and therefore +All Linux BPF objects use CO-RE for safe kernel field access and therefore require usable target BTF. A compatible target-BTF kernel tries fentry/fexit -first and legacy kprobes second. A kernel without usable target BTF falls -directly to procfs rather than relying on fixed structure offsets. +first and legacy kprobes second. On Linux 5.11 and newer, a separate one-shot +task-file iterator captures socket owners that predate the live probes. Its +failure leaves the selected live tracker running and falls back to the procfs +startup inventory. A kernel without usable target BTF falls directly to procfs +rather than relying on fixed structure offsets. ## Linux eBPF integration matrix @@ -99,6 +102,11 @@ sudo -E cargo test -p rustnet-host --features ebpf -- \ --ignored --exact \ linux::ebpf::tracker_libbpf::integration_tests::legacy_kprobe_socket_attribution_matrix \ --nocapture + +sudo -E cargo test -p rustnet-host --features ebpf -- \ + --ignored --exact \ + linux::ebpf::task_file::tests::kernel_iterator_reports_current_process_socket \ + --nocapture ``` ## Scope diff --git a/crates/rustnet-host/build.rs b/crates/rustnet-host/build.rs index 9a0d9680..c24a497a 100644 --- a/crates/rustnet-host/build.rs +++ b/crates/rustnet-host/build.rs @@ -59,11 +59,11 @@ fn compile_ebpf_programs() { let vmlinux_include_path = get_vmlinux_header(vmlinux_arch).expect("Failed to locate bundled vmlinux.h"); - for backend in ["fentry", "kprobe"] { - let src = format!("src/linux/ebpf/programs/socket_tracker_{backend}.bpf.c"); - let out = out_dir.join(format!("socket_tracker_{backend}.skel.rs")); + for program in ["fentry", "kprobe", "task_file"] { + let src = format!("src/linux/ebpf/programs/socket_tracker_{program}.bpf.c"); + let out = out_dir.join(format!("socket_tracker_{program}.skel.rs")); - println!("cargo:warning=Building eBPF {backend} backend using libbpf-cargo"); + println!("cargo:warning=Building eBPF {program} program using libbpf-cargo"); SkeletonBuilder::new() .source(&src) diff --git a/crates/rustnet-host/src/linux/ebpf/mod.rs b/crates/rustnet-host/src/linux/ebpf/mod.rs index 3251412e..9f95bded 100644 --- a/crates/rustnet-host/src/linux/ebpf/mod.rs +++ b/crates/rustnet-host/src/linux/ebpf/mod.rs @@ -5,8 +5,10 @@ mod loader; mod maps_libbpf; +mod task_file; mod tracker_libbpf; +pub(super) use task_file::snapshot_task_file_owners; pub(super) use tracker_libbpf::LibbpfSocketTracker as EbpfSocketTracker; use crate::MatchQuality; diff --git a/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c b/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c new file mode 100644 index 00000000..cf324bde --- /dev/null +++ b/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c @@ -0,0 +1,53 @@ +#include "vmlinux.h" + +#include +#include + +#define TASK_COMM_LEN 16 +#define TASK_FILE_OWNER_SIZE 32 + +/* Binary record consumed by task_file.rs. */ +struct task_file_owner +{ + __u64 inode; + __u32 tgid; + __u32 uid; + char comm[TASK_COMM_LEN]; +}; + +_Static_assert(sizeof(struct task_file_owner) == TASK_FILE_OWNER_SIZE, + "task-file owner ABI size changed"); + +/* + * One-shot startup inventory for sockets that existed before the live + * fentry/kprobe programs were attached. The kernel's task_file iterator skips + * threads that share their group leader's file table, so normal thread groups + * do not emit duplicate owners. + */ +SEC("iter/task_file") +int snapshot_task_file_owners(struct bpf_iter__task_file *ctx) +{ + struct task_struct *task = ctx->task; + struct file *file = ctx->file; + + if (!task || !file || !bpf_sock_from_file(file)) + return 0; + + struct task_file_owner owner = {}; + owner.inode = BPF_CORE_READ(file, f_inode, i_ino); + owner.tgid = BPF_CORE_READ(task, tgid); + owner.uid = BPF_CORE_READ(task, cred, euid.val); + + struct task_struct *leader = BPF_CORE_READ(task, group_leader); + if (!leader || owner.inode == 0 || owner.tgid == 0) + return 0; + + if (BPF_CORE_READ_STR_INTO(&owner.comm, leader, comm) <= 0 || + owner.comm[0] == '\0') + return 0; + + bpf_seq_write(ctx->meta->seq, &owner, sizeof(owner)); + return 0; +} + +char LICENSE[] SEC("license") = "Dual BSD/GPL"; diff --git a/crates/rustnet-host/src/linux/ebpf/task_file.rs b/crates/rustnet-host/src/linux/ebpf/task_file.rs new file mode 100644 index 00000000..4fed37b3 --- /dev/null +++ b/crates/rustnet-host/src/linux/ebpf/task_file.rs @@ -0,0 +1,176 @@ +//! One-shot BPF task-file iterator for sockets that predate RustNet startup. + +use crate::SocketOwner; +use crate::linux::process::StartupSocketOwners; +use anyhow::{Context, Result, ensure}; +use libbpf_rs::skel::{OpenSkel, SkelBuilder}; +use libbpf_rs::{Iter, IterOpts}; +use std::io::Read; + +mod socket_tracker_task_file { + include!(concat!( + env!("OUT_DIR"), + "/socket_tracker_task_file.skel.rs" + )); +} + +use socket_tracker_task_file::*; + +const TASK_COMM_LEN: usize = 16; +const OWNER_RECORD_SIZE: usize = 32; +const READ_BUFFER_SIZE: usize = 64 * 1024; + +pub(in crate::linux) fn snapshot_task_file_owners() -> Result { + let skel_builder = SocketTrackerTaskFileSkelBuilder::default(); + let mut open_object = Box::new(std::mem::MaybeUninit::uninit()); + let open_skel = skel_builder + .open(&mut open_object) + .context("open task-file iterator skeleton")?; + let skel = open_skel + .load() + .context("load task-file iterator BPF object")?; + let link = skel + .progs + .snapshot_task_file_owners + .attach_iter_with_opts(IterOpts::None) + .context("attach task-file iterator")?; + let mut iter = Iter::new(&link).context("create task-file iterator reader")?; + + read_owner_records(&mut iter) +} + +fn read_owner_records(reader: &mut impl Read) -> Result { + let mut owners = StartupSocketOwners::default(); + let mut pending = Vec::with_capacity(READ_BUFFER_SIZE + OWNER_RECORD_SIZE); + let mut buffer = [0_u8; READ_BUFFER_SIZE]; + + loop { + let bytes_read = reader + .read(&mut buffer) + .context("read task-file iterator records")?; + if bytes_read == 0 { + break; + } + + pending.extend_from_slice(&buffer[..bytes_read]); + let complete_len = pending.len() / OWNER_RECORD_SIZE * OWNER_RECORD_SIZE; + let (records, remainder) = pending[..complete_len].as_chunks::(); + debug_assert!(remainder.is_empty()); + for record in records { + if let Some((inode, owner)) = decode_owner_record(record) { + owners.insert(inode, owner); + } + } + + let remaining = pending.len() - complete_len; + pending.copy_within(complete_len.., 0); + pending.truncate(remaining); + } + + ensure!( + pending.is_empty(), + "task-file iterator returned a truncated owner record" + ); + Ok(owners) +} + +fn decode_owner_record(record: &[u8]) -> Option<(u64, SocketOwner)> { + if record.len() != OWNER_RECORD_SIZE { + return None; + } + + let inode = u64::from_ne_bytes(record[0..8].try_into().ok()?); + let tgid = u32::from_ne_bytes(record[8..12].try_into().ok()?); + let uid = u32::from_ne_bytes(record[12..16].try_into().ok()?); + let comm_bytes = &record[16..16 + TASK_COMM_LEN]; + let comm_len = comm_bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(TASK_COMM_LEN); + let comm = String::from_utf8_lossy(&comm_bytes[..comm_len]).to_string(); + + if inode == 0 || tgid == 0 || comm.is_empty() { + return None; + } + + Some((inode, SocketOwner::new(tgid, comm, Some(uid)))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::net::TcpListener; + use std::os::fd::AsRawFd; + + fn record(inode: u64, tgid: u32, uid: u32, comm: &str) -> [u8; OWNER_RECORD_SIZE] { + let mut bytes = [0_u8; OWNER_RECORD_SIZE]; + bytes[0..8].copy_from_slice(&inode.to_ne_bytes()); + bytes[8..12].copy_from_slice(&tgid.to_ne_bytes()); + bytes[12..16].copy_from_slice(&uid.to_ne_bytes()); + let comm = comm.as_bytes(); + let len = comm.len().min(TASK_COMM_LEN - 1); + bytes[16..16 + len].copy_from_slice(&comm[..len]); + bytes + } + + #[test] + fn decodes_binary_owner_record() { + let bytes = record(123, 456, 789, "nordvpnd"); + let (inode, owner) = decode_owner_record(&bytes).expect("record must decode"); + + assert_eq!(inode, 123); + assert_eq!(owner, SocketOwner::new(456, "nordvpnd", Some(789))); + } + + #[test] + fn reads_records_split_across_io_boundaries() { + struct SplitReader { + bytes: Cursor>, + chunk_size: usize, + } + + impl Read for SplitReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let len = buf.len().min(self.chunk_size); + self.bytes.read(&mut buf[..len]) + } + } + + let bytes = [record(1, 10, 100, "one"), record(2, 20, 200, "two")].concat(); + let mut reader = SplitReader { + bytes: Cursor::new(bytes), + chunk_size: 7, + }; + + let owners = read_owner_records(&mut reader).expect("records must decode"); + assert_eq!(owners.len(), 2); + } + + #[test] + fn rejects_truncated_stream() { + let mut bytes = record(1, 10, 100, "one").to_vec(); + bytes.pop(); + + let error = read_owner_records(&mut Cursor::new(bytes)).unwrap_err(); + assert!(error.to_string().contains("truncated owner record")); + } + + #[test] + #[ignore = "requires CAP_BPF, CAP_PERFMON, and a kernel with task-file iterators"] + fn kernel_iterator_reports_current_process_socket() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener must bind"); + let link = std::fs::read_link(format!("/proc/self/fd/{}", listener.as_raw_fd())) + .expect("listener fd must resolve"); + let link = link.to_str().expect("socket link must be UTF-8"); + let inode = link + .strip_prefix("socket:[") + .and_then(|value| value.strip_suffix(']')) + .and_then(|value| value.parse::().ok()) + .expect("socket link must contain an inode"); + + let owners = snapshot_task_file_owners().expect("task-file iterator must load"); + let owner = owners.get(inode).expect("listener owner must be present"); + assert_eq!(owner.pid, std::process::id()); + } +} diff --git a/crates/rustnet-host/src/linux/enhanced.rs b/crates/rustnet-host/src/linux/enhanced.rs index 8d532f02..78e9208b 100644 --- a/crates/rustnet-host/src/linux/enhanced.rs +++ b/crates/rustnet-host/src/linux/enhanced.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::sync::RwLock; use std::time::{Duration, Instant}; -use super::ebpf::EbpfSocketTracker; +use super::ebpf::{EbpfSocketTracker, snapshot_task_file_owners}; use crate::linux::ebpf::SocketMatch; use crate::linux::process::{refine_truncated_name, resolve_executable, resolve_parent_pid}; use rustnet_core::network::types::ProtocolState; @@ -54,7 +54,6 @@ impl Default for CleanupConfig { impl EnhancedLinuxProcessLookup { pub(super) fn new() -> Result { let cleanup_config = CleanupConfig::default(); - let procfs_lookup = LinuxProcessLookup::new()?; let (ebpf_tracker, degradation_reason) = match EbpfSocketTracker::new() { Ok((tracker_opt, reason)) => { @@ -77,6 +76,33 @@ impl EnhancedLinuxProcessLookup { } }; + // Attach the live tracker before taking the one-shot task-file + // inventory. Connections created during or after the inventory are + // then covered by fentry/kprobe even if the iterator does not visit + // them. Keep the iterator in a separate BPF object so an older kernel + // can reject it without disabling the live tracker. + let startup_owners = if ebpf_tracker.is_some() { + match snapshot_task_file_owners() { + Ok(owners) => { + info!( + "eBPF task-file startup snapshot found {} uniquely owned socket inodes", + owners.len() + ); + owners + } + Err(error) => { + info!( + "eBPF task-file startup snapshot unavailable: {}; using procfs ownership", + error + ); + Default::default() + } + } + } else { + Default::default() + }; + let procfs_lookup = LinuxProcessLookup::new_with_bpf_startup_owners(startup_owners)?; + Ok(Self { ebpf_tracker: RwLock::new(ebpf_tracker), procfs_lookup, diff --git a/crates/rustnet-host/src/linux/process.rs b/crates/rustnet-host/src/linux/process.rs index 5c56c45a..43de4072 100644 --- a/crates/rustnet-host/src/linux/process.rs +++ b/crates/rustnet-host/src/linux/process.rs @@ -7,7 +7,7 @@ use crate::{ }; use anyhow::Result; use rustnet_core::network::types::{Connection, Protocol}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::fs; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::os::unix::fs::MetadataExt; @@ -147,6 +147,52 @@ fn resolve_process_lineage(tgid: u32, ppid: u32) -> Option { /// Map of socket inode to its best-effort process owner. type InodeProcessMap = HashMap; + +/// Socket owners discovered before the procfs connection tables are parsed. +/// +/// The BPF task-file iterator and procfs may both report an inode. Repeated +/// reports from the same TGID are harmless, but an inode held by different +/// processes is ambiguous and must not be attributed to whichever scan entry +/// happened to arrive last. +#[derive(Debug, Default)] +pub(super) struct StartupSocketOwners { + owners: InodeProcessMap, + ambiguous: HashSet, +} + +impl StartupSocketOwners { + pub(super) fn insert(&mut self, inode: u64, owner: SocketOwner) { + if inode == 0 || self.ambiguous.contains(&inode) { + return; + } + + match self.owners.entry(inode) { + Entry::Vacant(entry) => { + entry.insert(owner); + } + Entry::Occupied(mut entry) if entry.get().pid == owner.pid => { + entry.insert(owner); + } + Entry::Occupied(entry) => { + entry.remove(); + self.ambiguous.insert(inode); + } + } + } + + pub(super) fn len(&self) -> usize { + self.owners.len() + } + + #[cfg(test)] + pub(super) fn get(&self, inode: u64) -> Option<&SocketOwner> { + self.owners.get(&inode) + } + + fn into_map(self) -> InodeProcessMap { + self.owners + } +} /// Map of PID to process name #[cfg(feature = "ebpf")] type PidNameMap = HashMap; @@ -155,9 +201,9 @@ type PidNameMap = (); /// Map of connection key to (PID, process name) type ConnectionProcessMap = HashMap; -/// Owner recorded by the privileged startup scan for one pre-existing -/// socket, keyed by its exact 4-tuple. The inode pins the attribution to -/// the socket object itself, not merely the tuple. +/// Owner recorded by the BPF or privileged procfs startup scan for one +/// pre-existing socket, keyed by its exact 4-tuple. The inode pins the +/// attribution to the socket object itself, not merely the tuple. #[derive(Debug, Clone)] struct SnapshotOwner { pid: u32, @@ -259,9 +305,18 @@ pub(super) struct LinuxProcessLookup { impl LinuxProcessLookup { pub(super) fn new() -> Result { + Self::new_with_startup_socket_owners(StartupSocketOwners::default()) + } + + #[cfg(feature = "ebpf")] + pub(super) fn new_with_bpf_startup_owners(owners: StartupSocketOwners) -> Result { + Self::new_with_startup_socket_owners(owners) + } + + fn new_with_startup_socket_owners(owners: StartupSocketOwners) -> Result { // Populate the cache immediately so early connections have process names available. // This ensures the PID→name cache is ready before packet capture starts. - let (process_map, _pid_names, socket_snapshot) = Self::build_process_map()?; + let (process_map, _pid_names, socket_snapshot) = Self::build_process_map(owners)?; Ok(Self { startup_snapshot: build_startup_snapshot(&socket_snapshot), @@ -348,10 +403,11 @@ impl LinuxProcessLookup { } drop(cache); - // Last resort: the privileged startup snapshot, for connections that - // already existed at launch but whose owner the post-uid-drop rescan - // can no longer see. Exact 4-tuple hits only: relaxed matching - // against the snapshot would let a stale listener entry claim new + // Last resort: the BPF or privileged procfs startup snapshot, for + // connections that already existed at launch but whose owner the + // post-uid-drop rescan can no longer see. Exact 4-tuple hits only: + // relaxed matching against the snapshot would let a stale listener + // entry claim new // inbound connections indefinitely. The hit is only trusted while // (a) the very same socket, by inode, still occupies the tuple in // the periodically refreshed socket inventory (which stays readable @@ -363,7 +419,7 @@ impl LinuxProcessLookup { if self.snapshot_socket_unchanged(&key, owner.inode) && snapshot_owner_still_matches(owner.pid, &owner.name) { - return Some((owner.pid, owner.name.clone(), MatchQuality::ProcfsSnapshot)); + return Some((owner.pid, owner.name.clone(), MatchQuality::StartupSnapshot)); } None } @@ -419,12 +475,14 @@ impl LinuxProcessLookup { } /// Build connection -> process mapping and PID -> name mapping - fn build_process_map() -> Result<(ConnectionProcessMap, PidNameMap, SocketSnapshot)> { + fn build_process_map( + startup_owners: StartupSocketOwners, + ) -> Result<(ConnectionProcessMap, PidNameMap, SocketSnapshot)> { let mut process_map = HashMap::new(); let mut sockets = Vec::new(); // First, build inode -> process mapping and PID -> name mapping - let (inode_to_process, pid_names) = Self::build_inode_map()?; + let (inode_to_process, pid_names) = Self::build_inode_map(startup_owners)?; // Then, parse network files to map connections -> inodes -> processes Self::parse_and_map( @@ -460,10 +518,15 @@ impl LinuxProcessLookup { } /// Build inode -> (pid, process_name) mapping and PID -> process_name mapping - fn build_inode_map() -> Result<(InodeProcessMap, PidNameMap)> { - let mut inode_map = HashMap::new(); + fn build_inode_map( + mut startup_owners: StartupSocketOwners, + ) -> Result<(InodeProcessMap, PidNameMap)> { #[cfg(feature = "ebpf")] - let mut pid_names = HashMap::new(); + let mut pid_names = startup_owners + .owners + .values() + .map(|owner| (owner.pid, owner.name.clone())) + .collect::>(); #[cfg(not(feature = "ebpf"))] let pid_names = (); @@ -499,7 +562,7 @@ impl LinuxProcessLookup { && let Some(link_str) = link.to_str() && let Some(inode) = Self::extract_socket_inode(link_str) { - inode_map.insert( + startup_owners.insert( inode, SocketOwner { pid, @@ -513,7 +576,7 @@ impl LinuxProcessLookup { } } - Ok((inode_map, pid_names)) + Ok((startup_owners.into_map(), pid_names)) } /// Parse /proc/net file and map connections to processes @@ -529,6 +592,17 @@ impl LinuxProcessLookup { Err(_) => return Ok(()), // File might not exist }; + Self::parse_and_map_content(&content, protocol, inode_map, result, sockets); + Ok(()) + } + + fn parse_and_map_content( + content: &str, + protocol: Protocol, + inode_map: &InodeProcessMap, + result: &mut ConnectionProcessMap, + sockets: &mut Vec, + ) { for (i, line) in content.lines().enumerate() { if i == 0 { continue; // Skip header @@ -579,8 +653,6 @@ impl LinuxProcessLookup { native_id: inode, }); } - - Ok(()) } fn parse_hex_address(hex_addr: &str) -> Option { @@ -629,7 +701,8 @@ impl ProcessLookup for LinuxProcessLookup { } fn refresh(&self) -> Result<()> { - let (process_map, _pid_names, socket_snapshot) = Self::build_process_map()?; + let (process_map, _pid_names, socket_snapshot) = + Self::build_process_map(StartupSocketOwners::default())?; *self.cache.write().expect("process cache lock poisoned") = process_map; *self @@ -683,6 +756,61 @@ mod tests { } } + #[test] + fn startup_socket_owners_reject_cross_process_ambiguity() { + let mut owners = StartupSocketOwners::default(); + owners.insert(42, SocketOwner::new(10, "first", Some(1000))); + owners.insert(42, SocketOwner::new(20, "second", Some(1001))); + owners.insert(42, SocketOwner::new(10, "first", Some(1000))); + + assert_eq!(owners.len(), 0); + assert!(owners.get(42).is_none()); + } + + #[test] + fn startup_socket_owners_collapse_same_process_duplicates() { + let mut owners = StartupSocketOwners::default(); + owners.insert(42, SocketOwner::new(10, "old-name", None)); + owners.insert(42, SocketOwner::new(10, "current-name", Some(1000))); + + assert_eq!( + owners.get(42), + Some(&SocketOwner::new(10, "current-name", Some(1000))) + ); + } + + #[test] + fn bpf_startup_owner_joins_the_procfs_socket_inode() { + let mut owners = StartupSocketOwners::default(); + owners.insert(123, SocketOwner::new(10, "nordvpnd", Some(0))); + let inode_map = owners.into_map(); + let mut process_map = ConnectionProcessMap::new(); + let mut sockets = Vec::new(); + let table = concat!( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n", + " 0: 0100007F:1F90 08080808:01BB 01 00000000:00000000 00:00000000 00000000 0 0 123\n", + ); + + LinuxProcessLookup::parse_and_map_content( + table, + Protocol::Tcp, + &inode_map, + &mut process_map, + &mut sockets, + ); + + let socket = sockets.first().expect("socket row must parse"); + assert_eq!(socket.native_id, Some(123)); + assert_eq!( + socket.owner, + Some(SocketOwner::new(10, "nordvpnd", Some(0))) + ); + assert_eq!( + process_map.get(&key("127.0.0.1:8080", "8.8.8.8:443")), + Some(&(10, "nordvpnd".to_string())) + ); + } + /// Attribute to this very test process so the /proc reads have a live /// target with known credentials and a known executable. fn own_pid() -> u32 { @@ -782,8 +910,8 @@ mod tests { #[test] fn startup_snapshot_attributes_when_the_rescanned_table_cannot() { - // A connection whose owner is present in the privileged startup - // scan but invisible to every post-uid-drop rescan: same socket + // A connection whose owner is present in the startup owner scan but + // invisible to every post-uid-drop rescan: same socket // (same inode) still on the tuple, owner still alive. The snapshot // records this very test process so /proc validation has a real // target. @@ -803,7 +931,7 @@ mod tests { lookup.lookup_match(&conn).expect("snapshot should match"); assert_eq!(got_pid, own_pid()); assert_eq!(got_name, own_comm()); - assert_eq!(quality, MatchQuality::ProcfsSnapshot); + assert_eq!(quality, MatchQuality::StartupSnapshot); } #[test] From 7998e7af0e5485e140e4ca7829c45cee911fdcd1 Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Fri, 28 Aug 2026 13:39:04 +0200 Subject: [PATCH 3/3] fix(linux): keep owners for fork-shared socket inodes The shared-inode rejection now applies only to the startup snapshot. The live procfs table keeps a deterministic owner (lowest PID), so a pre-forking server's inherited listener stays attributable. Also validate the snapshot owner's start time: comm alone does not survive PID reuse. --- crates/rustnet-host/src/linux/enhanced.rs | 2 +- crates/rustnet-host/src/linux/process.rs | 215 +++++++++++++++++----- 2 files changed, 174 insertions(+), 43 deletions(-) diff --git a/crates/rustnet-host/src/linux/enhanced.rs b/crates/rustnet-host/src/linux/enhanced.rs index 78e9208b..c9f25662 100644 --- a/crates/rustnet-host/src/linux/enhanced.rs +++ b/crates/rustnet-host/src/linux/enhanced.rs @@ -85,7 +85,7 @@ impl EnhancedLinuxProcessLookup { match snapshot_task_file_owners() { Ok(owners) => { info!( - "eBPF task-file startup snapshot found {} uniquely owned socket inodes", + "eBPF task-file startup snapshot found owners for {} socket inodes", owners.len() ); owners diff --git a/crates/rustnet-host/src/linux/process.rs b/crates/rustnet-host/src/linux/process.rs index 43de4072..d0e82d6d 100644 --- a/crates/rustnet-host/src/linux/process.rs +++ b/crates/rustnet-host/src/linux/process.rs @@ -151,18 +151,21 @@ type InodeProcessMap = HashMap; /// Socket owners discovered before the procfs connection tables are parsed. /// /// The BPF task-file iterator and procfs may both report an inode. Repeated -/// reports from the same TGID are harmless, but an inode held by different -/// processes is ambiguous and must not be attributed to whichever scan entry -/// happened to arrive last. +/// reports from the same TGID are harmless, but fork and fd passing let +/// several processes hold one socket: a pre-forking server's workers all +/// carry the listening inode their master opened. Those inodes keep a +/// best-effort owner for the live table, chosen by lowest PID so that +/// /proc iteration order cannot change the answer between refreshes, and are +/// reported separately as shared so the startup snapshot can skip them. #[derive(Debug, Default)] pub(super) struct StartupSocketOwners { owners: InodeProcessMap, - ambiguous: HashSet, + shared: HashSet, } impl StartupSocketOwners { pub(super) fn insert(&mut self, inode: u64, owner: SocketOwner) { - if inode == 0 || self.ambiguous.contains(&inode) { + if inode == 0 { return; } @@ -170,12 +173,16 @@ impl StartupSocketOwners { Entry::Vacant(entry) => { entry.insert(owner); } + // The same process seen twice: prefer the later report, which is + // the procfs scan refreshing what BPF recorded first. Entry::Occupied(mut entry) if entry.get().pid == owner.pid => { entry.insert(owner); } - Entry::Occupied(entry) => { - entry.remove(); - self.ambiguous.insert(inode); + Entry::Occupied(mut entry) => { + self.shared.insert(inode); + if owner.pid < entry.get().pid { + entry.insert(owner); + } } } } @@ -189,8 +196,10 @@ impl StartupSocketOwners { self.owners.get(&inode) } - fn into_map(self) -> InodeProcessMap { - self.owners + /// The owner map for the live socket table, plus the inodes held by more + /// than one process. + fn into_parts(self) -> (InodeProcessMap, HashSet) { + (self.owners, self.shared) } } /// Map of PID to process name @@ -209,6 +218,16 @@ struct SnapshotOwner { pid: u32, name: String, inode: u64, + /// Process start time in clock ticks, when it was readable at startup. + /// `comm` alone cannot survive PID reuse because any process may rename + /// itself with `prctl(PR_SET_NAME)`; the start time cannot be forged. + start_ticks: Option, +} + +/// Process start time in clock ticks, stable for the life of the process. +fn process_start_ticks(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + Some(parse_proc_stat(&stat)?.start_ticks) } /// Build the startup fallback table from the startup socket inventory, @@ -216,9 +235,14 @@ struct SnapshotOwner { /// come from different sockets. A tuple occupied by more than one socket /// (SO_REUSEPORT plus connected UDP makes exact duplicates legal) is /// dropped entirely, even when only one of its rows resolved an owner: -/// observed traffic cannot be assigned to either socket. Rows without an -/// inode or a resolved owner contribute no entry of their own. -fn build_startup_snapshot(sockets: &SocketSnapshot) -> HashMap { +/// observed traffic cannot be assigned to either socket. So is a socket held +/// by several processes, whose single owner is a best-effort pick the live +/// table can live with but a long-lived fallback should not freeze. Rows +/// without an inode or a resolved owner contribute no entry of their own. +fn build_startup_snapshot( + sockets: &SocketSnapshot, + shared_inodes: &HashSet, +) -> HashMap { let key_of = |socket: &HostSocket| { socket.remote_addr.map(|remote_addr| ConnectionKey { protocol: socket.protocol, @@ -241,7 +265,7 @@ fn build_startup_snapshot(sockets: &SocketSnapshot) -> HashMap HashMap HashMap/comm` (world-readable even after the uid drop) must -/// still exist and match the name captured at startup. This rejects owners -/// that have exited and PIDs since reused by a different program. -fn snapshot_owner_still_matches(pid: u32, name: &str) -> bool { - fs::read_to_string(format!("/proc/{pid}/comm")) - .map(|comm| comm.trim() == name) - .unwrap_or(false) +/// process. `/proc/` stays world-readable after the uid drop, so both +/// the name captured at startup and, when it was readable, the process start +/// time must still match. This rejects owners that have exited and PIDs +/// since reused by a different program, including one that renamed itself to +/// impersonate the original. +fn snapshot_owner_still_matches(owner: &SnapshotOwner) -> bool { + let name_matches = fs::read_to_string(format!("/proc/{}/comm", owner.pid)) + .map(|comm| comm.trim() == owner.name) + .unwrap_or(false); + + name_matches + && match owner.start_ticks { + Some(start_ticks) => process_start_ticks(owner.pid) == Some(start_ticks), + None => true, + } } fn parse_proc_tcp_state(value: &str) -> HostTcpState { @@ -316,10 +349,11 @@ impl LinuxProcessLookup { fn new_with_startup_socket_owners(owners: StartupSocketOwners) -> Result { // Populate the cache immediately so early connections have process names available. // This ensures the PID→name cache is ready before packet capture starts. - let (process_map, _pid_names, socket_snapshot) = Self::build_process_map(owners)?; + let (process_map, _pid_names, socket_snapshot, shared_inodes) = + Self::build_process_map(owners)?; Ok(Self { - startup_snapshot: build_startup_snapshot(&socket_snapshot), + startup_snapshot: build_startup_snapshot(&socket_snapshot, &shared_inodes), cache: RwLock::new(process_map), #[cfg(feature = "ebpf")] pid_names: RwLock::new(_pid_names), @@ -416,8 +450,7 @@ impl LinuxProcessLookup { // verifiably still the same process. The refresh cadence bounds the // reuse-detection window to one refresh interval. let owner = self.startup_snapshot.get(&key)?; - if self.snapshot_socket_unchanged(&key, owner.inode) - && snapshot_owner_still_matches(owner.pid, &owner.name) + if self.snapshot_socket_unchanged(&key, owner.inode) && snapshot_owner_still_matches(owner) { return Some((owner.pid, owner.name.clone(), MatchQuality::StartupSnapshot)); } @@ -477,12 +510,17 @@ impl LinuxProcessLookup { /// Build connection -> process mapping and PID -> name mapping fn build_process_map( startup_owners: StartupSocketOwners, - ) -> Result<(ConnectionProcessMap, PidNameMap, SocketSnapshot)> { + ) -> Result<( + ConnectionProcessMap, + PidNameMap, + SocketSnapshot, + HashSet, + )> { let mut process_map = HashMap::new(); let mut sockets = Vec::new(); // First, build inode -> process mapping and PID -> name mapping - let (inode_to_process, pid_names) = Self::build_inode_map(startup_owners)?; + let (inode_to_process, pid_names, shared_inodes) = Self::build_inode_map(startup_owners)?; // Then, parse network files to map connections -> inodes -> processes Self::parse_and_map( @@ -514,13 +552,18 @@ impl LinuxProcessLookup { &mut sockets, )?; - Ok((process_map, pid_names, SocketSnapshot::new(sockets))) + Ok(( + process_map, + pid_names, + SocketSnapshot::new(sockets), + shared_inodes, + )) } /// Build inode -> (pid, process_name) mapping and PID -> process_name mapping fn build_inode_map( mut startup_owners: StartupSocketOwners, - ) -> Result<(InodeProcessMap, PidNameMap)> { + ) -> Result<(InodeProcessMap, PidNameMap, HashSet)> { #[cfg(feature = "ebpf")] let mut pid_names = startup_owners .owners @@ -576,7 +619,8 @@ impl LinuxProcessLookup { } } - Ok((startup_owners.into_map(), pid_names)) + let (inode_map, shared_inodes) = startup_owners.into_parts(); + Ok((inode_map, pid_names, shared_inodes)) } /// Parse /proc/net file and map connections to processes @@ -701,7 +745,7 @@ impl ProcessLookup for LinuxProcessLookup { } fn refresh(&self) -> Result<()> { - let (process_map, _pid_names, socket_snapshot) = + let (process_map, _pid_names, socket_snapshot, _shared_inodes) = Self::build_process_map(StartupSocketOwners::default())?; *self.cache.write().expect("process cache lock poisoned") = process_map; @@ -757,14 +801,43 @@ mod tests { } #[test] - fn startup_socket_owners_reject_cross_process_ambiguity() { + fn startup_socket_owners_report_a_shared_inode_without_losing_its_owner() { + // A pre-forking server: the master opened the listening socket and + // every worker inherited it. Dropping the inode would leave the + // listener ownerless in the live table, so the lowest PID (the + // master) wins and the inode is reported as shared instead. let mut owners = StartupSocketOwners::default(); - owners.insert(42, SocketOwner::new(10, "first", Some(1000))); - owners.insert(42, SocketOwner::new(20, "second", Some(1001))); - owners.insert(42, SocketOwner::new(10, "first", Some(1000))); + owners.insert(42, SocketOwner::new(1001, "nginx", Some(33))); + owners.insert(42, SocketOwner::new(1000, "nginx", Some(0))); + owners.insert(42, SocketOwner::new(1002, "nginx", Some(33))); + + assert_eq!( + owners.get(42), + Some(&SocketOwner::new(1000, "nginx", Some(0))) + ); - assert_eq!(owners.len(), 0); - assert!(owners.get(42).is_none()); + let (inode_map, shared) = owners.into_parts(); + assert_eq!( + inode_map.get(&42), + Some(&SocketOwner::new(1000, "nginx", Some(0))) + ); + assert!(shared.contains(&42)); + } + + #[test] + fn startup_socket_owners_pick_a_shared_inode_owner_independent_of_scan_order() { + // /proc iteration order must not change the answer between refreshes. + let mut forward = StartupSocketOwners::default(); + for pid in [1000, 1001, 1002] { + forward.insert(42, SocketOwner::new(pid, "nginx", Some(0))); + } + let mut reverse = StartupSocketOwners::default(); + for pid in [1002, 1001, 1000] { + reverse.insert(42, SocketOwner::new(pid, "nginx", Some(0))); + } + + assert_eq!(forward.get(42).map(|owner| owner.pid), Some(1000)); + assert_eq!(reverse.get(42).map(|owner| owner.pid), Some(1000)); } #[test] @@ -783,7 +856,7 @@ mod tests { fn bpf_startup_owner_joins_the_procfs_socket_inode() { let mut owners = StartupSocketOwners::default(); owners.insert(123, SocketOwner::new(10, "nordvpnd", Some(0))); - let inode_map = owners.into_map(); + let (inode_map, _shared) = owners.into_parts(); let mut process_map = ConnectionProcessMap::new(); let mut sockets = Vec::new(); let table = concat!( @@ -851,7 +924,15 @@ mod tests { ) -> LinuxProcessLookup { let (local, remote, pid, name, inode) = owner; let mut startup = HashMap::new(); - startup.insert(key(local, remote), SnapshotOwner { pid, name, inode }); + startup.insert( + key(local, remote), + SnapshotOwner { + pid, + name, + inode, + start_ticks: process_start_ticks(pid), + }, + ); LinuxProcessLookup { startup_snapshot: startup, cache: RwLock::new(ConnectionProcessMap::new()), @@ -876,7 +957,7 @@ mod tests { owned_socket("192.168.1.10:44444", "203.0.113.5:22", 777, 41, "sshd"), owned_socket("192.168.1.10:55555", "203.0.113.6:443", 888, 42, "nginx"), ]); - let snapshot = build_startup_snapshot(&sockets); + let snapshot = build_startup_snapshot(&sockets, &HashSet::new()); let a = &snapshot[&key("192.168.1.10:44444", "203.0.113.5:22")]; assert_eq!((a.pid, a.name.as_str(), a.inode), (41, "sshd", 777)); @@ -893,11 +974,28 @@ mod tests { let owned = owned_socket("192.168.1.10:5353", "203.0.113.5:5353", 777, 41, "resolver"); let ownerless = host_socket("192.168.1.10:5353", "203.0.113.5:5353", 778); let sockets = SocketSnapshot::new(vec![owned, ownerless]); - let snapshot = build_startup_snapshot(&sockets); + let snapshot = build_startup_snapshot(&sockets, &HashSet::new()); assert!(snapshot.is_empty()); } + #[test] + fn startup_snapshot_skips_a_socket_held_by_several_processes() { + // The live table keeps the master as a best-effort owner, but the + // long-lived fallback must not freeze that pick: the master can exit + // while a worker keeps the socket open. + let sockets = SocketSnapshot::new(vec![owned_socket( + "192.168.1.10:44444", + "203.0.113.5:22", + 777, + 1000, + "nginx", + )]); + let shared = HashSet::from([777]); + + assert!(build_startup_snapshot(&sockets, &shared).is_empty()); + } + #[test] fn startup_snapshot_skips_ownerless_and_inodeless_rows() { let ownerless = host_socket("192.168.1.10:44444", "203.0.113.5:22", 777); @@ -905,7 +1003,7 @@ mod tests { inodeless.native_id = None; let sockets = SocketSnapshot::new(vec![ownerless, inodeless]); - assert!(build_startup_snapshot(&sockets).is_empty()); + assert!(build_startup_snapshot(&sockets, &HashSet::new()).is_empty()); } #[test] @@ -991,6 +1089,38 @@ mod tests { assert!(lookup.lookup_match(&conn).is_none()); } + #[test] + fn startup_snapshot_rejects_a_reused_pid_that_took_the_owner_name() { + // Socket unchanged and a live process answers to the recorded name, + // but it started later: any process can rename itself with + // prctl(PR_SET_NAME), so only the start time settles PID reuse. + let mut startup = HashMap::new(); + startup.insert( + key("192.168.1.10:44444", "203.0.113.5:22"), + SnapshotOwner { + pid: own_pid(), + name: own_comm(), + inode: 777, + start_ticks: process_start_ticks(own_pid()).map(|ticks| ticks + 1), + }, + ); + let lookup = LinuxProcessLookup { + startup_snapshot: startup, + cache: RwLock::new(ConnectionProcessMap::new()), + #[cfg(feature = "ebpf")] + pid_names: RwLock::new(HashMap::new()), + lineages: RwLock::new(HashMap::new()), + socket_snapshot: RwLock::new(SocketSnapshot::new(vec![host_socket( + "192.168.1.10:44444", + "203.0.113.5:22", + 777, + )])), + }; + + let conn = connection("192.168.1.10:44444", "203.0.113.5:22"); + assert!(lookup.lookup_match(&conn).is_none()); + } + #[test] fn startup_snapshot_never_matches_relaxed_shapes() { // A stale wildcard listener entry in the snapshot must not claim @@ -1016,6 +1146,7 @@ mod tests { pid: 4242, name: "old-owner".to_string(), inode: 777, + start_ticks: None, }, ); let mut live = ConnectionProcessMap::new();