Skip to content

Commit a4f6d97

Browse files
committed
fix(net): send named-unix datagrams on-behalf under a destination policy
A named/abstract AF_UNIX SOCK_DGRAM sendmsg/sendmmsg under a destination policy (with the unix fs-gate off) failed closed with EAFNOSUPPORT, while the identical sendto Continue'd and worked -- the two syscalls disagreed on the same destination. Rather than resolve this by Continuing sendmsg (which would decide on the transient address family and let a racing dup2(inet_sock, sockfd)+msg_name swap ride out on the kernel's re-read to a denied IP), gate on the stable socket domain and send the unix datagram ON-BEHALF on the pinned fd: - Add a pure `classify_send_path(connected, ip, is_unix_socket)` verdict. A non-IP address on a unix-domain socket -> UnixOnBehalf (send, no IP check); on a non-unix socket -> Reject (EAFNOSUPPORT, the addr-family swap shape). - send_msghdr_on_behalf (sendmsg/sendmmsg) uses it: a unix datagram is materialized and sent on the pinned dup_fd, never Continue. - sendto's non-IP branch matches: no destination policy -> Continue (nothing to bypass); policy active -> send a unix datagram on-behalf, fail closed on a non-unix socket. All three send paths now behave identically for unix datagrams and never Continue under a destination policy, closing the re-read TOCTOU window. The decision is unit-tested in verdict.rs (four SendPath arms); the handler wiring is exercised by the kernel integration suite.
1 parent c63cf0e commit a4f6d97

2 files changed

Lines changed: 139 additions & 22 deletions

File tree

crates/sandlock-core/src/network/send.rs

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use super::unix::{
2121
mmsg_entry_named_unix_path, sendmmsg_named_unix_on_behalf, sendto_named_unix_on_behalf,
2222
unix_sendmsg_gate,
2323
};
24-
use super::verdict::{check_ip_destination, path_under_any};
24+
use super::verdict::{check_ip_destination, classify_send_path, path_under_any, SendPath};
2525
use super::{query_socket_protocol, socket_is_unix, Protocol};
2626

2727
// ============================================================
@@ -127,7 +127,41 @@ pub(super) async fn sendto_on_behalf(
127127
)
128128
}
129129
}
130-
_ => NotifAction::Continue,
130+
_ => {
131+
// Non-IP destination with no fs-path gate (an abstract unix
132+
// address, or the fs-gate-off case). With no destination policy
133+
// there is nothing to bypass, so Continue (matching the connected
134+
// fast path). Under a destination policy do NOT Continue: the
135+
// kernel would re-read `sockfd`/addr and a racing
136+
// `dup2(inet_sock, sockfd)` + addr swap could redirect the send to
137+
// a denied IP. Gate on the stable socket domain and send a unix
138+
// datagram on-behalf on the pinned fd (mirroring the sendmsg
139+
// path); fail closed on a non-unix socket presenting a non-IP
140+
// address.
141+
if !ctx.policy.has_net_destination_policy {
142+
return NotifAction::Continue;
143+
}
144+
let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
145+
Ok(fd) => fd,
146+
Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
147+
};
148+
if !socket_is_unix(dup_fd.as_raw_fd()) {
149+
return NotifAction::Errno(libc::EAFNOSUPPORT);
150+
}
151+
let data = match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) {
152+
Ok(b) => b,
153+
Err(_) => return NotifAction::Errno(libc::EIO),
154+
};
155+
let m = MaterializedMsg {
156+
data,
157+
control: None,
158+
addr: addr_bytes,
159+
_scm_fds: Vec::new(),
160+
_pinned: None,
161+
};
162+
let blocking = wants_blocking(dup_fd.as_raw_fd(), flags);
163+
resolve_send(dup_fd, m, flags, blocking)
164+
}
131165
}
132166
}
133167
}
@@ -296,31 +330,35 @@ async fn send_msghdr_on_behalf(
296330
Err(e) => return Err(e),
297331
}
298332
};
299-
if !connected {
300-
let ip = match parse_ip_from_sockaddr(&addr_bytes) {
301-
Some(ip) => ip,
302-
// A non-IP, non-connected address on an IP send path (e.g. the
303-
// sockaddr changed under us). Fail closed.
304-
None => return Err(libc::EAFNOSUPPORT),
305-
};
306-
let dest_port = parse_port_from_sockaddr(&addr_bytes);
307-
// A non-connected IP send must have a resolved protocol to key the
308-
// per-protocol allowlist. If it couldn't be resolved, fail closed.
309-
let protocol = protocol.ok_or(ECONNREFUSED)?;
310-
check_ip_destination(ctx, notif.pid, protocol, ip, dest_port).await?;
333+
334+
// Classify on the *stable* socket domain of the pinned `dup_fd`, not the
335+
// transient address family: a unix datagram sends on-behalf here (never
336+
// Continue), so a racing `dup2(inet_sock, sockfd)` + `msg_name` swap cannot
337+
// redirect it to a denied IP.
338+
let is_unix = socket_is_unix(dup_fd.as_raw_fd());
339+
match classify_send_path(connected, parse_ip_from_sockaddr(&addr_bytes), is_unix) {
340+
SendPath::ConnectedOnBehalf => {}
341+
SendPath::IpChecked(ip) => {
342+
let dest_port = parse_port_from_sockaddr(&addr_bytes);
343+
// A non-connected IP send must have a resolved protocol to key the
344+
// per-protocol allowlist. If it couldn't be resolved, fail closed.
345+
let protocol = protocol.ok_or(ECONNREFUSED)?;
346+
check_ip_destination(ctx, notif.pid, protocol, ip, dest_port).await?;
347+
}
348+
// A named/abstract unix datagram (the fs-gate-off case; the fs-gate-on
349+
// case was handled by `unix_sendmsg_gate` before we got here). The IP
350+
// allowlist does not govern unix traffic, so send it on-behalf on the
351+
// pinned fd with no IP check.
352+
SendPath::UnixOnBehalf => {}
353+
// A non-IP address on a non-unix (IP) socket — the address-family-swap
354+
// shape. Fail closed.
355+
SendPath::Reject => return Err(libc::EAFNOSUPPORT),
311356
}
312357

313358
// Translate SCM_RIGHTS / reject creds only for a unix socket; an IP socket's
314359
// control carries no fds or credentials and passes through untouched.
315360
// (`addr_bytes` is already empty for a connected send.)
316-
materialize_msg(
317-
notif,
318-
notif_fd,
319-
&hdr,
320-
addr_bytes,
321-
socket_is_unix(dup_fd.as_raw_fd()),
322-
None,
323-
)
361+
materialize_msg(notif, notif_fd, &hdr, addr_bytes, is_unix, None)
324362
}
325363

326364
// ============================================================

crates/sandlock-core/src/network/verdict.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,47 @@ pub(crate) fn path_under_any(path: &std::path::Path, prefixes: &[std::path::Path
7676
prefixes.iter().any(|p| norm.starts_with(p))
7777
}
7878

79+
/// The send path selected for one message, from the already-parsed destination
80+
/// shape and the *stable* socket domain. Pure: the caller supplies whether the
81+
/// message is connected, the parsed IP (if any), and whether the pinned socket
82+
/// is `AF_UNIX`, so the decision is unit-testable and cannot re-read child state.
83+
///
84+
/// The unix-datagram arm sends on-behalf on the pinned fd rather than returning
85+
/// `Continue`: gating on the transient address family and Continuing would let a
86+
/// racing `dup2(inet_sock, sockfd)` + `msg_name` swap ride out on the kernel's
87+
/// re-read and reach a denied IP. Sending on the pinned fd we already checked
88+
/// closes that window.
89+
#[derive(Debug, PartialEq, Eq)]
90+
pub(crate) enum SendPath {
91+
/// Connected socket: no per-message destination; send on-behalf, no check.
92+
ConnectedOnBehalf,
93+
/// IP destination: validate `ip` against the allowlist, then send on-behalf.
94+
IpChecked(IpAddr),
95+
/// Non-IP address on a unix-domain socket: a unix datagram; send on-behalf on
96+
/// the pinned fd with no IP check (the kernel constrains a unix socket to
97+
/// unix peers, and we never Continue).
98+
UnixOnBehalf,
99+
/// Non-IP address on a non-unix socket: the address-family-swap shape; fail
100+
/// closed with `EAFNOSUPPORT`.
101+
Reject,
102+
}
103+
104+
/// Classify a send destination into the [`SendPath`] the handler should take.
105+
pub(crate) fn classify_send_path(
106+
connected: bool,
107+
ip: Option<IpAddr>,
108+
is_unix_socket: bool,
109+
) -> SendPath {
110+
if connected {
111+
return SendPath::ConnectedOnBehalf;
112+
}
113+
match ip {
114+
Some(ip) => SendPath::IpChecked(ip),
115+
None if is_unix_socket => SendPath::UnixOnBehalf,
116+
None => SendPath::Reject,
117+
}
118+
}
119+
79120
#[cfg(test)]
80121
mod tests {
81122
use super::*;
@@ -120,4 +161,42 @@ mod tests {
120161
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
121162
assert_eq!(destination_verdict(&p, allowed, None), Err(ECONNREFUSED));
122163
}
164+
165+
#[test]
166+
fn send_path_connected_never_checks_address() {
167+
// A connected socket carries no per-message destination; the parsed
168+
// address and socket domain are irrelevant.
169+
let ip: IpAddr = "10.0.0.1".parse().unwrap();
170+
assert_eq!(
171+
classify_send_path(true, Some(ip), false),
172+
SendPath::ConnectedOnBehalf
173+
);
174+
assert_eq!(
175+
classify_send_path(true, None, true),
176+
SendPath::ConnectedOnBehalf
177+
);
178+
}
179+
180+
#[test]
181+
fn send_path_ip_destination_is_checked() {
182+
let ip: IpAddr = "10.0.0.1".parse().unwrap();
183+
assert_eq!(classify_send_path(false, Some(ip), false), SendPath::IpChecked(ip));
184+
// Socket domain does not override a parsed IP destination.
185+
assert_eq!(classify_send_path(false, Some(ip), true), SendPath::IpChecked(ip));
186+
}
187+
188+
#[test]
189+
fn send_path_non_ip_on_unix_socket_goes_on_behalf() {
190+
// A non-IP address on a unix-domain socket is a unix datagram: send it
191+
// on-behalf on the pinned fd (never Continue), so a raced fd/addr swap
192+
// cannot redirect it to a denied IP.
193+
assert_eq!(classify_send_path(false, None, true), SendPath::UnixOnBehalf);
194+
}
195+
196+
#[test]
197+
fn send_path_non_ip_on_non_unix_socket_is_rejected() {
198+
// A non-IP address on a non-unix (IP) socket is the address-family-swap
199+
// shape; fail closed.
200+
assert_eq!(classify_send_path(false, None, false), SendPath::Reject);
201+
}
123202
}

0 commit comments

Comments
 (0)