Skip to content

Commit 0fb2bc4

Browse files
authored
Merge pull request #154 from dzerik/fix/net-sendto-toctou
fix(net): close a sendto non-IP destination-policy TOCTOU (sendto half of #130)
2 parents 44b1c65 + 4292141 commit 0fb2bc4

7 files changed

Lines changed: 775 additions & 36 deletions

File tree

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use std::os::unix::io::{AsRawFd, OwnedFd, RawFd};
1111
use crate::seccomp::notif::read_child_mem;
1212
use crate::sys::structs::{SeccompNotif, AF_INET, AF_INET6};
1313

14+
use super::verdict::DestShape;
15+
1416
/// Maximum buffer size for sendto/sendmsg on-behalf operations (64 MiB).
1517
/// Prevents a sandboxed process from triggering OOM in the supervisor.
1618
pub(crate) const MAX_SEND_BUF: usize = 64 << 20;
@@ -220,6 +222,30 @@ pub(crate) fn named_unix_socket_path(addr_bytes: &[u8]) -> Option<std::path::Pat
220222
std::str::from_utf8(raw).ok().map(std::path::PathBuf::from)
221223
}
222224

225+
/// Classify a raw destination `sockaddr` into the [`DestShape`] the non-IP send
226+
/// arms key off. Pure function of the already-copied bytes: no child state is
227+
/// re-read, so the shape cannot change after the decision.
228+
///
229+
/// The distinction that matters is the destination's ADDRESS FAMILY, not just
230+
/// "did a pathname come out of it": an `AF_NETLINK` sockaddr and an abstract
231+
/// `AF_UNIX` sockaddr both yield no pathname, but they must take opposite
232+
/// paths — see [`DestShape`].
233+
pub(crate) fn classify_dest_shape(addr_bytes: &[u8]) -> DestShape {
234+
// Below two bytes there is no family to read; fail closed by reporting the
235+
// shape the caller refuses.
236+
if addr_bytes.len() < 2 {
237+
return DestShape::UnixNoPath;
238+
}
239+
let family = u16::from_ne_bytes([addr_bytes[0], addr_bytes[1]]);
240+
if family != libc::AF_UNIX as u16 {
241+
return DestShape::NotUnix;
242+
}
243+
match named_unix_socket_path(addr_bytes) {
244+
Some(path) => DestShape::UnixNamed(path),
245+
None => DestShape::UnixNoPath,
246+
}
247+
}
248+
223249
/// `struct msghdr` size on LP64 Linux (x86_64 / aarch64 / riscv64): four
224250
/// 8-byte pointer/length fields, one (u32 + pad) `msg_namelen`, and the
225251
/// trailing (i32 + pad) `msg_flags` = 56 bytes.
@@ -506,4 +532,57 @@ mod tests {
506532
let v4_family = (AF_INET as u16).to_ne_bytes();
507533
assert!(!sockaddr_is_ipv6(&[v4_family[0], v4_family[1], 0, 0]));
508534
}
535+
536+
/// Build a raw `sockaddr_un` image for `sun_path` (a leading NUL byte in
537+
/// `sun_path` makes it an abstract address, exactly as the kernel reads it).
538+
fn unix_sockaddr_bytes(sun_path: &[u8]) -> Vec<u8> {
539+
let mut out = (libc::AF_UNIX as u16).to_ne_bytes().to_vec();
540+
out.extend_from_slice(sun_path);
541+
out
542+
}
543+
544+
#[test]
545+
fn dest_shape_named_unix_carries_the_path() {
546+
assert_eq!(
547+
classify_dest_shape(&unix_sockaddr_bytes(b"/run/svc.dgram\0")),
548+
DestShape::UnixNamed(std::path::PathBuf::from("/run/svc.dgram"))
549+
);
550+
}
551+
552+
#[test]
553+
fn dest_shape_pathless_unix_addresses_are_unix_no_path() {
554+
// Abstract (leading NUL), unnamed (no path at all), and non-UTF-8
555+
// sun_path all land in the arm that fails closed: there is nothing the
556+
// supervisor can re-resolve in the child's root view.
557+
assert_eq!(
558+
classify_dest_shape(&unix_sockaddr_bytes(b"\0abstract-name")),
559+
DestShape::UnixNoPath
560+
);
561+
assert_eq!(
562+
classify_dest_shape(&unix_sockaddr_bytes(b"")),
563+
DestShape::UnixNoPath
564+
);
565+
assert_eq!(
566+
classify_dest_shape(&unix_sockaddr_bytes(b"/run/\xff\xfe\0")),
567+
DestShape::UnixNoPath
568+
);
569+
// Too short to even carry a family: fail closed, do not guess.
570+
assert_eq!(classify_dest_shape(&[1]), DestShape::UnixNoPath);
571+
}
572+
573+
#[test]
574+
fn dest_shape_netlink_is_not_unix() {
575+
// sandlock's NETLINK_ROUTE virtualization hands the child a
576+
// socketpair(AF_UNIX, ...) end that glibc addresses with a
577+
// `sockaddr_nl`. That address has no pathname either, but it is NOT
578+
// the abstract-unix case: classifying on the family keeps them apart.
579+
let mut nl = (libc::AF_NETLINK as u16).to_ne_bytes().to_vec();
580+
nl.extend_from_slice(&[0u8; 10]); // pad, nl_pid, nl_groups
581+
assert_eq!(classify_dest_shape(&nl), DestShape::NotUnix);
582+
583+
// Same for any other non-unix, non-IP family.
584+
let mut vsock = (libc::AF_VSOCK as u16).to_ne_bytes().to_vec();
585+
vsock.extend_from_slice(&[0u8; 14]);
586+
assert_eq!(classify_dest_shape(&vsock), DestShape::NotUnix);
587+
}
509588
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,21 @@ fn socket_is_unix(fd: RawFd) -> bool {
149149
/// at all (they return ReturnValue/Errno after performing the syscall in
150150
/// the supervisor). The Continue cases in this module are:
151151
/// 1. Non-IP families (AF_UNIX etc.) — the IP allowlist doesn't apply;
152-
/// Landlock IPC scoping is the enforcement boundary.
152+
/// Landlock IPC scoping is the enforcement boundary. One sub-case is
153+
/// narrower: `sendto`'s non-IP fall-through does NOT Continue once a
154+
/// destination policy is active. That fall-through has no chroot check —
155+
/// it is whatever misses the named-path gate, i.e. a pathless AF_UNIX or a
156+
/// non-AF_UNIX destination — so it fails closed under `--chroot` too.
157+
/// There the send goes on-behalf on a
158+
/// pinned fd — a named unix target resolved in the child's root view, or a
159+
/// non-unix destination on a unix socket (the NETLINK_ROUTE
160+
/// virtualization) — or fails closed with EAFNOSUPPORT for an AF_UNIX
161+
/// address with no pathname to pin, notably an abstract one: the
162+
/// supervisor has no Landlock domain, so an on-behalf abstract send would
163+
/// escape the child's scope. The other non-IP arms still Continue under a
164+
/// destination policy — `connect`'s abstract/unnamed/non-AF_UNIX arm and
165+
/// both chroot named-unix arms (`connect`, `sendto`/`sendmsg`), which is
166+
/// the residue tracked by issues #27 / #143.
153167
/// 2. Connected sockets with addr_ptr == 0 — the address was already
154168
/// validated at connect time, so the kernel re-read of (nothing) is
155169
/// moot.

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

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,18 @@ use crate::seccomp::notif::{read_child_mem, NotifAction};
1212
use crate::sys::structs::{SeccompNotif, ECONNREFUSED};
1313

1414
use super::materialize::{
15-
materialize_msg, mmsg_entry_ptr, mmsg_msglen_addr, named_unix_socket_path,
16-
parse_ip_from_sockaddr, parse_port_from_sockaddr, ChildMsghdr, MaterializedMsg,
17-
MAX_SEND_BUF,
15+
classify_dest_shape, materialize_msg, mmsg_entry_ptr, mmsg_msglen_addr,
16+
named_unix_socket_path, parse_ip_from_sockaddr, parse_port_from_sockaddr, ChildMsghdr,
17+
MaterializedMsg, MAX_SEND_BUF,
1818
};
1919
use super::send_engine::{batch_send_step, resolve_send, wants_blocking, BatchStep};
2020
use super::unix::{
2121
mmsg_entry_named_unix_path, sendmmsg_named_unix_on_behalf, sendto_named_unix_on_behalf,
22-
unix_sendmsg_gate,
22+
sendto_pinned_unix_on_behalf, unix_sendmsg_gate,
23+
};
24+
use super::verdict::{
25+
check_ip_destination, classify_send_path, path_under_any, DestShape, SendPath,
2326
};
24-
use super::verdict::{check_ip_destination, path_under_any};
2527
use super::{query_socket_protocol, socket_is_unix, Protocol};
2628

2729
// ============================================================
@@ -127,7 +129,95 @@ pub(super) async fn sendto_on_behalf(
127129
)
128130
}
129131
}
130-
_ => NotifAction::Continue,
132+
_ => {
133+
// Non-IP destination with no fs-path gate: an abstract unix
134+
// address, a named unix address with the fs-gate off, a non-unix
135+
// family (AF_NETLINK/AF_PACKET/AF_VSOCK/...) on any socket. With
136+
// NO destination policy there is nothing to bypass, so Continue
137+
// (matching the connected fast path). Under a destination policy
138+
// do NOT Continue: the kernel would re-read `sockfd` and the
139+
// address after our decision, and a racing
140+
// `dup2(inet_sock, sockfd)` + address swap could ride the Continue
141+
// out to a denied IP. Pin the fd and gate on its STABLE socket
142+
// domain instead. NOTE: this fails closed for non-IP sends on
143+
// non-unix sockets and for AF_UNIX destinations we cannot pin in
144+
// the child's context (abstract / empty / non-UTF-8 `sun_path`)
145+
// under a destination policy — see the PR description.
146+
if !ctx.policy.has_net_destination_policy {
147+
return NotifAction::Continue;
148+
}
149+
let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
150+
Ok(fd) => fd,
151+
Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
152+
};
153+
// The address is non-IP here (parse_ip_from_sockaddr returned
154+
// None), so classify on the pinned socket's stable domain plus the
155+
// destination's ADDRESS FAMILY. Family first: an abstract AF_UNIX
156+
// address and a sockaddr_nl both carry no pathname but are not the
157+
// same case (see DestShape).
158+
let dest = classify_dest_shape(&addr_bytes);
159+
match classify_send_path(
160+
false,
161+
None,
162+
socket_is_unix(dup_fd.as_raw_fd()),
163+
&dest,
164+
) {
165+
SendPath::NamedUnixOnBehalf => match &dest {
166+
// Resolve the target in the CHILD's root view and send to
167+
// the pinned inode: the supervisor performs the send, so
168+
// passing the child's raw `sun_path` through would resolve
169+
// it against the supervisor's cwd/root. No fs-grant check
170+
// here — this arm is only reachable with
171+
// `has_unix_fs_gate == false` (the gated case is handled
172+
// above), i.e. the sandbox declares no fs grants to check
173+
// against.
174+
DestShape::UnixNamed(path) => sendto_pinned_unix_on_behalf(
175+
notif, notif_fd, dup_fd, buf_ptr, buf_len, flags, path,
176+
),
177+
// Unreachable: the classifier returns NamedUnixOnBehalf
178+
// only for DestShape::UnixNamed. Fail closed rather than
179+
// unwrap — see the panic note below.
180+
_ => NotifAction::Errno(libc::EAFNOSUPPORT),
181+
},
182+
// A non-AF_UNIX destination on a unix socket. Send on-behalf
183+
// on the pinned fd with the child's address bytes verbatim:
184+
// this is what sandlock's own NETLINK_ROUTE virtualization
185+
// produces (child fd = one end of a socketpair(AF_UNIX,
186+
// SOCK_SEQPACKET), addressed with a sockaddr_nl), and that
187+
// socket is connected, so the kernel ignores `msg_name`
188+
// outright. Nothing is widened: the fd was pinned before its
189+
// domain was read, so no dup2 can redirect the send, and for
190+
// any other family the kernel rejects the mismatch itself.
191+
SendPath::RawDestOnBehalf => {
192+
let data =
193+
match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) {
194+
Ok(b) => b,
195+
Err(_) => return NotifAction::Errno(libc::EIO),
196+
};
197+
let m = MaterializedMsg {
198+
data,
199+
control: None,
200+
addr: addr_bytes,
201+
_scm_fds: Vec::new(),
202+
_pinned: None,
203+
};
204+
let blocking = wants_blocking(dup_fd.as_raw_fd(), flags);
205+
resolve_send(dup_fd, m, flags, blocking)
206+
}
207+
// Reject (a non-IP address on a non-unix socket — the
208+
// address-family-swap shape — or an AF_UNIX address with no
209+
// pathname to pin, notably an ABSTRACT one: the supervisor
210+
// carries no Landlock domain, so sending it on-behalf would
211+
// escape the child's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET) and,
212+
// defensively, the connected/IP variants that
213+
// classify_send_path(false, None, ..) cannot return: fail
214+
// closed with an errno (parity with the sendmsg Reject arm)
215+
// rather than panic in the seccomp-notif supervisor — a panic
216+
// on this untrusted path would unwind the supervisor task and
217+
// DoS the whole sandbox.
218+
_ => NotifAction::Errno(libc::EAFNOSUPPORT),
219+
}
220+
}
131221
}
132222
}
133223
}

0 commit comments

Comments
 (0)