Files
Kigi-CLI/crates/codegen/kigi-sandbox/src/child_net.rs
T
雷电芽衣 cadc46f5dc 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
2026-09-13 01:19:36 -04:00

248 lines
8.1 KiB
Rust

//! Per-child seccomp network filter. No-op on non-Linux.
#[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<sock_filter>) {
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<libc::sock_filter> {
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<Vec<libc::sock_filter>> = 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(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_ptr().cast_mut(),
};
// 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 a filter the kernel copies.
if unsafe {
prctl(
PR_SET_SECCOMP,
SECCOMP_MODE_FILTER as libc::c_ulong,
&prog as *const _ as libc::c_ulong,
0,
0,
)
} != 0
{
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// # Safety
///
/// No-op on non-Linux.
#[cfg(not(target_os = "linux"))]
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());
}
}