diff --git a/crates/sandlock-core/src/network/materialize.rs b/crates/sandlock-core/src/network/materialize.rs index b28f305a..f8b815ba 100644 --- a/crates/sandlock-core/src/network/materialize.rs +++ b/crates/sandlock-core/src/network/materialize.rs @@ -11,6 +11,8 @@ use std::os::unix::io::{AsRawFd, OwnedFd, RawFd}; use crate::seccomp::notif::read_child_mem; use crate::sys::structs::{SeccompNotif, AF_INET, AF_INET6}; +use super::verdict::DestShape; + /// Maximum buffer size for sendto/sendmsg on-behalf operations (64 MiB). /// Prevents a sandboxed process from triggering OOM in the supervisor. pub(crate) const MAX_SEND_BUF: usize = 64 << 20; @@ -220,6 +222,30 @@ pub(crate) fn named_unix_socket_path(addr_bytes: &[u8]) -> Option DestShape { + // Below two bytes there is no family to read; fail closed by reporting the + // shape the caller refuses. + if addr_bytes.len() < 2 { + return DestShape::UnixNoPath; + } + let family = u16::from_ne_bytes([addr_bytes[0], addr_bytes[1]]); + if family != libc::AF_UNIX as u16 { + return DestShape::NotUnix; + } + match named_unix_socket_path(addr_bytes) { + Some(path) => DestShape::UnixNamed(path), + None => DestShape::UnixNoPath, + } +} + /// `struct msghdr` size on LP64 Linux (x86_64 / aarch64 / riscv64): four /// 8-byte pointer/length fields, one (u32 + pad) `msg_namelen`, and the /// trailing (i32 + pad) `msg_flags` = 56 bytes. @@ -506,4 +532,57 @@ mod tests { let v4_family = (AF_INET as u16).to_ne_bytes(); assert!(!sockaddr_is_ipv6(&[v4_family[0], v4_family[1], 0, 0])); } + + /// Build a raw `sockaddr_un` image for `sun_path` (a leading NUL byte in + /// `sun_path` makes it an abstract address, exactly as the kernel reads it). + fn unix_sockaddr_bytes(sun_path: &[u8]) -> Vec { + let mut out = (libc::AF_UNIX as u16).to_ne_bytes().to_vec(); + out.extend_from_slice(sun_path); + out + } + + #[test] + fn dest_shape_named_unix_carries_the_path() { + assert_eq!( + classify_dest_shape(&unix_sockaddr_bytes(b"/run/svc.dgram\0")), + DestShape::UnixNamed(std::path::PathBuf::from("/run/svc.dgram")) + ); + } + + #[test] + fn dest_shape_pathless_unix_addresses_are_unix_no_path() { + // Abstract (leading NUL), unnamed (no path at all), and non-UTF-8 + // sun_path all land in the arm that fails closed: there is nothing the + // supervisor can re-resolve in the child's root view. + assert_eq!( + classify_dest_shape(&unix_sockaddr_bytes(b"\0abstract-name")), + DestShape::UnixNoPath + ); + assert_eq!( + classify_dest_shape(&unix_sockaddr_bytes(b"")), + DestShape::UnixNoPath + ); + assert_eq!( + classify_dest_shape(&unix_sockaddr_bytes(b"/run/\xff\xfe\0")), + DestShape::UnixNoPath + ); + // Too short to even carry a family: fail closed, do not guess. + assert_eq!(classify_dest_shape(&[1]), DestShape::UnixNoPath); + } + + #[test] + fn dest_shape_netlink_is_not_unix() { + // sandlock's NETLINK_ROUTE virtualization hands the child a + // socketpair(AF_UNIX, ...) end that glibc addresses with a + // `sockaddr_nl`. That address has no pathname either, but it is NOT + // the abstract-unix case: classifying on the family keeps them apart. + let mut nl = (libc::AF_NETLINK as u16).to_ne_bytes().to_vec(); + nl.extend_from_slice(&[0u8; 10]); // pad, nl_pid, nl_groups + assert_eq!(classify_dest_shape(&nl), DestShape::NotUnix); + + // Same for any other non-unix, non-IP family. + let mut vsock = (libc::AF_VSOCK as u16).to_ne_bytes().to_vec(); + vsock.extend_from_slice(&[0u8; 14]); + assert_eq!(classify_dest_shape(&vsock), DestShape::NotUnix); + } } diff --git a/crates/sandlock-core/src/network/mod.rs b/crates/sandlock-core/src/network/mod.rs index 2904ac85..29ac5dd2 100644 --- a/crates/sandlock-core/src/network/mod.rs +++ b/crates/sandlock-core/src/network/mod.rs @@ -149,7 +149,21 @@ fn socket_is_unix(fd: RawFd) -> bool { /// at all (they return ReturnValue/Errno after performing the syscall in /// the supervisor). The Continue cases in this module are: /// 1. Non-IP families (AF_UNIX etc.) — the IP allowlist doesn't apply; -/// Landlock IPC scoping is the enforcement boundary. +/// Landlock IPC scoping is the enforcement boundary. One sub-case is +/// narrower: `sendto`'s non-IP fall-through does NOT Continue once a +/// destination policy is active. That fall-through has no chroot check — +/// it is whatever misses the named-path gate, i.e. a pathless AF_UNIX or a +/// non-AF_UNIX destination — so it fails closed under `--chroot` too. +/// There the send goes on-behalf on a +/// pinned fd — a named unix target resolved in the child's root view, or a +/// non-unix destination on a unix socket (the NETLINK_ROUTE +/// virtualization) — or fails closed with EAFNOSUPPORT for an AF_UNIX +/// address with no pathname to pin, notably an abstract one: the +/// supervisor has no Landlock domain, so an on-behalf abstract send would +/// escape the child's scope. The other non-IP arms still Continue under a +/// destination policy — `connect`'s abstract/unnamed/non-AF_UNIX arm and +/// both chroot named-unix arms (`connect`, `sendto`/`sendmsg`), which is +/// the residue tracked by issues #27 / #143. /// 2. Connected sockets with addr_ptr == 0 — the address was already /// validated at connect time, so the kernel re-read of (nothing) is /// moot. diff --git a/crates/sandlock-core/src/network/send.rs b/crates/sandlock-core/src/network/send.rs index f717a7e5..5ac22979 100644 --- a/crates/sandlock-core/src/network/send.rs +++ b/crates/sandlock-core/src/network/send.rs @@ -12,16 +12,18 @@ use crate::seccomp::notif::{read_child_mem, NotifAction}; use crate::sys::structs::{SeccompNotif, ECONNREFUSED}; use super::materialize::{ - materialize_msg, mmsg_entry_ptr, mmsg_msglen_addr, named_unix_socket_path, - parse_ip_from_sockaddr, parse_port_from_sockaddr, ChildMsghdr, MaterializedMsg, - MAX_SEND_BUF, + classify_dest_shape, materialize_msg, mmsg_entry_ptr, mmsg_msglen_addr, + named_unix_socket_path, parse_ip_from_sockaddr, parse_port_from_sockaddr, ChildMsghdr, + MaterializedMsg, MAX_SEND_BUF, }; use super::send_engine::{batch_send_step, resolve_send, wants_blocking, BatchStep}; use super::unix::{ mmsg_entry_named_unix_path, sendmmsg_named_unix_on_behalf, sendto_named_unix_on_behalf, - unix_sendmsg_gate, + sendto_pinned_unix_on_behalf, unix_sendmsg_gate, +}; +use super::verdict::{ + check_ip_destination, classify_send_path, path_under_any, DestShape, SendPath, }; -use super::verdict::{check_ip_destination, path_under_any}; use super::{query_socket_protocol, socket_is_unix, Protocol}; // ============================================================ @@ -127,7 +129,95 @@ pub(super) async fn sendto_on_behalf( ) } } - _ => NotifAction::Continue, + _ => { + // Non-IP destination with no fs-path gate: an abstract unix + // address, a named unix address with the fs-gate off, a non-unix + // family (AF_NETLINK/AF_PACKET/AF_VSOCK/...) on any socket. With + // NO destination policy there is nothing to bypass, so Continue + // (matching the connected fast path). Under a destination policy + // do NOT Continue: the kernel would re-read `sockfd` and the + // address after our decision, and a racing + // `dup2(inet_sock, sockfd)` + address swap could ride the Continue + // out to a denied IP. Pin the fd and gate on its STABLE socket + // domain instead. NOTE: this fails closed for non-IP sends on + // non-unix sockets and for AF_UNIX destinations we cannot pin in + // the child's context (abstract / empty / non-UTF-8 `sun_path`) + // under a destination policy — see the PR description. + if !ctx.policy.has_net_destination_policy { + return NotifAction::Continue; + } + let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) { + Ok(fd) => fd, + Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)), + }; + // The address is non-IP here (parse_ip_from_sockaddr returned + // None), so classify on the pinned socket's stable domain plus the + // destination's ADDRESS FAMILY. Family first: an abstract AF_UNIX + // address and a sockaddr_nl both carry no pathname but are not the + // same case (see DestShape). + let dest = classify_dest_shape(&addr_bytes); + match classify_send_path( + false, + None, + socket_is_unix(dup_fd.as_raw_fd()), + &dest, + ) { + SendPath::NamedUnixOnBehalf => match &dest { + // Resolve the target in the CHILD's root view and send to + // the pinned inode: the supervisor performs the send, so + // passing the child's raw `sun_path` through would resolve + // it against the supervisor's cwd/root. No fs-grant check + // here — this arm is only reachable with + // `has_unix_fs_gate == false` (the gated case is handled + // above), i.e. the sandbox declares no fs grants to check + // against. + DestShape::UnixNamed(path) => sendto_pinned_unix_on_behalf( + notif, notif_fd, dup_fd, buf_ptr, buf_len, flags, path, + ), + // Unreachable: the classifier returns NamedUnixOnBehalf + // only for DestShape::UnixNamed. Fail closed rather than + // unwrap — see the panic note below. + _ => NotifAction::Errno(libc::EAFNOSUPPORT), + }, + // A non-AF_UNIX destination on a unix socket. Send on-behalf + // on the pinned fd with the child's address bytes verbatim: + // this is what sandlock's own NETLINK_ROUTE virtualization + // produces (child fd = one end of a socketpair(AF_UNIX, + // SOCK_SEQPACKET), addressed with a sockaddr_nl), and that + // socket is connected, so the kernel ignores `msg_name` + // outright. Nothing is widened: the fd was pinned before its + // domain was read, so no dup2 can redirect the send, and for + // any other family the kernel rejects the mismatch itself. + SendPath::RawDestOnBehalf => { + let data = + match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) { + Ok(b) => b, + Err(_) => return NotifAction::Errno(libc::EIO), + }; + let m = MaterializedMsg { + data, + control: None, + addr: addr_bytes, + _scm_fds: Vec::new(), + _pinned: None, + }; + let blocking = wants_blocking(dup_fd.as_raw_fd(), flags); + resolve_send(dup_fd, m, flags, blocking) + } + // Reject (a non-IP address on a non-unix socket — the + // address-family-swap shape — or an AF_UNIX address with no + // pathname to pin, notably an ABSTRACT one: the supervisor + // carries no Landlock domain, so sending it on-behalf would + // escape the child's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET) and, + // defensively, the connected/IP variants that + // classify_send_path(false, None, ..) cannot return: fail + // closed with an errno (parity with the sendmsg Reject arm) + // rather than panic in the seccomp-notif supervisor — a panic + // on this untrusted path would unwind the supervisor task and + // DoS the whole sandbox. + _ => NotifAction::Errno(libc::EAFNOSUPPORT), + } + } } } } diff --git a/crates/sandlock-core/src/network/unix.rs b/crates/sandlock-core/src/network/unix.rs index 2390a010..e5909992 100644 --- a/crates/sandlock-core/src/network/unix.rs +++ b/crates/sandlock-core/src/network/unix.rs @@ -20,21 +20,46 @@ use super::materialize::{ use super::send_engine::{batch_send_step, resolve_send, wants_blocking, BatchStep}; use super::verdict::{path_under_any, real_path_under_any}; -/// Resolve a named unix socket `sun_path` to its real, symlink-followed inode -/// in the child's root view (`/proc//root`) and verify that inode is under -/// an fs-write grant. On success returns a pinned `O_PATH` fd to that exact -/// inode; on failure returns the deny/refuse `NotifAction`. Callers must -/// operate on the pinned fd via `/proc/self/fd` so the checked inode is the one -/// acted on, immune to a path swap after the check (TOCTOU- and symlink-safe). -fn resolve_named_unix_target( +/// Render the supervisor-side path that addresses `sun_path` **in the child's +/// root view**, i.e. `/proc//root` + the child's absolute `sun_path`. +/// Pure: builds a string, touches nothing. Single source of truth for the +/// resolution context, so a change to it is visible in one unit test rather +/// than only in a live sandbox. +/// +/// Returns `None` for a relative `sun_path`, which is refused instead of +/// resolved: the supervisor cannot reproduce the child's cwd, so resolving one +/// here would silently address a different socket — whichever one sits at that +/// name under the *supervisor's* cwd. Concatenating a relative input onto the +/// prefix would also produce a path that merely happens to be bogus today +/// (`/proc/42/rootsvc.dgram`); the explicit check makes the fail-closed +/// property intentional rather than incidental, and keeps it under a refactor +/// to `Path::join`, which would silently drop the prefix. +fn child_root_path(child_pid: u32, sun_path: &std::path::Path) -> Option { + if !sun_path.is_absolute() { + return None; + } + Some(format!("/proc/{}/root{}", child_pid, sun_path.display())) +} + +/// Pin the inode a named unix socket `sun_path` resolves to **in the child's +/// root view** (see [`child_root_path`]), following the child's symlinks rather +/// than the supervisor's. Returns an `O_PATH` fd to that exact inode; callers +/// address it through `/proc/self/fd` so the resolved inode is the one acted on, +/// immune to a path swap after the check (TOCTOU- and symlink-safe). +/// +/// A `sun_path` [`child_root_path`] refuses gets `ECONNREFUSED`, which matches +/// the errno an unreachable target already returns, so no caller observes a new +/// failure mode. +fn pin_child_unix_target( child_pid: u32, sun_path: &std::path::Path, - writable: &[std::path::PathBuf], ) -> Result { - // Resolve in the child's mount/root view so its symlinks (not ours) decide - // the target. `O_PATH` follows symlinks to the real socket inode and pins - // it without performing any I/O on the socket. - let proc_path = format!("/proc/{}/root{}", child_pid, sun_path.display()); + let proc_path = match child_root_path(child_pid, sun_path) { + Some(p) => p, + None => return Err(NotifAction::Errno(ECONNREFUSED)), + }; + // `O_PATH` follows symlinks to the real socket inode and pins it without + // performing any I/O on the socket. let c_proc = std::ffi::CString::new(proc_path) .map_err(|_| NotifAction::Errno(libc::EACCES))?; let pinned_raw = unsafe { libc::open(c_proc.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) }; @@ -42,7 +67,19 @@ fn resolve_named_unix_target( // Target missing or unreachable: refuse without leaking the reason. return Err(NotifAction::Errno(ECONNREFUSED)); } - let pinned = unsafe { OwnedFd::from_raw_fd(pinned_raw) }; + Ok(unsafe { OwnedFd::from_raw_fd(pinned_raw) }) +} + +/// Resolve a named unix socket `sun_path` to its real, symlink-followed inode +/// in the child's root view (see [`pin_child_unix_target`]) and verify that +/// inode is under an fs-write grant. On success returns the pinned `O_PATH` fd; +/// on failure returns the deny/refuse `NotifAction`. +fn resolve_named_unix_target( + child_pid: u32, + sun_path: &std::path::Path, + writable: &[std::path::PathBuf], +) -> Result { + let pinned = pin_child_unix_target(child_pid, sun_path)?; // Canonical path of the pinned inode in our mount namespace. let real = std::fs::read_link(format!("/proc/self/fd/{}", pinned.as_raw_fd())) @@ -73,6 +110,14 @@ fn proc_self_fd_sockaddr(fd: RawFd) -> Option<(libc::sockaddr_un, libc::socklen_ Some((sun, len)) } +/// Flatten a `sockaddr_un` into the owned byte form [`MaterializedMsg::addr`] +/// carries. Single place so every on-behalf send encodes the destination the +/// same way. +fn sockaddr_un_bytes(sun: &libc::sockaddr_un, len: libc::socklen_t) -> Vec { + unsafe { std::slice::from_raw_parts(sun as *const libc::sockaddr_un as *const u8, len as usize) } + .to_vec() +} + /// On-behalf `connect()` for a NAMED `AF_UNIX` socket in non-chroot mode: /// resolve+verify the target, then connect the child's socket to the pinned /// inode through `/proc/self/fd`. @@ -143,10 +188,7 @@ pub(super) fn sendto_named_unix_on_behalf( // datagram queue it never drains — the same DoS this change fixes elsewhere. // The first attempt is non-blocking on the loop; a blocking child's would- // block is completed off-loop. - let addr = unsafe { - std::slice::from_raw_parts(&sun as *const libc::sockaddr_un as *const u8, len as usize) - } - .to_vec(); + let addr = sockaddr_un_bytes(&sun, len); let m = MaterializedMsg { data, control: None, @@ -158,6 +200,64 @@ pub(super) fn sendto_named_unix_on_behalf( resolve_send(dup_fd, m, flags, blocking) } +/// On-behalf `sendto()` for a NAMED `AF_UNIX` datagram on a sandbox that +/// declares NO filesystem grants (`has_unix_fs_gate == false`), so there is no +/// fs-write grant list to check the target against — [`resolve_named_unix_target`] +/// would refuse every target against an empty list. The destination must still be +/// resolved in the CHILD's context: this pins the inode via `/proc//root` +/// and sends to `/proc/self/fd/`, so a relative (or otherwise +/// child-relative) `sun_path` can never be resolved against the supervisor's +/// cwd/root, and the socket that was resolved is the socket written to. +/// +/// Takes the caller's already-dup'd socket — the caller pinned it to read its +/// stable `SO_DOMAIN` — so the child's `sockfd` is sampled exactly once and +/// cannot be swapped between the domain check and the send. +/// +/// REACHABILITY: no configuration that can execute code reaches this today, so +/// it carries no integration test and is defence in depth rather than a live +/// path. `has_unix_fs_gate == false` means the sandbox declares no fs grant at +/// all, and Landlock then denies `execve` of anything (verified: a sandbox with +/// only `net_allow` fails with `EACCES` on exec), while `Sandbox::fork` — the +/// one way into an already-running process — installs a deny-only seccomp +/// filter and no notification supervisor, so no send handler runs there. The +/// arm exists because the pre-existing alternative was to hand the child's raw +/// `sun_path` to the supervisor's own `sendto`, which resolves against the +/// SUPERVISOR's cwd/root; if either gap closes (an fs opt-out, or `fork` gaining +/// notify supervision) that would become a live confused-deputy write. +pub(super) fn sendto_pinned_unix_on_behalf( + notif: &SeccompNotif, + notif_fd: RawFd, + dup_fd: OwnedFd, + buf_ptr: u64, + buf_len: usize, + flags: i32, + sun_path: &std::path::Path, +) -> NotifAction { + let pinned = match pin_child_unix_target(notif.pid, sun_path) { + Ok(fd) => fd, + Err(action) => return action, + }; + let (sun, len) = match proc_self_fd_sockaddr(pinned.as_raw_fd()) { + Some(s) => s, + None => return NotifAction::Errno(libc::ENAMETOOLONG), + }; + let data = match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) { + Ok(b) => b, + Err(_) => return NotifAction::Errno(libc::EIO), + }; + // `pinned` must stay open (and at the same fd number) for the + // `/proc/self/fd/` destination to resolve, so the message owns it. + let m = MaterializedMsg { + data, + control: None, + addr: sockaddr_un_bytes(&sun, len), + _scm_fds: Vec::new(), + _pinned: Some(pinned), + }; + let blocking = wants_blocking(dup_fd.as_raw_fd(), flags); + resolve_send(dup_fd, m, flags, blocking) +} + /// Apply the named-unix fs gate to a `sendmsg()` whose `msg_name` may address a /// unix socket. Returns `Some(action)` when the target is a named `AF_UNIX` /// socket (handled here), or `None` to fall through to the IP path (connected @@ -244,10 +344,7 @@ fn send_named_unix_msghdr( // The destination is the `/proc/self/fd/` sockaddr; `pinned` must // stay open (and at the same fd number) for that path to resolve, so the // message keeps it alive. Copy the sockaddr bytes it currently encodes. - let addr = unsafe { - std::slice::from_raw_parts(&sun as *const libc::sockaddr_un as *const u8, sun_len as usize) - } - .to_vec(); + let addr = sockaddr_un_bytes(&sun, sun_len); // Named target is always AF_UNIX, so translate SCM_RIGHTS / reject creds. let m = materialize_msg(notif, notif_fd, &hdr, addr, true, Some(pinned))?; @@ -327,3 +424,48 @@ pub(super) fn sendmmsg_named_unix_on_behalf( NotifAction::Errno(first_errno.unwrap_or(libc::EACCES)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The resolution context is the CHILD's root view, not the supervisor's. + /// Asserting on the rendered string is the only way to see that: without a + /// mount namespace the supervisor's `/` and `/proc//root` name the same + /// inodes, so dropping the prefix is invisible to any live-sandbox test but + /// is exactly the bug (a chrooted or differently-mounted child would be + /// addressed against the supervisor's root). + #[test] + fn child_root_path_resolves_in_the_childs_root_view() { + assert_eq!( + child_root_path(4242, std::path::Path::new("/run/svc.dgram")).as_deref(), + Some("/proc/4242/root/run/svc.dgram") + ); + } + + /// A relative `sun_path` must be refused outright, never resolved: the + /// supervisor's cwd is not the child's, so resolving it would address + /// whatever socket happens to sit at that name next to the supervisor. + /// Asserted on the builder rather than on `pin_child_unix_target`, whose + /// `ECONNREFUSED` is indistinguishable from "target missing" — dropping the + /// guard there still yields `ECONNREFUSED` because the concatenation + /// (`/proc//rootsvc.dgram`) fails to open anyway, so such a test would + /// pass with the guard deleted. + #[test] + fn child_root_path_refuses_relative_sun_path() { + assert_eq!( + child_root_path(4242, std::path::Path::new("svc.dgram")), + None, + "a relative sun_path must be refused, not concatenated" + ); + } + + /// End-to-end control for the two assertions above: the builder's output is + /// actually what gets opened and pinned. Uses our own pid, whose root view + /// is the test process's own root, so the pin is deterministic. + #[test] + fn pin_child_unix_target_pins_absolute_path() { + let pinned = pin_child_unix_target(std::process::id(), std::path::Path::new("/dev/null")); + assert!(pinned.is_ok(), "absolute path must pin, got {:?}", pinned.err()); + } +} diff --git a/crates/sandlock-core/src/network/verdict.rs b/crates/sandlock-core/src/network/verdict.rs index ae32b821..93c24d6a 100644 --- a/crates/sandlock-core/src/network/verdict.rs +++ b/crates/sandlock-core/src/network/verdict.rs @@ -76,6 +76,114 @@ pub(crate) fn path_under_any(path: &std::path::Path, prefixes: &[std::path::Path prefixes.iter().any(|p| norm.starts_with(p)) } +/// The shape of a non-IP destination sockaddr, keyed on its ADDRESS FAMILY +/// first. Produced by `materialize::classify_dest_shape` from the copied +/// address bytes. +/// +/// The family is the load-bearing distinction. An abstract `AF_UNIX` address +/// and an `AF_NETLINK` address both yield no pathname, but they are not the +/// same case and must not share an arm: the first has to fail closed (see +/// [`SendPath::Reject`]), the second is a legitimate message on a unix socket +/// (see [`SendPath::RawDestOnBehalf`]). +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DestShape { + /// `sa_family == AF_UNIX` with a pathname `sun_path` the supervisor can + /// re-resolve in the child's root view. + UnixNamed(std::path::PathBuf), + /// `sa_family == AF_UNIX` with no usable pathname: an ABSTRACT address + /// (`sun_path[0] == 0`), an unnamed one, a non-UTF-8 `sun_path`, or a + /// buffer too short to carry a family at all. + UnixNoPath, + /// `sa_family != AF_UNIX`. Reached here only for families + /// `parse_ip_from_sockaddr` does not handle: `AF_NETLINK`, `AF_PACKET`, + /// `AF_VSOCK`, ... + NotUnix, +} + +/// The send path selected for one message, from the already-parsed destination +/// shape and the *stable* socket domain. Pure: the caller supplies whether the +/// message is connected, the parsed IP (if any), whether the pinned socket is +/// `AF_UNIX`, and the destination's [`DestShape`], so the decision is +/// unit-testable and cannot re-read child state. +/// +/// Every non-`Reject` arm sends on-behalf on the pinned fd rather than returning +/// `Continue`: gating on the transient address family and Continuing would let a +/// racing `dup2(inet_sock, sockfd)` + `msg_name` swap ride out on the kernel's +/// re-read and reach a denied IP. Sending on the pinned fd we already checked +/// closes that window. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SendPath { + /// Connected socket: no per-message destination; send on-behalf, no check. + ConnectedOnBehalf, + /// IP destination: validate `ip` against the allowlist, then send on-behalf. + IpChecked(IpAddr), + /// Named (pathname) unix destination on a unix-domain socket: resolve the + /// target in the CHILD's root view, pin its inode, and send on-behalf to + /// `/proc/self/fd/` with no IP check (the kernel constrains a unix + /// socket to unix peers, and we never Continue). + NamedUnixOnBehalf, + /// A non-`AF_UNIX` destination address on a unix-domain socket: send + /// on-behalf on the pinned fd with the child's address bytes verbatim. + /// + /// This is the shape sandlock's own `NETLINK_ROUTE` virtualization + /// produces: the child's "netlink socket" is one end of a + /// `socketpair(AF_UNIX, SOCK_SEQPACKET)`, and glibc addresses it with a + /// `sockaddr_nl`. There is no pathname to pin and none is needed — the + /// socket is connected, so the kernel ignores `msg_name` entirely, and the + /// fd was pinned before the domain was read, so no `dup2` can redirect the + /// send. Refusing this shape instead would take every netlink query + /// (`if_nameindex`, `getaddrinfo`'s `AI_ADDRCONFIG` probe, ...) offline for + /// any sandbox that declares a destination policy. + /// + /// For any other non-unix family on a unix socket the kernel itself + /// rejects the mismatch, so passing the bytes through is not a widening. + RawDestOnBehalf, + /// Everything else, failed closed with `EAFNOSUPPORT`: + /// - a non-IP address on a non-unix socket — the address-family-swap shape; + /// - an `AF_UNIX` destination we cannot pin in the child's context: an + /// ABSTRACT address, an empty one, or a non-UTF-8 `sun_path`. + /// + /// Abstract addresses fail closed because an on-behalf send is executed by + /// the supervisor, which carries no Landlock domain, and + /// `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` is enforced against the credentials + /// of the process performing the send. Sending an abstract datagram + /// on-behalf would therefore hand the child every abstract socket on the + /// host, turning the child's scope boundary off whenever a destination + /// policy is active. Continuing instead (so the child runs the send inside + /// its own scoped domain) is not an option here — that is exactly the TOCTOU + /// this path closes — so the send is refused. + Reject, +} + +/// Classify a send destination into the [`SendPath`] the handler should take. +/// +/// `dest` is the destination's [`DestShape`] — attacker-controlled per message. +/// It is deliberately separate from `is_unix_socket` (the socket's stable +/// `SO_DOMAIN`), which cannot be swapped once the fd is pinned. Only a +/// unix-domain socket may take an on-behalf non-IP arm at all; what the +/// destination family then selects is *which* on-behalf arm. +pub(crate) fn classify_send_path( + connected: bool, + ip: Option, + is_unix_socket: bool, + dest: &DestShape, +) -> SendPath { + if connected { + return SendPath::ConnectedOnBehalf; + } + match ip { + Some(ip) => SendPath::IpChecked(ip), + // A non-IP address on a non-unix socket is the address-family-swap + // shape, whatever the address claims to be. + None if !is_unix_socket => SendPath::Reject, + None => match dest { + DestShape::UnixNamed(_) => SendPath::NamedUnixOnBehalf, + DestShape::UnixNoPath => SendPath::Reject, + DestShape::NotUnix => SendPath::RawDestOnBehalf, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -120,4 +228,96 @@ mod tests { let allowed: IpAddr = "10.0.0.1".parse().unwrap(); assert_eq!(destination_verdict(&p, allowed, None), Err(ECONNREFUSED)); } + + fn named(p: &str) -> DestShape { + DestShape::UnixNamed(std::path::PathBuf::from(p)) + } + + #[test] + fn send_path_connected_never_checks_address() { + // A connected socket carries no per-message destination; the parsed + // address and socket domain are irrelevant. + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + assert_eq!( + classify_send_path(true, Some(ip), false, &DestShape::NotUnix), + SendPath::ConnectedOnBehalf + ); + assert_eq!( + classify_send_path(true, None, true, &DestShape::UnixNoPath), + SendPath::ConnectedOnBehalf + ); + } + + #[test] + fn send_path_ip_destination_is_checked() { + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + assert_eq!( + classify_send_path(false, Some(ip), false, &DestShape::NotUnix), + SendPath::IpChecked(ip) + ); + // Socket domain does not override a parsed IP destination. + assert_eq!( + classify_send_path(false, Some(ip), true, &DestShape::NotUnix), + SendPath::IpChecked(ip) + ); + } + + #[test] + fn send_path_named_unix_destination_goes_on_behalf() { + // A NAMED (pathname) address on a unix-domain socket is the one unix + // datagram the supervisor can re-resolve in the child's root view: send + // it on-behalf on the pinned fd (never Continue), so a raced fd/addr swap + // cannot redirect it to a denied IP. + assert_eq!( + classify_send_path(false, None, true, &named("/run/svc.dgram")), + SendPath::NamedUnixOnBehalf + ); + } + + #[test] + fn send_path_abstract_unix_destination_is_rejected() { + // An abstract (or empty, or non-UTF-8) AF_UNIX address has no pathname + // to pin. It must fail closed rather than be sent on-behalf — the + // supervisor carries no Landlock domain, so an on-behalf abstract send + // would escape the child's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET. + assert_eq!( + classify_send_path(false, None, true, &DestShape::UnixNoPath), + SendPath::Reject + ); + } + + #[test] + fn send_path_non_unix_destination_on_unix_socket_goes_on_behalf() { + // A non-AF_UNIX address on a unix-domain socket is NOT the abstract + // case and must not share its arm: it is what sandlock's own + // NETLINK_ROUTE virtualization produces (child fd = one end of a + // socketpair(AF_UNIX, SOCK_SEQPACKET), addressed with a sockaddr_nl). + // Collapsing it into Reject takes every netlink query offline under a + // destination policy, so this assertion is the deterministic witness + // that the two no-pathname shapes stay apart. + assert_eq!( + classify_send_path(false, None, true, &DestShape::NotUnix), + SendPath::RawDestOnBehalf + ); + } + + #[test] + fn send_path_non_ip_on_non_unix_socket_is_rejected() { + // A non-IP address on a non-unix (IP) socket is the address-family-swap + // shape; fail closed. Pre-fix logic returned Continue for this input, so + // this assertion is the deterministic fail-without-fix witness. The + // stable socket domain decides first: no destination shape rescues it. + assert_eq!( + classify_send_path(false, None, false, &DestShape::NotUnix), + SendPath::Reject + ); + assert_eq!( + classify_send_path(false, None, false, &DestShape::UnixNoPath), + SendPath::Reject + ); + assert_eq!( + classify_send_path(false, None, false, &named("/run/svc.dgram")), + SendPath::Reject + ); + } } diff --git a/crates/sandlock-core/tests/integration/test_landlock.rs b/crates/sandlock-core/tests/integration/test_landlock.rs index 90ace163..893097df 100644 --- a/crates/sandlock-core/tests/integration/test_landlock.rs +++ b/crates/sandlock-core/tests/integration/test_landlock.rs @@ -1033,8 +1033,10 @@ async fn test_named_unix_dgram_sendmsg_denied_without_fs_write() { // to a socket UNDER a write grant must not only be permitted but actually // deliver the payload (the supervisor performs the send on-behalf). Exercises // the success path of sendto_named_unix_on_behalf / sendmsg_named_unix_on_behalf -// that the deny tests never reach. `which` selects the syscall. -async fn dgram_allow_delivers(which: &str, tag: &str) { +// that the deny tests never reach. `which` selects the syscall; `net_allow` adds +// an IP destination rule so the same delivery can be asserted with a destination +// policy active. +async fn dgram_allow_delivers(which: &str, tag: &str, net_allow: Option<&str>) { if sandlock_core::landlock_abi_version().unwrap_or(0) < 6 { eprintln!("Skipping: Landlock ABI v6 required"); return; @@ -1106,7 +1108,7 @@ async fn dgram_allow_delivers(which: &str, tag: &str) { out = out.display(), ); - let policy = Sandbox::builder() + let builder = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -1116,9 +1118,16 @@ async fn dgram_allow_delivers(which: &str, tag: &str) { .fs_read("/dev") .fs_write("/tmp") // socket dir is WRITE granted -> send permitted, on-behalf - .fs_write(sock_dir.to_str().unwrap()) - .build() - .unwrap(); + .fs_write(sock_dir.to_str().unwrap()); + // A non-empty net_allow turns on the destination policy, which is what makes + // the sendto/sendmsg handlers take their on-behalf paths for every send — + // including this unix datagram, whose destination the IP allowlist cannot + // describe. + let builder = match net_allow { + Some(rule) => builder.net_allow(rule), + None => builder, + }; + let policy = builder.build().unwrap(); policy .clone() @@ -1156,12 +1165,38 @@ async fn dgram_allow_delivers(which: &str, tag: &str) { #[tokio::test] async fn test_named_unix_dgram_sendto_allowed_delivers() { - dgram_allow_delivers("sendto", "to").await; + dgram_allow_delivers("sendto", "to", None).await; } #[tokio::test] async fn test_named_unix_dgram_sendmsg_allowed_delivers() { - dgram_allow_delivers("sendmsg", "msg").await; + dgram_allow_delivers("sendmsg", "msg", None).await; +} + +/// Same delivery guarantee with an IP destination policy active. +/// +/// Scope, stated precisely: this sandbox declares fs grants, so +/// `has_unix_fs_gate` is true and the send takes the PRE-EXISTING named-unix +/// gate arm (`sendto_named_unix_on_behalf`), not the non-IP fall-through this +/// PR changes. It therefore guards one thing only — that a named unix datagram +/// is still DELIVERED while a destination policy is active. It does NOT pin +/// down which arm delivers it: mutating the gate arm so the send falls through +/// to `sendto_pinned_unix_on_behalf` (which pins and delivers too) leaves this +/// test, all seven `named_unix_dgram` tests and the whole suite green. +/// +/// It is likewise NOT a witness for either hardening change: mutating the +/// abstract-address refusal, the fall-through's fd pinning, or the +/// `/proc//root` resolution context all leave it green. The first is +/// covered by `test_abstract_unix_dgram_sendto_refused_under_destination_policy` +/// and the third by the `child_root_path` unit tests. +/// `if_nameindex_works_under_destination_policy` covers the fall-through only +/// for the `RawDestOnBehalf` arm; the named-unix fall-through +/// (`sendto_pinned_unix_on_behalf`) has NO test by design — it is unreachable +/// in any configuration that can execute code, see the REACHABILITY note on +/// that function. +#[tokio::test] +async fn test_named_unix_dgram_sendto_delivers_under_destination_policy() { + dgram_allow_delivers("sendto", "to-netpolicy", Some("127.0.0.1:9")).await; } // `sendmmsg()` (batched datagram send) is the last named-unix vector and Python @@ -1614,3 +1649,135 @@ async fn test_deny_openat2_does_not_bypass() { let _ = std::fs::remove_dir_all(&dir); } + +/// The abstract-unix half of the sendto hardening, end to end. +/// +/// Under a destination policy `sendto` no longer Continues on a non-IP +/// address; it performs the send itself. For an ABSTRACT `AF_UNIX` destination +/// that is a scope escape rather than a hardening: `LANDLOCK_SCOPE_ABSTRACT_-` +/// `UNIX_SOCKET` is enforced against the credentials of the process performing +/// the send, and the supervisor carries no Landlock domain — so an on-behalf +/// abstract send reaches every abstract socket on the host, deterministically +/// turning the child's scope off for as long as a destination policy is active. +/// The handler must refuse it (`EAFNOSUPPORT`) instead. +/// +/// The listener binds an abstract name OUTSIDE the sandbox, so it is exactly +/// the host socket the child's scope is supposed to hide. Two assertions, both +/// of which flip if the refusal is replaced by an on-behalf send: the child +/// must see `EAFNOSUPPORT`, and the host listener must time out having received +/// nothing. +#[tokio::test] +async fn test_abstract_unix_dgram_sendto_refused_under_destination_policy() { + if sandlock_core::landlock_abi_version().unwrap_or(0) < 6 { + eprintln!("Skipping: Landlock ABI v6 required"); + return; + } + + let abstract_name = format!("sandlock-abs-escape-{}", std::process::id()); + let out = temp_file("abstract-dgram-child"); + let ready_file = temp_file("abstract-dgram-ready"); + let recv_file = temp_file("abstract-dgram-recv"); + for f in [&out, &ready_file, &recv_file] { + let _ = std::fs::remove_file(f); + } + + // Host-side listener on an ABSTRACT address, deliberately unsandboxed. + let listener_script = format!( + concat!( + "import socket\n", + "s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)\n", + "s.bind('\\0{name}')\n", + "s.settimeout(6)\n", + "open('{ready}', 'w').write('ready')\n", + "try:\n", + " data, _ = s.recvfrom(64)\n", + " open('{recv}', 'w').write('LEAKED:' + data.decode())\n", + "except socket.timeout:\n", + " open('{recv}', 'w').write('NOTHING')\n", + "s.close()\n", + ), + name = abstract_name, + ready = ready_file.display(), + recv = recv_file.display(), + ); + let mut listener_proc = std::process::Command::new("python3") + .args(["-c", &listener_script]) + .spawn() + .unwrap(); + for _ in 0..100 { + if ready_file.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(ready_file.exists(), "abstract listener should signal readiness"); + + let child_script = format!( + concat!( + "import socket\n", + "s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)\n", + "try:\n", + " s.sendto(b'abstract-escape', '\\0{name}')\n", + " result = 'SENT'\n", + "except OSError as e:\n", + " result = 'ERRNO:%d' % e.errno\n", + "finally:\n", + " s.close()\n", + "open('{out}', 'w').write(result)\n", + ), + name = abstract_name, + out = out.display(), + ); + + // A non-empty net_allow is the switch: it makes has_net_destination_policy + // true, so the send takes the supervisor's on-behalf arm instead of + // Continuing into the child's own (scoped) domain. + let policy = Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .fs_write("/tmp") + .net_allow("127.0.0.1:9") + .build() + .unwrap(); + + policy + .clone() + .with_name("test") + .run_interactive(&["python3", "-c", &child_script]) + .await + .unwrap(); + + let child_result = std::fs::read_to_string(&out).unwrap_or_default(); + + for _ in 0..160 { + if recv_file.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let _ = listener_proc.wait(); + let delivered = std::fs::read_to_string(&recv_file).unwrap_or_default(); + + let _ = listener_proc.kill(); + for f in [&out, &ready_file, &recv_file] { + let _ = std::fs::remove_file(f); + } + + assert_eq!( + delivered, "NOTHING", + "the host's abstract socket must not receive the child's datagram: \ + delivery means the supervisor sent it outside the child's \ + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET" + ); + assert_eq!( + child_result, + format!("ERRNO:{}", libc::EAFNOSUPPORT), + "an abstract unix sendto must fail closed under a destination policy, \ + not be executed by the (undomained) supervisor" + ); +} diff --git a/crates/sandlock-core/tests/integration/test_netlink_virt.rs b/crates/sandlock-core/tests/integration/test_netlink_virt.rs index 7ef90822..d2f9d510 100644 --- a/crates/sandlock-core/tests/integration/test_netlink_virt.rs +++ b/crates/sandlock-core/tests/integration/test_netlink_virt.rs @@ -410,3 +410,50 @@ async fn etc_hosts_virtualization_resists_path_bypasses() { } assert!(result.success()); } + +/// Regression guard for the netlink virtualization under a DESTINATION POLICY. +/// +/// A non-empty `net_allow` sets `has_net_destination_policy`, which stops +/// `sendto` from Continuing on a non-IP destination and routes it through the +/// supervisor's on-behalf arm instead. The virtualized "netlink socket" the +/// child holds is one end of a `socketpair(AF_UNIX, SOCK_SEQPACKET)` +/// (`netlink::handlers::handle_socket`), and glibc addresses it with a +/// `sockaddr_nl` — a NON-`AF_UNIX` destination on a unix-domain socket. That +/// shape carries no pathname, exactly like an abstract unix address; a handler +/// that keys on "did a pathname come out of it" instead of on the address +/// family collapses the two and fails the send closed with `EAFNOSUPPORT`, +/// taking every netlink query offline for any sandbox with a destination +/// policy — `if_nameindex`, `getaddrinfo`'s `AI_ADDRCONFIG` probe, and so on. +/// +/// `if_nameindex()` is the shortest observable consumer: glibc opens a +/// NETLINK_ROUTE socket, `sendto`s an `RTM_GETLINK` dump request, and reads the +/// reply. The assertion is the same one `if_nameindex_returns_only_lo` makes +/// without a policy, so the two differ only in the switch under test. +#[tokio::test] +async fn if_nameindex_works_under_destination_policy() { + let out = temp_out("if-nameindex-netpolicy"); + let _ = std::fs::remove_file(&out); + let script = format!(concat!( + "import socket\n", + "try:\n", + " result = repr(socket.if_nameindex())\n", + "except OSError as e:\n", + " result = 'ERRNO:%d' % e.errno\n", + "open('{out}', 'w').write(result)\n", + ), out = out.display()); + + // Any rule at all flips has_net_destination_policy on; the rule itself is + // irrelevant to a unix/netlink send. + let policy = base_policy().net_allow("127.0.0.1:9").build().unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) + .await.unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert!( + contents.contains("'lo'") && !contents.contains("'eth"), + "netlink must keep working under a destination policy; expected only lo, got: {}", + contents + ); + assert!(result.success()); +}