From f8e3ea98215c8be6dec12fcd746812589437ce88 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 7 Jul 2026 14:45:38 +0300 Subject: [PATCH] fix(net): gate named-AF_UNIX datagram sends on the socket domain, not the address family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference implementation — see the PR description for reachability and testing. A named (pathname/abstract) AF_UNIX SOCK_DGRAM sendmsg/sendmmsg under a destination policy with the unix fs-gate off returned EAFNOSUPPORT, while the identical sendto Continued. `send_msghdr_on_behalf` now returns a `MsgOutcome`: for a non-connected, non-IP address it Continues when the socket is AF_UNIX (the kernel constrains an AF_UNIX socket to AF_UNIX destinations, so a racing msg_name swap to an IP address cannot bypass the IP policy — gating on the stable socket domain is TOCTOU-safe) and fails closed with EAFNOSUPPORT on an AF_INET socket. sendmsg/sendmmsg return Continue on that outcome (in a batch only at entry 0, since a unix socket carries only unix entries). sendto's `_ => Continue` is tightened the same way: it now Continues only when the socket is AF_UNIX, closing a latent TOCTOU where an AF_INET socket presenting a transient AF_UNIX address could Continue and then have the address swapped to a denied IP on the kernel's re-read. Connected AF_UNIX (the #125 path) is unchanged — it still goes on-behalf. --- crates/sandlock-core/src/network.rs | 95 ++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index b5446ea..08be92b 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -1408,7 +1408,25 @@ async fn sendto_on_behalf( ) } } - _ => NotifAction::Continue, + _ => { + // Non-IP destination with the unix fs-gate off (or an abstract + // unix address). Continue only if the socket is actually AF_UNIX: + // the kernel then constrains the send to AF_UNIX regardless of a + // racing addr swap, so an IP destination policy cannot be + // bypassed. An AF_INET socket presenting a non-IP address is the + // kernel's EAFNOSUPPORT; Continuing it (deciding on the transient + // address family rather than the stable socket domain) would let a + // swap to a denied IP ride out on the kernel's re-read. + let dup = 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)), + }; + if socket_is_unix(dup.as_raw_fd()) { + NotifAction::Continue + } else { + NotifAction::Errno(libc::EAFNOSUPPORT) + } + } } } } @@ -1478,10 +1496,11 @@ async fn sendmsg_on_behalf( let protocol = query_socket_protocol(dup_fd.as_raw_fd()); match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, protocol, msghdr_ptr).await { - Ok(m) => { + Ok(MsgOutcome::Send(m)) => { let blocking = wants_blocking(dup_fd.as_raw_fd(), flags); resolve_send(dup_fd, m, flags, blocking) } + Ok(MsgOutcome::ContinueUnixDatagram) => NotifAction::Continue, Err(errno) => NotifAction::Errno(errno), } } @@ -1756,10 +1775,26 @@ fn complete_batch_entry( /// (`ECONNREFUSED`), so an IP send whose protocol can't be resolved is refused /// rather than escaping the allowlist. /// -/// Returns a [`MaterializedMsg`] the caller sends (inline and, if it would -/// block, deferred) via [`resolve_send`] / [`send_materialized`]; or an errno. -/// ECONNREFUSED is used both for "destination blocked by policy" and for -/// "couldn't parse a port from the sockaddr"; EIO for sub-buffer read failures. +/// What the caller should do with one `msghdr` after the parse/decide phase. +enum MsgOutcome { + /// Send this materialized message on-behalf (inline, deferred if it blocks). + Send(MaterializedMsg), + /// A named (pathname/abstract) `AF_UNIX` datagram on an `AF_UNIX` socket with + /// the unix fs-gate off: the caller returns `NotifAction::Continue`. The IP + /// destination policy does not apply to a unix send, and with no fs grants + /// the target path is unrestricted, so the kernel handles it in the child's + /// namespace (an on-behalf send would resolve the path in ours). Gating on + /// the stable socket domain is TOCTOU-safe: the kernel constrains an + /// `AF_UNIX` socket to `AF_UNIX` destinations, so a racing `msg_name` swap to + /// an IP address cannot bypass the destination policy. + ContinueUnixDatagram, +} + +/// Returns a [`MsgOutcome`]: a [`MaterializedMsg`] the caller sends (inline and, +/// if it would block, deferred) via [`resolve_send`], `Continue` for a named +/// unix datagram, or an errno. ECONNREFUSED is used both for "destination +/// blocked by policy" and for "couldn't parse a port from the sockaddr"; EIO for +/// sub-buffer read failures. async fn send_msghdr_on_behalf( notif: &SeccompNotif, ctx: &Arc, @@ -1767,7 +1802,7 @@ async fn send_msghdr_on_behalf( dup_fd: &std::os::unix::io::OwnedFd, protocol: Option, msghdr_ptr: u64, -) -> Result { +) -> Result { let msghdr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msghdr_ptr, 56) { Ok(b) if b.len() >= 56 => b, _ => return Err(libc::EFAULT), @@ -1798,9 +1833,20 @@ async fn send_msghdr_on_behalf( if !connected { let ip = match parse_ip_from_sockaddr(&addr_bytes) { Some(ip) => ip, - // A non-IP, non-connected address on an IP send path (e.g. the - // sockaddr changed under us). Fail closed. - None => return Err(libc::EAFNOSUPPORT), + // A non-IP, non-connected address. On an AF_UNIX socket this is a + // named unix datagram (the fs-gate-off case; the fs-gate-on case was + // handled by `unix_sendmsg_gate` before we got here) — Continue it, + // mirroring `sendto`, since the IP policy doesn't govern unix traffic + // and the kernel constrains an AF_UNIX socket to AF_UNIX destinations. + // An AF_INET socket with a non-IP address is the kernel's + // EAFNOSUPPORT; failing closed also avoids a racing swap to a denied + // IP riding out on a Continue. + None => { + if socket_is_unix(dup_fd.as_raw_fd()) { + return Ok(MsgOutcome::ContinueUnixDatagram); + } + return Err(libc::EAFNOSUPPORT); + } }; let dest_port = parse_port_from_sockaddr(&addr_bytes); // A non-connected IP send must have a resolved protocol to key the @@ -1840,13 +1886,13 @@ async fn send_msghdr_on_behalf( socket_is_unix(dup_fd.as_raw_fd()), )?; - Ok(MaterializedMsg { + Ok(MsgOutcome::Send(MaterializedMsg { data, control: control_buf, addr: if connected { Vec::new() } else { addr_bytes }, _scm_fds: scm_fds, _pinned: None, - }) + })) } // ============================================================ @@ -1956,7 +2002,18 @@ async fn sendmmsg_on_behalf( let m = match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, protocol, entry_ptr) .await { - Ok(m) => m, + Ok(MsgOutcome::Send(m)) => m, + Ok(MsgOutcome::ContinueUnixDatagram) => { + // Named unix datagram batch on a unix socket (fs-gate off). A + // unix socket carries only unix entries, so this is reached at + // entry 0 with nothing sent yet — Continue the whole syscall. + // Defensive: if somehow reached mid-batch, report the count + // already delivered as a short send. + if sent == 0 { + return NotifAction::Continue; + } + break; + } Err(errno) => { first_errno = Some(errno); break; @@ -2036,7 +2093,17 @@ async fn sendmmsg_on_behalf( // Every entry is OnBehalf (IP, non-connected) per the prescan above, so // the resolved protocol is always required and present here. let m = match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, Some(protocol), entry_ptr).await { - Ok(m) => m, + Ok(MsgOutcome::Send(m)) => m, + Ok(MsgOutcome::ContinueUnixDatagram) => { + // This loop has a resolved IP protocol, so the socket is not + // AF_UNIX and this arm is unreachable in practice; handle it as a + // whole-call Continue at entry 0 for exhaustiveness, else a short + // send. + if sent == 0 { + return NotifAction::Continue; + } + break; + } Err(errno) => { first_errno = Some(errno); break;