| 1 | //! Linux seccomp (Secure Computing) filter layer (#2182). |
| 2 | //! |
| 3 | //! This module is dormant in v0.9.1: command execution does not install this |
| 4 | //! filter. Kernel support therefore does not mean that a command is sandboxed. |
| 5 | //! |
| 6 | //! Seccomp BPF (Berkeley Packet Filter) is a kernel facility that allows a |
| 7 | //! process to restrict the system calls it (and its descendants) can make. |
| 8 | //! Once wired, the implementation can complement a filesystem sandbox by |
| 9 | //! blocking entire *classes* of dangerous syscalls like `ptrace`, `mount`, |
| 10 | //! and `kexec_load`. |
| 11 | //! |
| 12 | //! # Architecture |
| 13 | //! |
| 14 | //! The filter is written as a raw BPF program (array of `sock_filter` |
| 15 | //! instructions) and loaded via `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)`. |
| 16 | //! This avoids any dependency on external crates like `libseccomp-sys` or |
| 17 | //! `seccompiler` — we use only the `libc` crate already in the dependency |
| 18 | //! tree. |
| 19 | //! |
| 20 | //! # Whitelisted syscalls |
| 21 | //! |
| 22 | //! The filter uses a whitelist approach: only syscalls that are known to be |
| 23 | //! safe for a development/shell workload are allowed. Everything else is |
| 24 | //! killed with `SECCOMP_RET_KILL_PROCESS`. The whitelist includes: |
| 25 | //! |
| 26 | //! - File I/O: read, write, open, openat, close, stat, fstat, lstat, newfstatat |
| 27 | //! - Directory: getdents, getdents64, getcwd, chdir |
| 28 | //! - Memory: mmap, mprotect, munmap, brk, mremap, madvise |
| 29 | //! - Process: clone, clone3, fork, vfork, execve, execveat, exit, exit_group |
| 30 | //! - IPC: pipe, pipe2, socket, socketpair, connect, bind, listen, accept, accept4 |
| 31 | //! - Synchronization: futex, nanosleep, clock_nanosleep |
| 32 | //! - Signals: rt_sigaction, rt_sigprocmask, rt_sigreturn, kill, tkill, tgkill |
| 33 | //! - Resource: getrlimit, setrlimit, prlimit64, getrusage |
| 34 | //! - Time: clock_gettime, gettimeofday, time |
| 35 | //! - Misc: getpid, gettid, getuid, geteuid, getgid, getegid, uname, arch_prctl |
| 36 | //! |
| 37 | //! # Explicitly denied |
| 38 | //! |
| 39 | //! - ptrace (process hijacking) |
| 40 | //! - mount, umount2 (filesystem manipulation) |
| 41 | //! - kexec_load, kexec_file_load (kernel execution) |
| 42 | //! - init_module, finit_module, delete_module (kernel module loading) |
| 43 | //! - bpf (loading BPF programs — would bypass seccomp!) |
| 44 | //! - reboot |
| 45 | //! - swapon, swapoff |
| 46 | //! - pivot_root |
| 47 | //! - setuid, setgid, setreuid, setregid, setresuid, setresgid |
| 48 | //! - personality |
| 49 | //! |
| 50 | //! # Safety |
| 51 | //! |
| 52 | //! Once the seccomp filter is installed, it is **irreversible** — even |
| 53 | //! `prctl(PR_SET_SECCOMP, ...)` is denied. This is by design. |
| 54 | |
| 55 | /// Check if seccomp is available on this system. |
| 56 | /// |
| 57 | /// Returns true if `/proc/sys/kernel/seccomp/actions_avail` exists and |
| 58 | /// contains "kill_process", indicating the kernel supports seccomp BPF. |
| 59 | #[cfg(target_os = "linux")] |
| 60 | pub fn is_available() -> bool { |
| 61 | std::path::Path::new("/proc/sys/kernel/seccomp/actions_avail").exists() |
| 62 | } |
| 63 | |
| 64 | #[cfg(not(target_os = "linux"))] |
| 65 | pub fn is_available() -> bool { |
| 66 | false |
| 67 | } |
| 68 | |
| 69 | /// Detect if a failure was caused by seccomp denial. |
| 70 | /// |
| 71 | /// Seccomp kills the process with SIGSYS (or the thread with SECCOMP_RET_KILL_THREAD), |
| 72 | /// and the exit code is typically SIGSYS (31) or the process may be killed with |
| 73 | /// "Bad system call" on stderr. |
| 74 | /// |
| 75 | /// Additionally, seccomp violations may produce EPERM for filtered syscalls |
| 76 | /// if using SECCOMP_RET_ERRNO. |
| 77 | #[cfg(target_os = "linux")] |
| 78 | pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { |
| 79 | // SIGSYS = 31 |
| 80 | if exit_code == 31 { |
| 81 | return true; |
| 82 | } |
| 83 | // Check for seccomp denial patterns in stderr |
| 84 | stderr.contains("Bad system call") |
| 85 | || stderr.contains("bad system call") |
| 86 | || stderr.contains("SIGSYS") |
| 87 | || stderr.contains("seccomp") |
| 88 | || stderr.contains("invalid argument") && exit_code == 159 |
| 89 | // 159 = 128 + 31 (died from SIGSYS with core dump disabled) |
| 90 | } |
| 91 | |
| 92 | #[cfg(not(target_os = "linux"))] |
| 93 | pub fn detect_denial(_exit_code: i32, _stderr: &str) -> bool { |
| 94 | false |
| 95 | } |
| 96 | |
| 97 | /// Apply the seccomp filter to the calling thread. |
| 98 | /// |
| 99 | /// This installs a BPF program that whitelists safe syscalls and kills the |
| 100 | /// process on any disallowed syscall. |
| 101 | /// |
| 102 | /// # Errors |
| 103 | /// |
| 104 | /// Returns an error if the prctl call fails (e.g., seccomp already enabled |
| 105 | /// or kernel too old). |
| 106 | #[cfg(target_os = "linux")] |
| 107 | pub fn apply_seccomp_filter() -> std::io::Result<()> { |
| 108 | // ── Build the BPF filter program ───────────────────────────────────── |
| 109 | // |
| 110 | // BPF for seccomp works as follows: |
| 111 | // 1. Load the architecture (4 bytes at offset 4 in seccomp_data) |
| 112 | // 2. Validate architecture matches AUDIT_ARCH_X86_64 (0xC000003E) |
| 113 | // 3. Load the syscall number (4 bytes at offset 0) |
| 114 | // 4. Compare against whitelist, return ALLOW on match |
| 115 | // 5. Return KILL on no match |
| 116 | // |
| 117 | // The filter uses a linear search over the whitelist. While not optimal, |
| 118 | // it's simple, auditable, and has no external dependencies. The BPF |
| 119 | // program is at most a few hundred instructions, which is well within |
| 120 | // the kernel's 4096-instruction limit. |
| 121 | |
| 122 | #[repr(C)] |
| 123 | struct sock_filter { |
| 124 | code: u16, |
| 125 | jt: u8, |
| 126 | jf: u8, |
| 127 | k: u32, |
| 128 | } |
| 129 | |
| 130 | const BPF_LD: u16 = 0x00; |
| 131 | const BPF_JMP: u16 = 0x05; |
| 132 | const BPF_RET: u16 = 0x06; |
| 133 | |
| 134 | const BPF_W: u16 = 0x00; |
| 135 | const BPF_ABS: u16 = 0x20; |
| 136 | |
| 137 | const BPF_JEQ: u16 = 0x10; |
| 138 | const BPF_JGE: u16 = 0x30; |
| 139 | const BPF_JA: u16 = 0x00; |
| 140 | |
| 141 | const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; |
| 142 | const SECCOMP_RET_ALLOW: u32 = 0x7FFF_0000; |
| 143 | |
| 144 | // Audit arch for x86_64 |
| 145 | const AUDIT_ARCH_X86_64: u32 = 0xC000_003E; |
| 146 | |
| 147 | // Helper to build a BPF instruction compactly. |
| 148 | // Pattern from openai/codex codex-rs/codex-sandbox/src/linux/seccomp.rs; reimplemented. |
| 149 | |
| 150 | // Whitelist of safe syscall numbers (x86_64). |
| 151 | // These are the syscalls most commonly used by shell commands, compilers, |
| 152 | // and developer tools. Any syscall NOT on this list causes immediate SIGSYS. |
| 153 | let allowed_syscalls: &[u32] = &[ |
| 154 | 0, // read |
| 155 | 1, // write |
| 156 | 2, // open |
| 157 | 3, // close |
| 158 | 4, // stat |
| 159 | 5, // fstat |
| 160 | 6, // lstat |
| 161 | 7, // poll |
| 162 | 8, // lseek |
| 163 | 9, // mmap |
| 164 | 10, // mprotect |
| 165 | 11, // munmap |
| 166 | 12, // brk |
| 167 | 13, // rt_sigaction |
| 168 | 14, // rt_sigprocmask |
| 169 | 15, // rt_sigreturn |
| 170 | 16, // ioctl |
| 171 | 17, // pread64 |
| 172 | 18, // pwrite64 |
| 173 | 19, // readv |
| 174 | 20, // writev |
| 175 | 21, // access |
| 176 | 22, // pipe |
| 177 | 23, // select |
| 178 | 24, // sched_yield |
| 179 | 25, // mremap |
| 180 | 27, // mincore |
| 181 | 28, // madvise |
| 182 | 29, // shmget |
| 183 | 30, // shmat |
| 184 | 32, // dup |
| 185 | 33, // dup2 |
| 186 | 35, // nanosleep |
| 187 | 39, // getpid |
| 188 | 41, // socket |
| 189 | 42, // connect |
| 190 | 43, // accept |
| 191 | 44, // sendto |
| 192 | 45, // recvfrom |
| 193 | 46, // sendmsg |
| 194 | 47, // recvmsg |
| 195 | 48, // shutdown |
| 196 | 49, // bind |
| 197 | 50, // listen |
| 198 | 51, // getsockname |
| 199 | 52, // getpeername |
| 200 | 53, // socketpair |
| 201 | 54, // setsockopt |
| 202 | 55, // getsockopt |
| 203 | 56, // clone |
| 204 | 57, // fork |
| 205 | 58, // vfork |
| 206 | 59, // execve |
| 207 | 60, // exit |
| 208 | 61, // wait4 |
| 209 | 62, // kill |
| 210 | 63, // uname |
| 211 | 72, // fcntl |
| 212 | 73, // flock |
| 213 | 74, // fsync |
| 214 | 75, // fdatasync |
| 215 | 76, // truncate |
| 216 | 77, // ftruncate |
| 217 | 78, // getdents |
| 218 | 79, // getcwd |
| 219 | 80, // chdir |
| 220 | 81, // fchdir |
| 221 | 82, // rename |
| 222 | 83, // mkdir |
| 223 | 84, // rmdir |
| 224 | 85, // creat |
| 225 | 86, // link |
| 226 | 87, // unlink |
| 227 | 88, // symlink |
| 228 | 89, // readlink |
| 229 | 90, // chmod |
| 230 | 91, // fchmod |
| 231 | 92, // chown |
| 232 | 93, // fchown |
| 233 | 94, // lchown |
| 234 | 95, // umask |
| 235 | 96, // gettimeofday |
| 236 | 97, // getrlimit |
| 237 | 98, // getrusage |
| 238 | 99, // sysinfo |
| 239 | 100, // times |
| 240 | 102, // getuid |
| 241 | 104, // getgid |
| 242 | 107, // geteuid |
| 243 | 108, // getegid |
| 244 | 110, // getppid |
| 245 | 111, // getpgrp |
| 246 | 112, // setsid |
| 247 | 116, // syslog |
| 248 | 131, // sigaltstack |
| 249 | 137, // statfs |
| 250 | 138, // fstatfs |
| 251 | 157, // prctl |
| 252 | 158, // arch_prctl |
| 253 | 186, // gettid |
| 254 | 201, // time |
| 255 | 202, // futex |
| 256 | 204, // sched_getaffinity |
| 257 | 217, // getdents64 |
| 258 | 218, // set_tid_address |
| 259 | 228, // clock_gettime |
| 260 | 230, // clock_nanosleep |
| 261 | 231, // exit_group |
| 262 | 232, // epoll_wait |
| 263 | 233, // epoll_ctl |
| 264 | 234, // tgkill |
| 265 | 235, // utimes |
| 266 | 257, // openat |
| 267 | 262, // newfstatat |
| 268 | 273, // set_robust_list |
| 269 | 281, // epoll_pwait |
| 270 | 291, // epoll_create1 |
| 271 | 292, // dup3 |
| 272 | 293, // pipe2 |
| 273 | 302, // prlimit64 |
| 274 | 318, // getrandom |
| 275 | 332, // statx |
| 276 | 334, // rseq |
| 277 | 435, // clone3 |
| 278 | ]; |
| 279 | |
| 280 | // Build the BPF program. |
| 281 | let mut filter = vec![ |
| 282 | // Instruction 0: load architecture from seccomp_data.arch |
| 283 | sock_filter { |
| 284 | code: BPF_LD | BPF_W | BPF_ABS, |
| 285 | jt: 0, |
| 286 | jf: 0, |
| 287 | k: 4, // offset of arch in seccomp_data |
| 288 | }, |
| 289 | // Instruction 1: compare with AUDIT_ARCH_X86_64 |
| 290 | // If match, jump to next instruction; if not, kill process |
| 291 | sock_filter { |
| 292 | code: BPF_JMP | BPF_JEQ, |
| 293 | jt: 0, |
| 294 | jf: 1, // jump 1 forward (to KILL) if arch doesn't match |
| 295 | k: AUDIT_ARCH_X86_64, |
| 296 | }, |
| 297 | // Instruction 2: KILL (wrong architecture) |
| 298 | sock_filter { |
| 299 | code: BPF_RET, |
| 300 | jt: 0, |
| 301 | jf: 0, |
| 302 | k: SECCOMP_RET_KILL_PROCESS, |
| 303 | }, |
| 304 | // Instruction 3: load syscall number from seccomp_data.nr |
| 305 | sock_filter { |
| 306 | code: BPF_LD | BPF_W | BPF_ABS, |
| 307 | jt: 0, |
| 308 | jf: 0, |
| 309 | k: 0, // offset of nr in seccomp_data |
| 310 | }, |
| 311 | ]; |
| 312 | |
| 313 | // For each allowed syscall, add a compare+jump to ALLOW. |
| 314 | // We use a linear scan for simplicity: each JEQ instruction jumps |
| 315 | // forward over the remaining checks + KILL to reach ALLOW. |
| 316 | for &syscall in allowed_syscalls { |
| 317 | let remaining = (allowed_syscalls.len() as u8).saturating_sub( |
| 318 | allowed_syscalls |
| 319 | .iter() |
| 320 | .position(|&s| s == syscall) |
| 321 | .unwrap_or(0) as u8, |
| 322 | ); |
| 323 | // If syscall == this one, jump to allow_target; otherwise fall through |
| 324 | filter.push(sock_filter { |
| 325 | code: BPF_JMP | BPF_JEQ, |
| 326 | jt: remaining, // jump forward to ALLOW |
| 327 | jf: 0, // fall through to next check |
| 328 | k: syscall, |
| 329 | }); |
| 330 | } |
| 331 | |
| 332 | // Instruction N: KILL PROCESS for any unmatched syscall |
| 333 | filter.push(sock_filter { |
| 334 | code: BPF_RET, |
| 335 | jt: 0, |
| 336 | jf: 0, |
| 337 | k: SECCOMP_RET_KILL_PROCESS, |
| 338 | }); |
| 339 | |
| 340 | // Instruction N+1: ALLOW |
| 341 | filter.push(sock_filter { |
| 342 | code: BPF_RET, |
| 343 | jt: 0, |
| 344 | jf: 0, |
| 345 | k: SECCOMP_RET_ALLOW, |
| 346 | }); |
| 347 | |
| 348 | // ── Load the filter into the kernel ─────────────────────────────────── |
| 349 | |
| 350 | #[repr(C)] |
| 351 | struct sock_fprog { |
| 352 | len: u16, |
| 353 | filter: *const sock_filter, |
| 354 | } |
| 355 | |
| 356 | let prog = sock_fprog { |
| 357 | len: filter.len() as u16, |
| 358 | filter: filter.as_ptr(), |
| 359 | }; |
| 360 | |
| 361 | // Safety: prctl with PR_SET_SECCOMP installs a seccomp BPF filter. |
| 362 | // The filter is a valid array of sock_filter instructions that lives |
| 363 | // for the duration of the prctl call. |
| 364 | let result = unsafe { |
| 365 | libc::prctl( |
| 366 | libc::PR_SET_SECCOMP, |
| 367 | libc::SECCOMP_MODE_FILTER, |
| 368 | &raw const prog, |
| 369 | 0i64, |
| 370 | 0i64, |
| 371 | ) |
| 372 | }; |
| 373 | |
| 374 | if result != 0 { |
| 375 | return Err(std::io::Error::last_os_error()); |
| 376 | } |
| 377 | |
| 378 | Ok(()) |
| 379 | } |
| 380 | |
| 381 | #[cfg(test)] |
| 382 | mod tests { |
| 383 | use super::*; |
| 384 | |
| 385 | #[test] |
| 386 | fn test_is_available_does_not_panic() { |
| 387 | let _ = is_available(); |
| 388 | } |
| 389 | |
| 390 | #[test] |
| 391 | #[cfg(target_os = "linux")] |
| 392 | fn test_detect_denial() { |
| 393 | assert!(detect_denial(31, "")); |
| 394 | assert!(detect_denial(1, "Bad system call")); |
| 395 | assert!(detect_denial(1, "SIGSYS")); |
| 396 | assert!(!detect_denial(0, "Success")); |
| 397 | assert!(!detect_denial(1, "File not found")); |
| 398 | } |
| 399 | |
| 400 | #[test] |
| 401 | fn test_detect_denial_non_linux() { |
| 402 | #[cfg(not(target_os = "linux"))] |
| 403 | { |
| 404 | assert!(!detect_denial(31, "Bad system call")); |
| 405 | } |
| 406 | } |
| 407 | } |
| 408 |