Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick 906a146503 Sandbox: test the socket owner ioctls with a real pointer
The merged test asserts FIOSETOWN and FIOASYNC with the ioctl argument passed
by value, where the kernel reads the number as an address and fails with
EFAULT whether or not the filter refuses it, so the refusal alone is not the
reason the call came back. Passing a pointer, as a C caller does, the call
succeeds unconfined and is EPERM confined.

This covers FIOSETOWN and SIOCSPGRP that way on a socket the parent made and
the program inherited as a standard stream, after reading its socket name so a
pipe cannot pass for one. SIOCSPGRP is in the rules and in the filter's count
assertion but in no test until now.

Deleting either rule line fails this test at that ioctl's assertion; restored,
it passes. The test above is untouched.
2026-11-15 04:26:05 +01:00
+54
View File
@@ -292,6 +292,60 @@ print("socket quiet")
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
}
/// The same inherited socket, by both ioctls that name the process to notify, `FIOSETOWN` (0x8901)
/// and `SIOCSPGRP` (0x8902), with the argument a real pointer. Named by value the kernel reads the
/// number as an address and fails with `EFAULT` whether or not the filter refuses it, so that form
/// shows only that a rule matched a request number; by pointer each call succeeds unconfined and is
/// `EPERM` confined, which is the transition the page claims. Kept apart from the test above so a
/// rule dropped for one request number fails for that request, not behind another. Linux only, as
/// those rules are: macOS's profile has none, and its standard streams here are pipes.
#[cfg(target_os = "linux")]
#[test]
fn a_confined_program_cant_point_an_inherited_socket_at_another_process_by_pointer() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import fcntl, socket, struct, 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
# Read first, so a standard stream that is not a socket can't pass for one: a pipe has no socket name.
# Detached, not dropped: a socket object closes the descriptor it wrapped when it is collected, and then
# the refusals below would be read off an invalid descriptor.
wrapped = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=0)
name = wrapped.getsockname()
wrapped.detach()
assert name == ""
# The process group is a pointer, as a C caller passes it; naming it by value has the kernel read the
# number as an address and fail with EFAULT, which would leave the refusal unexplained by the filter.
group = bytearray(struct.pack("i", other))
assert refused(fcntl.ioctl, 0, 0x8901, group), "giving a socket's notifications to another process"
assert refused(fcntl.ioctl, 0, 0x8902, group), "giving a socket's notifications to another process's group"
print("socket owner 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 owner 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`.