Skip to content

fix(net): close a sendto non-IP destination-policy TOCTOU (sendto half of #130)#154

Open
dzerik wants to merge 1 commit into
multikernel:mainfrom
dzerik:fix/net-sendto-toctou
Open

fix(net): close a sendto non-IP destination-policy TOCTOU (sendto half of #130)#154
dzerik wants to merge 1 commit into
multikernel:mainfrom
dzerik:fix/net-sendto-toctou

Conversation

@dzerik

@dzerik dzerik commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Splits the high-priority sendto half out of #130, as you suggested on that PR ("The sendto tightening is a high-priority fix... Maybe you want to separate it out as a fix which could land quickly"). The sendmsg/sendmmsg symmetry stays in draft #130; I'll rebase it onto this once this lands.

The bug

A non-IP sendto with no fs-path gate fell through to _ => NotifAction::Continue, so the kernel re-read sockfd and the destination address after the supervisor's decision. Under an active destination policy a child could race a dup2(inet_sock, sockfd) + address swap into that window and ride the Continue out to a denied IP. (Same Continue-shape as the chroot AF_UNIX gate report, #143.)

The fix

Under has_net_destination_policy the arm no longer Continues: it pins the fd via dup_fd_from_pid, classifies on the stable socket domain of the pinned fd (via the new pure classify_send_path), and either sends the unix datagram on-behalf on that pinned fd or fails closed with EAFNOSUPPORT. With no destination policy there is nothing to bypass, so Continue is preserved unchanged.

The non-UnixOnBehalf outcomes collapse to a single fail-closed errno arm rather than unreachable!(), so any future drift in the classifier fails closed instead of panicking — a panic on this seccomp-notif path would unwind the supervisor task and DoS the sandbox.

Testing

The pure classify_send_path helper + its 4 unit tests land here (sendto is its first consumer). send_path_non_ip_on_non_unix_socket_is_rejected is the deterministic fail-without-fix witness: pre-fix logic yielded Continue for that input, the helper now yields Reject/EAFNOSUPPORT.

I deliberately did not add an integration test for the fail-closed errno: sendto(AF_INET dgram fd, AF_UNIX sockaddr) returns EAFNOSUPPORT from the kernel too, so such a test is green pre- and post-fix and would be decorative. The bypass this closes is a race that isn't deterministically reproducible from a guest, so the pure-helper unit test is the honest witness.

classify_send_path is pub(crate) (no SDK/FFI surface). It is the shared classifier the sendmsg/sendmmsg rework in #130 consumes fully; landing it here with sendto as first consumer lets #130 become a pure consumer on rebase.

Behavior scope (please review)

This is fail-closed hardening that only triggers under a destination policy, but it does change some behavior — flagging explicitly:

  • A non-IP sendto on a non-unix, non-IP datagram family (AF_PACKET/AF_VSOCK/…) now returns EAFNOSUPPORT instead of Continue. (Narrow: AF_NETLINK is virtualized to a unix socket and stays on the on-behalf path; AF_PACKET needs CAP_NET_RAW.) connect_on_behalf still Continues these families, so there is a connect-vs-sendto asymmetry.
  • Abstract / fs-gate-off named unix datagrams now send on-behalf, so the receiver observes the supervisor's SO_PEERCRED/SCM_CREDENTIALS rather than the child's. Enabling an IP allowlist thus changes unix-datagram credential semantics (D-Bus/journald/etc.). This is intrinsic to closing the TOCTOU (Continue is unsafe once the fd can be swapped).
  • Until [draft] fix(net): symmetric named-AF_UNIX datagram gating (reference implementation) #130 lands, sendto and sendmsg differ on a non-IP unix datagram (sendto sends on-behalf; sendmsg still returns EAFNOSUPPORT). This preserves sendto's pre-existing behavior (it already sent, via Continue) while closing its TOCTOU; [draft] fix(net): symmetric named-AF_UNIX datagram gating (reference implementation) #130 aligns sendmsg.

Happy to add a family carve-out (fail closed only for AF_INET/AF_INET6 sockets) if you'd prefer to keep exotic non-unix datagram families working.

…olicy TOCTOU

Splits the high-priority sendto half out of multikernel#130 (per the maintainer's request
to land it as a fix that can go in quickly).

A non-IP sendto with no fs-path gate previously fell through to `_ => Continue`,
letting the kernel re-read `sockfd` and the destination after the supervisor's
decision. Under an active destination policy a child could race a
`dup2(inet_sock, sockfd)` + address swap into that window and ride the Continue
out to a denied IP.

Under `has_net_destination_policy` the arm no longer Continues: it pins the fd
via `dup_fd_from_pid`, classifies on the STABLE socket domain via the new pure
`classify_send_path` helper, and either sends the unix datagram on-behalf on the
pinned fd (UnixOnBehalf) or fails closed with EAFNOSUPPORT. With no destination
policy there is nothing to bypass, so Continue is preserved. The non-UnixOnBehalf
outcomes collapse to a single fail-closed errno arm rather than unreachable!(),
so any future drift in the classifier fails closed instead of panicking (a panic
on this seccomp-notif path would unwind the supervisor and DoS the sandbox).

The pure `classify_send_path` helper and its 4 unit tests land here (sendto is
its first consumer); `send_path_non_ip_on_non_unix_socket_is_rejected` is the
deterministic fail-without-fix witness (pre-fix logic yielded Continue). The
sendmsg/sendmmsg symmetric rework consumes the same helper and stays in draft
multikernel#130 — so until multikernel#130 lands, sendto and sendmsg differ on a non-IP unix datagram
(sendto sends on-behalf, sendmsg still returns EAFNOSUPPORT); this preserves the
pre-existing sendto behavior (it already sent via Continue) while closing its
TOCTOU, and multikernel#130 aligns sendmsg.

Behavior scope (fail-closed hardening, only under a destination policy): a non-IP
send on a non-unix/non-IP datagram family (AF_PACKET/AF_VSOCK/...) now returns
EAFNOSUPPORT instead of Continue; abstract/named-fs-gate-off unix datagrams now
send on-behalf, so the receiver observes the supervisor's SO_PEERCRED rather than
the child's. Same Continue-shape as the chroot AF_UNIX gate report (multikernel#143).

@congwang-mk congwang-mk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TOCTOU diagnosis is correct, and the fail-closed non-unix arm plus the pure classify_send_path helper are the right shape. Two problems in the UnixOnBehalf arm block this as written:

1. On-behalf abstract sends bypass Landlock abstract-socket scoping. The _ arm receives abstract addresses (named_unix_socket_path returns None for them; its comment even says "Landlock scope handles it"). LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET is enforced against the credentials of the process doing the sendto. Pre-PR, Continue ran the child's own syscall under the child's Landlock domain. Post-PR the unconfined supervisor performs the send, so enabling an IP destination policy deterministically disables abstract-socket scoping for unconnected sendto: the child can message any abstract socket on the host. That trades a racy IP-policy bypass for a deterministic Landlock-boundary bypass (same shape as #27).

Fix: fail closed for abstract destinations (EAFNOSUPPORT), matching what send_msghdr_on_behalf already does and what #130 aligns with anyway.

2. Named paths (fs gate off) resolve in the supervisor's context. The arm passes the raw child addr_bytes to resolve_send, so sun_path resolves against the supervisor's cwd/root instead of the child's. A relative sun_path silently targets the wrong path, and a chroot child's absolute path would resolve host-side. Every other named-unix on-behalf path resolves via /proc/<pid>/root (resolve_named_unix_target) for exactly this reason.

Fix: pin the inode via a /proc/<pid>/root resolution variant (grant check skipped) and send to /proc/self/fd/<pin>; rejecting relative sun_path with an errno is fine, resolving it against the wrong cwd is not.

Both fixes are user-visible behavior (abstract datagrams under a destination policy go from racy-working to EAFNOSUPPORT), so please note them in the behavior-scope section. No family carve-out needed for AF_VSOCK/AF_PACKET; fail-closed there is fine as flagged.

Also worth adding: a happy-path integration test that a named unix datagram still sends under a net_allow policy; that is the path both issues live on, and it would have caught the relative-path regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants