From 2f52ae5c7eeb147a539dfc74612be4e49de328ff Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 14 Nov 2026 21:12:49 -0600 Subject: [PATCH] Sandbox: refuse fcntl's asynchronous notification on Linux (#3846) * Sandbox: refuse fcntl's asynchronous notification on Linux * Sandbox: tests for fcntl's notification rules and the new probe * Sandbox: the socket ioctls that notify another process, too --- apps/gridcraft-cli/tests/it/sandbox.rs | 54 +++++++++++++++++++++++++- crates/sandbox/src/linux.rs | 45 ++++++++++++++++++++- crates/sandbox/src/probe.rs | 25 ++++++++++++ docs/sandbox.md | 4 +- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/apps/gridcraft-cli/tests/it/sandbox.rs b/apps/gridcraft-cli/tests/it/sandbox.rs index 19861f8d8..45e53c2ee 100644 --- a/apps/gridcraft-cli/tests/it/sandbox.rs +++ b/apps/gridcraft-cli/tests/it/sandbox.rs @@ -78,6 +78,8 @@ fn the_self_test_finds_every_denial() { ] { assert!(out.contains(line), "missing `{line}` in:\n{out}"); } + #[cfg(target_os = "linux")] + assert!(out.contains("denied: switch a descriptor's notifications on"), "missing the notification switch in:\n{out}"); std::fs::remove_dir_all(home).unwrap(); } @@ -181,7 +183,9 @@ fn system_python() -> Option<(String, String)> { /// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can /// still signal itself and its own threads. On Linux, where the filter names them, it also can't /// change another process's limits or priority, or type into a terminal: no call here reaches a real -/// terminal, since its standard input isn't one. +/// terminal, since its standard input isn't one. Linux also refuses the `fcntl` commands that would +/// have the kernel signal a process for it: naming the owner, choosing the signal, or switching the +/// notification on. #[test] fn a_confined_program_leaves_other_processes_alone() { if !confines() { @@ -226,6 +230,13 @@ if sys.platform == "linux": assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority" assert refused(os.sched_setaffinity, other, {0}), "another process's processors" assert refused(os.pidfd_open, other), "a handle on another process" + read, write = os.pipe() + assert refused(fcntl.fcntl, write, fcntl.F_SETOWN, other), "giving a descriptor's notifications to another process" + # 15 is F_SETOWN_EX and 10 is F_SETSIG, which Python's fcntl doesn't name; the filter refuses + # them whatever owner or signal they name, so the argument doesn't matter. + assert refused(fcntl.fcntl, write, 15), "naming a descriptor's owner the extended way" + assert refused(fcntl.fcntl, write, 10), "choosing a descriptor's notification signal" + assert refused(fcntl.fcntl, write, fcntl.F_SETFL, fcntl.fcntl(write, fcntl.F_GETFL) | os.O_ASYNC), "switching a descriptor's notifications on" resource.prlimit(0, resource.RLIMIT_NOFILE) resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE) os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0)) @@ -240,6 +251,47 @@ print("left alone") assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o)); } +/// A socket the parent made, which the program inherited as a standard stream, can't be pointed at +/// another process through the ioctls that do what `fcntl`'s notification commands do: `FIOSETOWN` +/// (0x8901) and `FIOASYNC` (0x5452), which Python's `fcntl` passes straight through. Linux only: +/// macOS's profile has no rule for them, and its standard streams here are pipes. +#[cfg(target_os = "linux")] +#[test] +fn a_confined_program_cant_point_an_inherited_socket_at_another_process() { + if !confines() { + return; + } + let Some((python, prefix)) = system_python() else { + return; + }; + let code = r#" +import fcntl, sys +other = int(sys.argv[1]) +def refused(call, *args): + try: + call(*args) + except PermissionError: + return True + except OSError as e: + print("other error", e) + return False +assert refused(fcntl.ioctl, 0, 0x8901, other), "giving a socket's notifications to another process" +assert refused(fcntl.ioctl, 0, 0x5452, 1), "switching a socket's notifications on" +print("socket quiet") +"#; + let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap(); + let socket: std::os::fd::OwnedFd = mine.into(); + let me = std::process::id().to_string(); + let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")) + .args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me]) + .stdin(std::process::Stdio::from(socket)) + .output() + .unwrap(); + let out = text(&o); + assert!(o.status.success(), "{out}"); + assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}"); +} + /// Confined, a program changes no file, whether the policy lets it read that file or not, by path or /// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags. /// Skipped when there is no `python3`. diff --git a/crates/sandbox/src/linux.rs b/crates/sandbox/src/linux.rs index 9866e034f..5d7039a42 100644 --- a/crates/sandbox/src/linux.rs +++ b/crates/sandbox/src/linux.rs @@ -268,12 +268,38 @@ const TIOCLINUX: u64 = libc::TIOCLINUX as u64; const IOC_WRITE: u64 = 0x4000_0000; /// Changes a directory, though its number says it only reads, as the kernel's headers have it. const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613; +#[allow(clippy::unnecessary_cast)] +const F_SETOWN: u64 = libc::F_SETOWN as u64; +#[allow(clippy::unnecessary_cast)] +const F_SETFL: u64 = libc::F_SETFL as u64; +#[allow(clippy::unnecessary_cast)] +const O_ASYNC: u64 = libc::O_ASYNC as u64; +/// The two `fcntl` commands of the same family that the kernel's headers name and `libc` doesn't; +/// the numbers are the same on every architecture (`asm-generic/fcntl.h`). +const F_SETOWN_EX: u64 = 15; +const F_SETSIG: u64 = 10; +/// Socket ioctls that do what those commands do: `FIOSETOWN` and `SIOCSPGRP` name the process to +/// notify, and `FIOASYNC` switches the notification on. Their requests carry no data-direction +/// bit, so the [`IOC_WRITE`] rule doesn't name them. `libc` doesn't name them; the numbers are +/// the kernel's, the same on every architecture (`asm-generic/sockios.h`). +const FIOSETOWN: u64 = 0x8901; +const SIOCSPGRP: u64 = 0x8902; +const FIOASYNC: u64 = 0x5452; /// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match. /// - `clone` without `CLONE_THREAD`: a new process. /// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer /// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one. /// - A change to the limits, priority or scheduling of any process but `me`. +/// - The `fcntl` commands that arrange asynchronous notification: naming the process to notify +/// (`F_SETOWN`, `F_SETOWN_EX`), choosing its signal (`F_SETSIG`), and `F_SETFL` when it sets +/// `O_ASYNC`, the switch without which nothing is sent. None is a signal call, and the only rule +/// that would cover them is Landlock's signal scope, which needs ABI 6, newer than a launch +/// requires; without these a confined program could have the kernel signal any process of its +/// user. `F_SETFL` itself still works, as `O_NONBLOCK` comes from it. A socket answers the same +/// arrangement through ioctls — `FIOSETOWN` and `SIOCSPGRP` to name the process, `FIOASYNC` to +/// switch the notification on — and those requests carry no data-direction bit, so they are +/// named alongside the others. /// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when /// `gridcraft-cli sandbox-check` runs a program from a shell. /// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or @@ -307,10 +333,22 @@ fn main_rules(me: u32) -> Result>> { vec![ rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?, rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, FIOSETOWN)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, SIOCSPGRP)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, FIOASYNC)?])?, rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?, rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?, ], ); + rules.insert( + libc::SYS_fcntl, + vec![ + rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN_EX)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, F_SETSIG)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, F_SETFL)?, word(2, SeccompCmpOp::MaskedEq(O_ASYNC), O_ASYNC)?])?, + ], + ); Ok(rules) } @@ -378,6 +416,9 @@ mod tests { #[test] fn the_calls_that_reach_another_process_have_rules() { let rules = main_rules(4242).unwrap(); + // `fcntl`'s notification commands: naming the owner to notify, both ways, choosing its + // signal, and the switch itself. + assert_eq!(rules.get(&libc::SYS_fcntl).map(Vec::len), Some(4), "the asynchronous-notification commands"); for nr in [ libc::SYS_kill, libc::SYS_tkill, @@ -415,7 +456,9 @@ mod tests { ] { assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright"); } - assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4)); + // The terminal rules, the socket notification ioctls (`FIOSETOWN`, `SIOCSPGRP`, + // `FIOASYNC`), the data-carrying rule and the encryption rule. + assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(7)); } #[test] diff --git a/crates/sandbox/src/probe.rs b/crates/sandbox/src/probe.rs index bf66a0abe..611ed83cf 100644 --- a/crates/sandbox/src/probe.rs +++ b/crates/sandbox/src/probe.rs @@ -44,6 +44,8 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec { Check { name: "TCP connection", outcome: denied(tcp_probe()) }, Check { name: "start a process", outcome: spawn_probe(exe) }, Check { name: "signal another process", outcome: signal_probe(target) }, + #[cfg(target_os = "linux")] + Check { name: "switch a descriptor's notifications on", outcome: async_probe() }, ] } @@ -128,6 +130,20 @@ fn write_probe(dir: &Path) -> Outcome { denied(r.map(drop)) } +/// `O_ASYNC` on a descriptor of the program's own: the switch that has the kernel signal the +/// descriptor's owner when its state changes. The owner may be a process the program didn't name, a +/// standard stream the parent made, so on Linux the filter refuses setting it, the same way it +/// refuses the commands that name another owner. Naming one can't be asked here without `fcntl`'s +/// variable-argument form, which no safe API wraps; the rules refuse those commands, and the +/// confined-program test covers them. +#[cfg(target_os = "linux")] +fn async_probe() -> Outcome { + let r = nix::unistd::pipe().map_err(std::io::Error::from).and_then(|(_, write)| { + nix::fcntl::fcntl(write, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_ASYNC)).map(drop).map_err(std::io::Error::from) + }); + denied(r) +} + fn tcp_probe() -> std::io::Result<()> { TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop) } @@ -216,6 +232,15 @@ mod tests { } } + /// Unconfined, the notification probe reports what the system allows; confined, the launch + /// check needs it `Denied`. Either way it comes out of the same three calls, so this proves + /// the probe runs and classifies rather than failing outright. + #[cfg(target_os = "linux")] + #[test] + fn the_notification_probe_classifies() { + assert!(matches!(async_probe(), Outcome::Allowed(_)), "unconfined the switch can be set"); + } + #[test] fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() { let canary = crate::Canary::create().unwrap(); diff --git a/docs/sandbox.md b/docs/sandbox.md index 78dcf1084..119d218c9 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,6 +1,6 @@ # The sandbox for helper processes -> **Last updated:** 2026-11-07 · **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`) +> **Last updated:** 2026-11-12 · **Change:** minor (no signals to another process on Linux, not even indirectly: `fcntl`'s notification commands and the socket ioctls that do the same are refused; earlier: no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`) The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's Python cells: a separate process that confines itself before the code starts. Here only @@ -24,7 +24,7 @@ other programs. The sandbox's job is that it can do none of these: - **Network:** no sockets, no connections and no listeners. The exception is a standard stream the parent made a socket (see below). - **Other processes and the system:** no debugging or reading another process, no System V shared - memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel + memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the `fcntl` commands and socket ioctls that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel modules, BPF, keyrings or performance counters. On Linux it also can't change another process's limits, priority or scheduling.