fix(net): pass through connected AF_UNIX sendmsg under a destination policy#125
fix(net): pass through connected AF_UNIX sendmsg under a destination policy#125dzerik wants to merge 3 commits into
Conversation
| // its fd-passing capability) the moment any --net-allow/--net-deny rule is | ||
| // set, even though the send targets no network destination. Mirrors the | ||
| // sendto path, which only takes IP-family destinations on-behalf. | ||
| if socket_is_unix(dup_fd.as_raw_fd()) { |
There was a problem hiding this comment.
TOCTOU: this Continue under an active destination policy is racy and, I think, can't be made safe. On RESP_CONTINUE the kernel re-resolves arg0 (and msg_name) against the live child after this check. A malicious child can race dup2(inet_sock, sockfd) right after the SO_DOMAIN probe, so the kernel runs the send on an IP socket to a denied destination. That's exactly the "never Continue under dest_policy" invariant (lines 1314-1318 / 1609-1614) being broken; no Continue-based fix closes it, since both fd and msg_name are child-controlled at execution time.
The actual defect is narrow: query_socket_protocol is required eagerly and hard-fails ECONNREFUSED just below, but protocol is only consumed in the non-connected IP branch of send_msghdr_on_behalf. Suggested redirection, staying on the TOCTOU-safe on-behalf path:
- Make
protocol: Option<Protocol>, require it only in the!connectedbranch. - Translate
SCM_RIGHTS: parse the cmsgs,pidfd_getfdeach child fd into the supervisor, rewrite with supervisor fd numbers, close after send.
Step 2 is needed either way: on-behalf sends copy the control buffer raw, so fd-passing is already silently corrupted here and in send_named_unix_msghdr. Doing it on-behalf closes the race and fixes that gap in one place. Heads up that the repro and the new test only exercise the no-fd case, so neither approach is verified against an actual fd-passing sendmsg.
16efdfd to
a61fa68
Compare
…n policy
When a `net_allow`/`net_deny` destination policy is active,
`sendmsg_on_behalf` (and `sendmmsg_on_behalf`) skipped the connected /
non-IP-family Continue fast path and routed every send through the IP
on-behalf path. For a connected `AF_UNIX` socket that path calls
`query_socket_protocol`, which returns `None` for a unix socket (no IP
protocol), and the send was refused with `ECONNREFUSED`.
This broke every connected-socket `sendmsg` user — Wayland clients,
D-Bus, anything that uses `sendmsg` for its fd-passing capability —
whenever any `--net-allow` rule was set: `connect()` and `recvmsg()`
succeeded but the client's outbound `sendmsg()` was rejected, so the
peer never received the request. `send()`/`sendto` were unaffected
(they already Continue connected sockets), so only `sendmsg` regressed.
Continue can't fix this under a destination policy: on `RESP_CONTINUE`
the kernel re-resolves the fd and `msg_name` against the live child, so a
racing `dup2(inet_sock, sockfd)` could redirect the send onto an IP
socket to a denied destination. Stay on the TOCTOU-safe on-behalf path:
- `send_msghdr_on_behalf` takes `protocol: Option<Protocol>` and
consumes it only in the non-connected IP branch. A connected send
(every AF_UNIX send that reaches here — its connection was gated at
connect time) needs no protocol and goes out on the immune `dup_fd`;
a non-connected send with no resolvable protocol still fails closed.
- On-behalf sends copy the control buffer, so `SCM_RIGHTS` fds were
passed with the child's fd numbers — silently corrupting fd-passing.
`translate_scm_rights` now `pidfd_getfd`s each fd into the supervisor
and rewrites its number before the send, for both the IP and
named-unix on-behalf paths.
The control-buffer parser is hardened against a hostile child:
- `cmsg_len` is bounds-checked against the *remaining* buffer with no
`off + cmsg_len` overflow; a malformed header fails closed (EINVAL)
instead of panicking or scanning out of bounds.
- `SCM_CREDENTIALS` is rejected (EPERM): the on-behalf sender is the
supervisor, so forwarding the child's crafted pid/uid/gid would let a
privileged supervisor forge credentials the child can't assert.
- An oversized control buffer (> 16 KiB) fails closed (EMSGSIZE) rather
than being silently truncated into a partial cmsg chain.
IP enforcement is untouched: non-unix sockets still resolve their
protocol and validate every destination.
Tests: connected `AF_UNIX` `sendmsg` under `net_allow` passes through; a
`SCM_RIGHTS` send delivers a working fd (receiver reads the passed file's
bytes); and unit tests cover the cmsg parser's overflow/short-header/
credential/pass-through cases.
a61fa68 to
a51a58c
Compare
|
You're right on the TOCTOU —
Your heads-up that the repro/test only exercised the no-fd case was the useful nudge: I verified end-to-end that a real Hardened the parser against a hostile child while I was in there:
IP enforcement is unchanged — non-unix sockets still resolve their protocol and validate every destination (verified: a denied UDP |
congwang-mk
left a comment
There was a problem hiding this comment.
Thanks for your quick followup. Here are some additional issues:
| let (buf, fds) = translate_scm_rights(notif.pid, &raw)?; | ||
| (Some(buf), fds) | ||
| } | ||
| Err(_) => (None, Vec::new()), |
There was a problem hiding this comment.
Should this be return Err(EIO)? Otherwise it would silently send the message unexpectedly?
| let dest_port = parse_port_from_sockaddr(&addr_bytes); | ||
| // A non-connected IP send must have a resolved protocol to key the | ||
| // per-protocol allowlist. If it couldn't be resolved, fail closed. | ||
| let protocol = protocol.ok_or(ECONNREFUSED)?; |
There was a problem hiding this comment.
Should we check it against AF_UNIX so that translate_scm_rights() would be called only for Unix socket?
| read_child_mem(notif_fd, notif.id, notif.pid, msg_control_ptr, len).ok() | ||
| // Copy the control buffer and rewrite any SCM_RIGHTS fds from child to | ||
| // supervisor numbers; `_scm_fds` keeps the fetched fds open across the send. | ||
| let (control_buf, _scm_fds) = if msg_control_ptr != 0 && msg_controllen > 0 { |
There was a problem hiding this comment.
This code is duplicated with the above in send_named_unix_msghdr(), make it a helper?
|
NOT caused by this PR, but a related issue found by AI is: Please let me know if you want to fix it together here. (If not, I can fix it separately.) Thanks! |
…closed on EIO
Extracted the duplicated control-buffer handling of send_msghdr_on_behalf
and send_named_unix_msghdr into materialize_control(), which:
- fails closed with EIO when the control buffer can't be read from the
child (previously it silently sent the message without its cmsgs,
dropping the caller's SCM_RIGHTS fds), and
- translates SCM_RIGHTS / rejects SCM_CREDENTIALS only for AF_UNIX
sockets (probed via SO_DOMAIN, restored as socket_is_unix). SCM_RIGHTS
and SCM_CREDENTIALS are unix-only, so an IP socket's control (e.g.
IP_PKTINFO) now passes through untouched instead of being walked as if
it could carry fds.
Seccomp notifications are processed sequentially on one loop, and the on-behalf sends (sendmsg / sendto in send_msghdr_on_behalf, send_named_unix_msghdr, sendto_on_behalf) ran inline on it. Because pidfd_getfd shares the child's open file description, the supervisor inherited the child's blocking mode, so a send to a peer that stops draining blocked the whole loop — every trapped syscall from every sandboxed process hung until the peer read or closed. A child could trigger it with no network permission at all (fill one end of a socketpair, then sendmsg). multikernel#125 widened the reach by routing connected AF_UNIX stream sendmsg (Wayland, D-Bus) through this path. The send is now non-blocking and, when it would block, deferred: - Each on-behalf message is materialized into an owned MaterializedMsg (iovec data, translated control buffer with its SCM_RIGHTS fds, any named-unix inode pin, destination sockaddr), so it can be sent from a worker without borrowing supervisor state. - resolve_send does the first send with MSG_DONTWAIT on the loop (never blocking there). If it would block and the child's socket is blocking, it returns NotifAction::Defer(defer_send), which awaits writability via the Tokio IO driver's epoll (never blocking a worker thread) and retries — preserving the child's blocking semantics while freeing the loop, bounded by the supervisor's deferred-work timeout. A non-blocking child gets EAGAIN, exactly as the kernel would return. - Batch sends (sendmmsg) go non-blocking per entry and stop the batch on a would-block entry (a short count / EAGAIN), matching the kernel's non-blocking sendmmsg semantics without occupying the loop. Verified end-to-end: a 2 MiB blocking send to a peer that stalls 2 s then drains completes in ~2 s with every byte delivered (blocking semantics preserved); a concurrent thread's sends stay at ~1 ms latency while that send is stalled (the loop is not wedged). Adds an integration test that a 4 MiB blocking send to a stall-then-drain peer defers and delivers in full.
Seccomp notifications are processed sequentially on one loop, and the on-behalf sends (sendmsg / sendto in send_msghdr_on_behalf, send_named_unix_msghdr, sendto_on_behalf) ran inline on it. Because pidfd_getfd shares the child's open file description, the supervisor inherited the child's blocking mode, so a send to a peer that stops draining blocked the whole loop — every trapped syscall from every sandboxed process hung until the peer read or closed. A child could trigger it with no network permission at all (fill one end of a socketpair, then sendmsg). multikernel#125 widened the reach by routing connected AF_UNIX stream sendmsg (Wayland, D-Bus) through this path. The send is now non-blocking and, when it would block, deferred: - Each on-behalf message is materialized into an owned MaterializedMsg (flattened iovec payload, translated control buffer with its SCM_RIGHTS fds, any named-unix inode pin, destination sockaddr), so it can be replayed from a byte offset on a worker without borrowing supervisor state. - resolve_send does the first send with MSG_DONTWAIT on the loop (never blocking there). For a blocking child whose whole message didn't fit, defer_send finishes it off the loop: it awaits writability via the Tokio IO driver's epoll (never blocking a worker thread) and pushes the rest, advancing the offset past each partial send, until the full length is delivered — preserving the kernel's "a blocking send of N returns N" contract. Control ancillary/address are attached only at offset 0, so SCM_RIGHTS transmits exactly once. A non-blocking child gets the single-attempt result (short count or EAGAIN), as the kernel would return. - Batch sends (sendmmsg) go non-blocking per entry and stop the batch on a would-block or partial-stream entry, matching the kernel's non-blocking sendmmsg semantics without occupying the loop. Verified end-to-end: a single blocking send() of 2 MiB to a peer that stalls 2 s then drains returns the full 2 MiB in ~2 s (blocking semantics preserved, not truncated to one SO_SNDBUF); a concurrent thread's sends stay at ~1-2 ms latency while that send is stalled (the loop is not wedged). Adds an integration test that one blocking send() of 4 MiB to a stall-then-drain peer returns the full count and delivers every byte.
d97d0c6 to
4db323f
Compare
|
Thanks — addressed the inline comments and took the supervisor-loop issue on here (happy to do it together). Inline:
Supervisor loop (blocking on-behalf send): you're right, and it's a real DoS — a blocking child sending to a peer that stops draining wedged the whole sequential loop, reachable with no net permission at all, and #125 widened it to connected AF_UNIX (Wayland/D-Bus). Went with the faithful version:
Verified end-to-end: a single blocking One heads-up on tradeoffs I landed on: a deferred send is bounded by the supervisor's deferred-work timeout, so a child blocked on a genuinely-stuck peer past that bound gets |
| // notification loop. A would-block entry stops the batch: the child | ||
| // sees the short count (or EAGAIN if the first entry blocked) and | ||
| // retries, matching the kernel's non-blocking sendmmsg semantics. | ||
| let ret = send_materialized_at(dup_fd.as_raw_fd(), &m, 0, flags | libc::MSG_DONTWAIT); |
There was a problem hiding this comment.
This batch (and the named-unix one) breaks blocking-socket semantics in two ways: if entry 0 would block, a blocking child gets EAGAIN with sent==0, which the kernel never returns on a blocking socket; and a partially-sent stream entry is counted as sent with a short msg_len, i.e. silent truncation, since blocking callers do not check per-entry msg_len.
A would-block at entry i>0 with nothing sent needs no fix: returning the short count i is contract-legal and callers retry. Only two cases must leave the loop, and they are the same operation ("complete entry i off-loop, then report i+1"):
- entry 0 would block entirely
- entry i partially sent (stream)
Suggested shape: split defer_send into a byte-level core, e.g. async fn push_until_done(afd, m, offset) -> Result<usize, i32> (resolve_send wraps it as today). Then, for a blocking child hitting case 1 or 2, return NotifAction::defer of: push_until_done, write the full msg_len back, return i+1. On error: Errno if i==0 and offset==0, else ReturnValue(i). Everything captured (notif copy, notif_fd, the MaterializedMsg) is already static-friendly. All three batch loops can share that tail.
Please do not materialize the whole batch up front to make it replayable: vlen x MAX_SEND_BUF is the supervisor OOM the 64MB cap exists to prevent.
Seccomp notifications are processed sequentially on one loop, and the on-behalf sends (sendmsg / sendto in send_msghdr_on_behalf, send_named_unix_msghdr, sendto_on_behalf) ran inline on it. Because pidfd_getfd shares the child's open file description, the supervisor inherited the child's blocking mode, so a send to a peer that stops draining blocked the whole loop — every trapped syscall from every sandboxed process hung until the peer read or closed. A child could trigger it with no network permission at all (fill one end of a socketpair, then sendmsg). multikernel#125 widened the reach by routing connected AF_UNIX stream sendmsg (Wayland, D-Bus) through this path. The send is now non-blocking and, when it would block, deferred: - Each on-behalf message is materialized into an owned MaterializedMsg (flattened iovec payload, translated control buffer with its SCM_RIGHTS fds, any named-unix inode pin, destination sockaddr), so it can be replayed from a byte offset on a worker without borrowing supervisor state. Batches are materialized one entry at a time, never whole (vlen x MAX_SEND_BUF would be the OOM the cap prevents). - resolve_send does the first send with MSG_DONTWAIT on the loop (never blocking there). For a blocking child whose message didn't fully fit, push_until_done finishes it off the loop: it awaits writability via the Tokio IO driver's epoll (never blocking a worker thread) and pushes the rest, advancing the offset past each partial send, until the full length is delivered — preserving the kernel's "a blocking send of N returns N" contract. Control/address are attached only at offset 0, so SCM_RIGHTS transmits once. A non-blocking child gets the single-attempt result (short count / EAGAIN), as the kernel would return. - sendmmsg keeps blocking semantics per entry: an entry that would block entirely (first entry) or partially sends a stream is completed off the loop via the shared complete_batch_entry tail (writing the full per-entry msg_len so a caller ignoring it isn't silently truncated), then reports the message count. A would-block at a later entry with nothing sent is a contract-legal short count the child retries. Verified end-to-end: a single blocking send() of 2 MiB to a peer that stalls 2 s then drains returns the full 2 MiB in ~2 s (blocking semantics preserved, not truncated to one SO_SNDBUF); a concurrent thread's sends stay at ~1 ms latency while that send is stalled (the loop is not wedged). Adds an integration test that one blocking send() of 4 MiB to a stall-then-drain peer returns the full count and delivers every byte.
4db323f to
ca73457
Compare
Problem
With any
--net-allow/--net-denyrule active, asendmsg()on an already-connectedAF_UNIXstream socket fails withECONNREFUSED.connect()andrecvmsg()on the same socket succeed — only the outboundsendmsg()is rejected. Since libwayland and D-Bus write their protocol viasendmsg(for the fd-passing capability), the client connects, sends its request, and then hangs waiting for a reply the peer never received. This is the GUI breakage reported in #121 (native Wayland clients such asrofi).send()/sendtowere unaffected — they Continue connected sockets unconditionally — which is why the breakage looked selective.Minimal repro (any live pathname unix socket):
Root cause
In
sendmsg_on_behalf, the connected / non-IP-family "Continue" fast path is gated behindif !dest_policy. With a destination policy active, a connectedAF_UNIXsend skips that path, is dup'd, and reachesquery_socket_protocol, which returnsNonefor a unix socket (no IP protocol) — treated as an unknown/forbidden protocol and refused withECONNREFUSED.sendmmsg_on_behalfhas the same gap.Fix
The IP destination policy governs IP sockets only; a
--net-allowrule must not change howAF_UNIXsockets are handled. AnAF_UNIXsend is already fully decided by the named-unix fs gate (a named target is taken on-behalf; a connected/abstract send has no destination to validate). ProbeSO_DOMAINand Continue for unix sockets before the IP path, in bothsendmsg_on_behalfandsendmmsg_on_behalf. This mirrorssendto, which only takes IP-family destinations on-behalf.IP enforcement is untouched: non-unix sockets still resolve their protocol and validate every destination. Verified end-to-end that connected
AF_UNIXsendmsgpasses through under--net-allow(Wayland registry roundtrip restored) while denied IP destinations are still blocked.Test
Adds an integration test: a connected
AF_UNIXstreamsendmsgunder an activenet_allowmust pass through and return the byte count.