Sandbox: refuse fcntl's asynchronous notification on Linux

This commit is contained in:
skylar taylor-barrick
2026-11-12 04:28:13 +01:00
parent 90180f4d87
commit 5d7f5d7fa1
4 changed files with 54 additions and 2 deletions
+12 -1
View File
@@ -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))
+25
View File
@@ -268,12 +268,28 @@ 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;
/// 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.
/// - `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
@@ -311,6 +327,15 @@ fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
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)
}
+16
View File
@@ -44,6 +44,8 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec<Check> {
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)
}
+1 -1
View File
@@ -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 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.