diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 83f643f..8eeec19 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -13,6 +13,8 @@ use crate::sys::structs::{ BPF_ABS, BPF_ALU, BPF_AND, BPF_JEQ, BPF_JSET, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, CLONE_NS_FLAGS, DEFAULT_DENY_SYSCALLS, EPERM, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, + SIOCETHTOOL, SIOCGIFADDR, SIOCGIFBRDADDR, SIOCGIFCONF, SIOCGIFDSTADDR, + SIOCGIFFLAGS, SIOCGIFHWADDR, SIOCGIFINDEX, SIOCGIFNAME, SIOCGIFNETMASK, SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI, PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER, OFFSET_ARGS0_LO, OFFSET_ARGS1_LO, OFFSET_ARGS2_LO, OFFSET_ARGS3_LO, OFFSET_NR, @@ -446,7 +448,7 @@ pub fn deny_syscall_numbers(policy: &Policy) -> Vec { /// /// Returns a `Vec` containing self-contained BPF blocks for: /// - clone: block namespace creation flags -/// - ioctl: block TIOCSTI, TIOCLINUX +/// - ioctl: block TIOCSTI, TIOCLINUX, SIOCGIF*, SIOCETHTOOL /// - prctl: block PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER /// - socket: block SOCK_RAW/SOCK_DGRAM on AF_INET/AF_INET6 (with type mask) pub fn arg_filters(policy: &Policy) -> Vec { @@ -471,9 +473,25 @@ pub fn arg_filters(policy: &Policy) -> Vec { insns.push(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NS_FLAGS as u32, 0, 1)); insns.push(stmt(BPF_RET | BPF_K, ret_errno)); - // --- ioctl: block dangerous commands (TIOCSTI, TIOCLINUX) --- + // --- ioctl: block dangerous commands --- + // Block terminal injection (TIOCSTI, TIOCLINUX) and network interface + // enumeration ioctls (SIOCGIF*, SIOCETHTOOL) to complement NETLINK_ROUTE + // virtualization. // Layout: LD NR, JEQ ioctl (skip 1 + N*2), LD arg1, [JEQ cmd, RET ERRNO] * N - let dangerous_ioctls: &[u32] = &[TIOCSTI as u32, TIOCLINUX as u32]; + let dangerous_ioctls: &[u32] = &[ + TIOCSTI as u32, + TIOCLINUX as u32, + SIOCGIFNAME as u32, + SIOCGIFCONF as u32, + SIOCGIFFLAGS as u32, + SIOCGIFADDR as u32, + SIOCGIFDSTADDR as u32, + SIOCGIFBRDADDR as u32, + SIOCGIFNETMASK as u32, + SIOCGIFHWADDR as u32, + SIOCGIFINDEX as u32, + SIOCETHTOOL as u32, + ]; let n_ioctls = dangerous_ioctls.len(); let skip_count = (1 + n_ioctls * 2) as u8; insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR)); @@ -1089,11 +1107,15 @@ mod tests { // Should contain JEQ for ioctl syscall nr assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) && f.k == libc::SYS_ioctl as u32)); - // Should contain JEQ for TIOCSTI and TIOCLINUX + // Should contain JEQ for TIOCSTI, TIOCLINUX, and SIOCGIF*/SIOCETHTOOL assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) && f.k == TIOCSTI as u32)); assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) && f.k == TIOCLINUX as u32)); + assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) + && f.k == SIOCGIFCONF as u32)); + assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) + && f.k == SIOCETHTOOL as u32)); // Should contain JEQ for prctl syscall nr assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) && f.k == libc::SYS_prctl as u32)); diff --git a/crates/sandlock-core/src/procfs.rs b/crates/sandlock-core/src/procfs.rs index ffbbffa..9878150 100644 --- a/crates/sandlock-core/src/procfs.rs +++ b/crates/sandlock-core/src/procfs.rs @@ -28,6 +28,7 @@ const SENSITIVE_PATHS: &[&str] = &[ "/proc/keys", "/proc/key-users", "/proc/sysrq-trigger", + "/sys/class/net", "/sys/firmware", "/sys/kernel/security", ]; @@ -251,6 +252,25 @@ pub(crate) fn generate_proc_mountinfo( buf.into_bytes() } +// ============================================================ +// /proc/net/dev and /proc/net/if_inet6 virtualization +// ============================================================ + +/// Generate a synthetic /proc/net/dev showing only the loopback interface. +pub(crate) fn generate_proc_net_dev() -> Vec { + concat!( + "Inter-| Receive | Transmit\n", + " face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n", + " lo: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + ).as_bytes().to_vec() +} + +/// Generate a synthetic /proc/net/if_inet6 showing only loopback (::1). +pub(crate) fn generate_proc_net_if_inet6() -> Vec { + // Format: address ifindex prefix_len scope flags ifname + b"00000000000000000000000000000001 01 80 10 80 lo\n".to_vec() +} + // ============================================================ // /proc/net/tcp filtering // ============================================================ @@ -424,6 +444,14 @@ pub(crate) async fn handle_proc_open( return inject_memfd(&content); } + // Virtualize /proc/net/dev and /proc/net/if_inet6 — show loopback only. + if path == "/proc/net/dev" { + return inject_memfd(&generate_proc_net_dev()); + } + if path == "/proc/net/if_inet6" { + return inject_memfd(&generate_proc_net_if_inet6()); + } + // Virtualize /proc/net/tcp and /proc/net/tcp6 when port_remap is active. if policy.port_remap && (path == "/proc/net/tcp" || path == "/proc/net/tcp6") { let is_v6 = path.ends_with('6'); @@ -857,7 +885,8 @@ mod tests { assert!(!is_sensitive_proc("/proc/cpuinfo")); assert!(!is_sensitive_proc("/proc/meminfo")); assert!(!is_sensitive_proc("/proc/1/status")); - assert!(!is_sensitive_proc("/sys/class/net")); + assert!(is_sensitive_proc("/sys/class/net")); + assert!(is_sensitive_proc("/sys/class/net/eth0")); } #[test] diff --git a/crates/sandlock-core/src/sys/structs.rs b/crates/sandlock-core/src/sys/structs.rs index 2c85021..1f68ee8 100644 --- a/crates/sandlock-core/src/sys/structs.rs +++ b/crates/sandlock-core/src/sys/structs.rs @@ -220,6 +220,18 @@ pub const CLONE_NS_FLAGS: u64 = CLONE_NEWNS pub const TIOCSTI: u64 = 0x5412; pub const TIOCLINUX: u64 = 0x541C; +// Network interface ioctls (linux/sockios.h) +pub const SIOCGIFNAME: u64 = 0x8910; +pub const SIOCGIFCONF: u64 = 0x8912; +pub const SIOCGIFFLAGS: u64 = 0x8913; +pub const SIOCGIFADDR: u64 = 0x8915; +pub const SIOCGIFDSTADDR: u64 = 0x8917; +pub const SIOCGIFBRDADDR: u64 = 0x8919; +pub const SIOCGIFNETMASK: u64 = 0x891B; +pub const SIOCGIFHWADDR: u64 = 0x8927; +pub const SIOCGIFINDEX: u64 = 0x8933; +pub const SIOCETHTOOL: u64 = 0x8946; + // ============================================================ // Dangerous prctl options // ============================================================ diff --git a/crates/sandlock-core/tests/integration/test_netlink_virt.rs b/crates/sandlock-core/tests/integration/test_netlink_virt.rs index c721a85..3b00773 100644 --- a/crates/sandlock-core/tests/integration/test_netlink_virt.rs +++ b/crates/sandlock-core/tests/integration/test_netlink_virt.rs @@ -93,6 +93,140 @@ async fn getaddrinfo_ai_addrconfig_returns_v4_and_v6() { assert!(result.success()); } +/// /proc/net/dev should be virtualized to show only loopback. +#[tokio::test] +async fn proc_net_dev_shows_only_lo() { + let out = temp_out("proc-net-dev"); + let script = format!(concat!( + "lines = open('/proc/net/dev').readlines()\n", + "ifaces = [l.split(':')[0].strip() for l in lines[2:]]\n", + "open('{out}', 'w').write(','.join(ifaces))\n", + ), out = out.display()); + + let policy = base_policy().build().unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!(contents.trim(), "lo", "expected only lo in /proc/net/dev, got: {}", contents); + assert!(result.success()); +} + +/// /proc/net/if_inet6 should be virtualized to show only loopback. +#[tokio::test] +async fn proc_net_if_inet6_shows_only_lo() { + let out = temp_out("proc-net-if-inet6"); + let script = format!(concat!( + "lines = open('/proc/net/if_inet6').readlines()\n", + "ifaces = [l.split()[-1] for l in lines if l.strip()]\n", + "open('{out}', 'w').write(','.join(ifaces))\n", + ), out = out.display()); + + let policy = base_policy().build().unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!(contents.trim(), "lo", "expected only lo in /proc/net/if_inet6, got: {}", contents); + assert!(result.success()); +} + +/// SIOCGIFCONF ioctl should be blocked by the BPF arg filter, returning EPERM. +#[tokio::test] +async fn ioctl_siocgifconf_blocked() { + let out = temp_out("ioctl-siocgifconf"); + let script = format!(concat!( + "import fcntl, struct, socket, errno\n", + "SIOCGIFCONF = 0x8912\n", + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "buf = b'\\x00' * 4096\n", + "ifc = struct.pack('iP', len(buf), 0)\n", + "try:\n", + " fcntl.ioctl(s.fileno(), SIOCGIFCONF, ifc)\n", + " result = 'ALLOWED'\n", + "except OSError as e:\n", + " result = f'BLOCKED:{{e.errno}}'\n", + "finally:\n", + " s.close()\n", + "open('{out}', 'w').write(result)\n", + ), out = out.display()); + + let policy = base_policy().build().unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!( + contents.trim(), + &format!("BLOCKED:{}", libc::EPERM), + "SIOCGIFCONF should be blocked with EPERM, got: {}", contents + ); + assert!(result.success()); +} + +/// SIOCETHTOOL ioctl should be blocked by the BPF arg filter. +#[tokio::test] +async fn ioctl_siocethtool_blocked() { + let out = temp_out("ioctl-siocethtool"); + let script = format!(concat!( + "import fcntl, struct, socket\n", + "SIOCETHTOOL = 0x8946\n", + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "ifr = struct.pack('16sP', b'eth0', 0)\n", + "try:\n", + " fcntl.ioctl(s.fileno(), SIOCETHTOOL, ifr)\n", + " result = 'ALLOWED'\n", + "except OSError as e:\n", + " result = f'BLOCKED:{{e.errno}}'\n", + "finally:\n", + " s.close()\n", + "open('{out}', 'w').write(result)\n", + ), out = out.display()); + + let policy = base_policy().build().unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!( + contents.trim(), + &format!("BLOCKED:{}", libc::EPERM), + "SIOCETHTOOL should be blocked with EPERM, got: {}", contents + ); + assert!(result.success()); +} + +/// /sys/class/net should be blocked as a sensitive path. +#[tokio::test] +async fn sys_class_net_blocked() { + let out = temp_out("sys-class-net"); + let script = format!(concat!( + "import os\n", + "try:\n", + " entries = os.listdir('/sys/class/net')\n", + " result = 'ALLOWED:' + ','.join(entries)\n", + "except OSError as e:\n", + " result = f'BLOCKED:{{e.errno}}'\n", + "open('{out}', 'w').write(result)\n", + ), out = out.display()); + + let policy = base_policy().build().unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert!( + contents.starts_with("BLOCKED:"), + "/sys/class/net should be blocked, got: {}", contents + ); + assert!(result.success()); +} + #[tokio::test] async fn non_route_netlink_still_blocked() { let out = temp_out("netlink-audit-blocked");