From cadc46f5dc8dd3c4457bf7008682430d6120bde0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 13 Sep 2026 01:19:36 -0400 Subject: [PATCH] fix(sandbox): close io_uring and sendmmsg holes in the child network filter Port of upstream grok-build 9684fa3 (1.0.11 window) `child_net.rs`. The per-child seccomp filter matched seven socket syscalls by number and allowed everything else, so a restricted child could still reach the network through `sendmmsg` or an io_uring submission queue, and a syscall issued through another arch table (x32 on x86_64) never matched the deny list at all. - Gate on the audit arch before matching `nr` and reject x32 numbers on x86_64 (foreign-table syscalls now get EPERM instead of passing). - Add `sendmmsg`, `io_uring_setup`, `io_uring_enter`, `io_uring_register`. - Build the program once (`prebuilt_child_network_filter`) and hand the slice to the `pre_exec` installer; the old code allocated the Vec after `fork`, which is not async-signal-safe. All three callers updated. - Tests evaluate the BPF program over synthetic `seccomp_data`. Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4 --- crates/codegen/kigi-sandbox/src/child_net.rs | 284 +++++++++++++----- .../src/terminal/streaming_local_terminal.rs | 5 +- .../kigi-tools/src/computer/local/terminal.rs | 6 +- 3 files changed, 218 insertions(+), 77 deletions(-) diff --git a/crates/codegen/kigi-sandbox/src/child_net.rs b/crates/codegen/kigi-sandbox/src/child_net.rs index 864a8cc..530e9fa 100644 --- a/crates/codegen/kigi-sandbox/src/child_net.rs +++ b/crates/codegen/kigi-sandbox/src/child_net.rs @@ -1,91 +1,130 @@ //! Per-child seccomp network filter. No-op on non-Linux. -/// Install seccomp BPF filter blocking network syscalls. +#[cfg(target_os = "linux")] +mod bpf { + use libc::sock_filter; + + pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; + pub(super) const EPERM_VAL: u32 = 1; + #[cfg(target_arch = "x86_64")] + pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000; + /// `seccomp_data` field offsets. + pub(super) const OFF_NR: u32 = 0; + pub(super) const OFF_ARCH: u32 = 4; + #[cfg(target_arch = "x86_64")] + pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64 + #[cfg(target_arch = "aarch64")] + pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64 + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + pub(super) const EXPECTED_ARCH: u32 = 0; + + pub(super) fn stmt(code: u32, k: u32) -> sock_filter { + sock_filter { + code: code as u16, + jt: 0, + jf: 0, + k, + } + } + + pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter { + sock_filter { + code: code as u16, + jt, + jf, + k, + } + } + + /// Reject a foreign-arch (and, on x86_64, x32) syscall number before + /// matching `nr`, so a syscall reached through another table cannot + /// alias past the deny list. + pub(super) fn push_arch_nr_gate(f: &mut Vec) { + use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W}; + f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH)); + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR)); + #[cfg(target_arch = "x86_64")] + { + f.push(jump( + BPF_JMP | libc::BPF_JSET | BPF_K, + X32_SYSCALL_BIT, + 0, + 1, + )); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + } + } +} + +/// Syscalls that open or send on a socket, including the `io_uring` and +/// `sendmmsg` paths that bypass the classic entry points. +#[cfg(target_os = "linux")] +fn child_network_blocked_syscalls() -> [u32; 11] { + use libc::{ + SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register, + SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto, + }; + [ + SYS_connect as u32, + SYS_bind as u32, + SYS_sendto as u32, + SYS_sendmsg as u32, + SYS_sendmmsg as u32, + SYS_listen as u32, + SYS_accept as u32, + SYS_accept4 as u32, + SYS_io_uring_setup as u32, + SYS_io_uring_enter as u32, + SYS_io_uring_register as u32, + ] +} + +#[cfg(target_os = "linux")] +fn build_child_network_filter() -> Vec { + use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt}; + use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET}; + + let blocked = child_network_blocked_syscalls(); + let mut f = Vec::with_capacity(blocked.len() + 10); + bpf::push_arch_nr_gate(&mut f); + let n = blocked.len(); + for (i, &sys) in blocked.iter().enumerate() { + let remaining = n - i - 1; + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0)); + } + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + f +} + +/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe. +#[cfg(target_os = "linux")] +pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] { + static FILTER: std::sync::OnceLock> = std::sync::OnceLock::new(); + FILTER.get_or_init(build_child_network_filter) +} + +/// Install the seccomp BPF filter blocking network syscalls. /// /// # Safety /// /// Must be called in a `pre_exec` context (after `fork`, before `exec`). #[cfg(target_os = "linux")] -pub unsafe fn install_child_network_filter() -> std::io::Result<()> { - use libc::{ - BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS, - PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect, - SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog, - }; - - const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; - const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; - // libc::EPERM - const EPERM_VAL: u32 = 1; - - macro_rules! bpf_stmt { - ($code:expr, $k:expr) => { - sock_filter { - code: $code as u16, - jt: 0, - jf: 0, - k: $k as u32, - } - }; - } - - macro_rules! bpf_jump { - ($code:expr, $k:expr, $jt:expr, $jf:expr) => { - sock_filter { - code: $code as u16, - jt: $jt, - jf: $jf, - k: $k as u32, - } - }; - } - - // seccomp_data.nr offset - const NR_OFFSET: u32 = 0; - - let blocked_syscalls: &[i64] = &[ - SYS_connect, - SYS_bind, - SYS_sendto, - SYS_sendmsg, - SYS_listen, - SYS_accept, - SYS_accept4, - ]; - - let mut filter: Vec = Vec::new(); - let total_checks = blocked_syscalls.len(); - - filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET)); - - for (i, &syscall) in blocked_syscalls.iter().enumerate() { - let remaining = total_checks - i - 1; - filter.push(bpf_jump!( - BPF_JMP | BPF_JEQ | BPF_K, - syscall, - // match: jump to ERRNO - remaining as u8 + 1, - // no match: check next - 0 - )); - } - - filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); - - filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); +pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> { + use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog}; let prog = sock_fprog { len: filter.len() as u16, - filter: filter.as_mut_ptr(), + filter: filter.as_ptr().cast_mut(), }; - - // Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter // SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context. if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { return Err(std::io::Error::last_os_error()); } - - // SAFETY: prog is a valid sock_fprog pointing to our filter array. + // SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies. if unsafe { prctl( PR_SET_SECCOMP, @@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> { { return Err(std::io::Error::last_os_error()); } - Ok(()) } @@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> { pub unsafe fn install_child_network_filter() -> std::io::Result<()> { Ok(()) } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::bpf::*; + use super::{build_child_network_filter, child_network_blocked_syscalls}; + use libc::sock_filter; + + /// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields. + fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 { + use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W}; + let mut pc = 0usize; + let mut a = 0u32; + for _ in 0..filter.len().saturating_mul(2) { + let insn = &filter[pc]; + let op = insn.code as u32; + if op == (BPF_LD | BPF_W | BPF_ABS) { + a = match insn.k { + OFF_NR => nr, + OFF_ARCH => arch, + _ => 0, + }; + pc += 1; + } else if op == (BPF_JMP | BPF_JEQ | BPF_K) { + pc = if a == insn.k { + pc + 1 + insn.jt as usize + } else { + pc + 1 + insn.jf as usize + }; + } else if op == (BPF_JMP | BPF_JSET | BPF_K) { + pc = if a & insn.k != 0 { + pc + 1 + insn.jt as usize + } else { + pc + 1 + insn.jf as usize + }; + } else if op == (BPF_RET | BPF_K) { + return insn.k; + } else { + panic!("unsupported opcode {:#x} at {pc}", insn.code); + } + assert!(pc < filter.len(), "pc out of range"); + } + panic!("filter did not RET"); + } + + fn is_allow(r: u32) -> bool { + r == SECCOMP_RET_ALLOW + } + + fn is_eperm(r: u32) -> bool { + r == (SECCOMP_RET_ERRNO | EPERM_VAL) + } + + #[test] + fn blocks_every_network_syscall_and_allows_read_and_socket() { + let f = build_child_network_filter(); + for sys in child_network_blocked_syscalls() { + assert!( + is_eperm(eval(&f, EXPECTED_ARCH, sys)), + "syscall {sys} must be EPERM" + ); + } + assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32))); + assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32))); + } + + #[test] + fn covers_io_uring_and_sendmmsg() { + let blocked = child_network_blocked_syscalls(); + for sys in [ + libc::SYS_sendmmsg, + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + ] { + assert!(blocked.contains(&(sys as u32)), "{sys} missing"); + } + } + + #[test] + fn wrong_arch_and_x32_are_denied_before_the_nr_match() { + let f = build_child_network_filter(); + assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32))); + #[cfg(target_arch = "x86_64")] + assert!(is_eperm(eval( + &f, + EXPECTED_ARCH, + (libc::SYS_read as u32) | X32_SYSCALL_BIT + ))); + } + + #[test] + fn prebuilt_filter_is_shared() { + let a = super::prebuilt_child_network_filter(); + let b = super::prebuilt_child_network_filter(); + assert!(std::ptr::eq(a, b)); + assert_eq!(a.len(), build_child_network_filter().len()); + } +} diff --git a/crates/codegen/kigi-shell/src/terminal/streaming_local_terminal.rs b/crates/codegen/kigi-shell/src/terminal/streaming_local_terminal.rs index 9fda9a2..e9c03fc 100644 --- a/crates/codegen/kigi-shell/src/terminal/streaming_local_terminal.rs +++ b/crates/codegen/kigi-shell/src/terminal/streaming_local_terminal.rs @@ -912,7 +912,10 @@ fn spawn_with_argv( if kigi_sandbox::should_restrict_child_network() { // SAFETY: single prctl syscall (async-signal-safe). unsafe { - cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter()); + let filter = kigi_sandbox::child_net::prebuilt_child_network_filter(); + cmd.pre_exec(move || { + kigi_sandbox::child_net::install_child_network_filter(filter) + }); } } }); diff --git a/crates/codegen/kigi-tools/src/computer/local/terminal.rs b/crates/codegen/kigi-tools/src/computer/local/terminal.rs index c00cf74..fb2dbab 100644 --- a/crates/codegen/kigi-tools/src/computer/local/terminal.rs +++ b/crates/codegen/kigi-tools/src/computer/local/terminal.rs @@ -634,7 +634,8 @@ impl LocalTerminalActor { #[cfg(target_os = "linux")] if kigi_sandbox::should_restrict_child_network() { unsafe { - cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter()); + let filter = kigi_sandbox::child_net::prebuilt_child_network_filter(); + cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter)); } } @@ -2646,7 +2647,8 @@ fn spawn_shell_command( #[cfg(target_os = "linux")] if kigi_sandbox::should_restrict_child_network() { unsafe { - cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter()); + let filter = kigi_sandbox::child_net::prebuilt_child_network_filter(); + cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter)); } } cmd