Chroot named-AF_UNIX gate is bypassable: lexical check + Continue (symlink/TOCTOU) and non-UTF-8 None-collapse (fail-open)
Component: crates/sandlock-core/src/network/ (connect / sendto / sendmsg / sendmmsg named-AF_UNIX gate)
Affected tree: main @ c63cf0e
Impact: an untrusted, unprivileged, non-ptracing child under an active has_unix_fs_gate policy can connect/send to a named AF_UNIX socket that is not under any fs-write grant — a full bypass of the only enforcement layer for pathname AF_UNIX (Landlock has no access right for unix connect, per connect.rs:119-121). Two distinct defects share this gate.
Bug 1 — HIGH — chroot branch uses a lexical path check and returns Continue on allow (symlink + TOCTOU)
Where
network/connect.rs:135-136 (connect)
network/send.rs:112-113 (sendto)
network/unix.rs:183-184 (sendmsg, via unix_sendmsg_gate)
network/send.rs:383 (sendmmsg chroot loop)
- Predicate:
network/verdict.rs:74-77 — path_under_any calls chroot::resolve::confine (chroot/resolve.rs:7-28), a purely lexical ./.. collapse that never touches the filesystem and never follows symlinks.
What
In chroot mode a named AF_UNIX target is validated with path_under_any(&path, &ctx.policy.chroot_writable) on the supervisor's immune copy, and on a match returns NotifAction::Continue:
if ctx.policy.chroot_root.is_some() {
if path_under_any(&path, &ctx.policy.chroot_writable) {
NotifAction::Continue // <-- kernel re-reads sun_path and resolves it
} else {
NotifAction::Errno(libc::EACCES)
}
} else {
connect_named_unix_on_behalf(/* pins the real inode, never Continue */)
}
Continue hands the syscall back to the kernel, which re-reads sun_path from child memory and performs real path resolution, following symlinks. The non-chroot branch is safe by contrast: it pins the real inode with O_PATH under /proc/<pid>/root, checks real_path_under_any, and acts on-behalf against /proc/self/fd/<pin> — never Continue.
Note that this "chroot" is virtual: the child is not chroot(2)/pivot_root'd and shares the host mount namespace (context.rs only does unshare(CLONE_NEWUSER) for --user; the chroot is emulated via seccomp path-rewriting of open-family syscalls, and connect/sendto sun_path is not rewritten). So on Continue the kernel resolves the child-controlled path against the host root.
Failure scenario (reachable, no root, no ptrace)
Policy: chroot active, an fs-write grant on a directory, has_unix_fs_gate on.
- TOCTOU (no symlink, no mount coincidence): a sibling thread overwrites the sun_path bytes in child memory after
path_under_any copies/checks but before the kernel re-reads on Continue. CLONE_THREAD is not denied (only a planned mitigation), so the racer is available. The child passes a granted path at check time, then swaps to /run/docker.sock; the kernel connects to the host socket.
- Symlink: with a write grant that is an RW identity bind mount (virtual path == host path), the child creates a symlink at a granted path whose target is an ungranted host socket. The lexical check confines to the symlink's granted path →
Continue → the kernel follows the link. (For a plain relocated write grant the supervisor materializes the symlink at chroot_root/<grant> while the kernel resolves the literal virtual path against the host root, so this variant needs the identity-mount alignment; the TOCTOU variant above does not.)
Why this matters even though other Continue sites exist
chroot/dispatch.rs (issue #27 discussion, module docs lines 6-40) already classifies its Continue sites and accepts the "category 4 Path-rewrite-then-Continue" TOCTOU window because the bound is Landlock (landlock_restrict_self still applies to a raced path). The named-AF_UNIX network gate is the same Continue-TOCTOU pattern, but there is no Landlock backstop for pathname AF_UNIX connect — so that safety argument does not extend here.
Why tests miss it
The chroot gate tests assert only the lexical verdict (in-grant literal → allow, out-of-grant literal → EACCES) on static PathBufs. None plants a symlink inside a write grant, none races a second thread mutating sun_path after the check, and none verifies that the inode the kernel actually reaches equals the inode that was checked. The inode-pinning tests cover only the non-chroot branch, masking that the chroot branch skips pinning entirely.
Proposed fix
Drop the lexical + Continue branch in chroot mode; reuse the non-chroot on-behalf pattern. Resolve sun_path to its real inode in the child's root view (/proc/<pid>/root + sun_path) with O_PATH (follows symlinks), map the canonical host path back to a virtual path (chroot::resolve::to_virtual_path), require it under a write grant, and connect/send on-behalf against /proc/self/fd/<pin> — i.e. reuse resolve_named_unix_target + connect_named_unix_on_behalf / sendto_named_unix_on_behalf / sendmsg_named_unix_on_behalf / sendmmsg_named_unix_on_behalf. Never return Continue on the allow branch.
Deny path: NotifAction::Errno(libc::EACCES) when the pinned real inode does not map under a virtual write grant (ECONNREFUSED when unresolvable); the allow path executes on-behalf on the pinned fd.
Bug 2 — MEDIUM — named_unix_socket_path returns None for a non-UTF-8 sun_path → fail-open Continue
Where
network/materialize.rs:206 — std::str::from_utf8(raw).ok().map(std::path::PathBuf::from)
- Callers that collapse
None into pass-through: connect.rs:153 (_ => Continue), send.rs:130 (sendto _ => Continue), unix.rs:180 (named_unix_socket_path(&addr_bytes)? short-circuits unix_sendmsg_gate), and mmsg_entry_named_unix_path (the sendmmsg entry becomes invisible to the scan).
What
Linux AF_UNIX sun_path is an arbitrary byte string; UTF-8 is not required. A single non-UTF-8 byte makes from_utf8 fail, so named_unix_socket_path returns None — indistinguishable from a genuine abstract socket (sun_path[0] == 0, materialize.rs:198). Every caller treats None as "gate not applicable" and returns Continue, so the kernel performs the connect/send to the child-controlled byte-exact path with no write-grant check. A non-UTF-8 path can never match a UTF-8 grant prefix, so the correct verdict is always deny. This also violates the code's own documented invariant "we never return Continue on the deny path" (connect.rs:125-126).
Failure scenario (reachable, no root, no ptrace)
A child connect()s/sendto()s an AF_UNIX socket whose sun_path contains e.g. 0x80 and targets a socket that exists outside the write grants. from_utf8 fails → None → Continue → the kernel resolves the byte-exact path and connects.
Honest exploitability bound: weaponizing into reaching a forbidden target requires a socket already bound at a non-UTF-8 path outside the write grants — the child cannot create one there itself (bind is Landlock-gated), and standard host sockets have ASCII names. So the practical target set is narrow, which is why this is MEDIUM, not HIGH. It remains a real completeness hole in a deny-by-default gate with no alternate enforcement layer.
Why tests miss it
All named-unix gate tests build sun_path from ASCII/UTF-8 literals, so from_utf8 always succeeds. The abstract test (sun_path[0]==0 → None) and the named test (UTF-8 → Some) both pass, hiding that a third class — named-but-non-UTF-8 — silently joins the abstract/Continue bucket.
Proposed fix
Parse sun_path as raw bytes: std::os::unix::ffi::OsStrExt::from_bytes(raw) → OsStr → PathBuf. Keep None only for sun_path[0] == 0 (abstract) and empty. A non-UTF-8 named socket is then classified as named and routed through the gate (on-behalf resolver / the Bug 1 chroot fix), where it fails the grant check.
Deny path: any non-abstract, non-empty sun_path → Some(PathBuf) → gated; not under a write grant → NotifAction::Errno(libc::EACCES).
Scope checked and clean
- IP connect/sendto/sendmsg/sendmmsg (AF_INET/INET6): on-behalf path operates on the supervisor's immune sockaddr copy and connects the dup'd fd — no Continue-on-allow TOCTOU, no IPv4/IPv6 parity gap.
- sockaddr materialize bounds:
named_unix_socket_path guards len < 3, truncates at first NUL, checks empty — no OOB/overflow/OOM from child-controlled length in this parser.
- Non-chroot named-AF_UNIX path: symlink/TOCTOU-safe (O_PATH inode pin +
real_path_under_any + on-behalf) — the pattern Bug 1 should adopt.
- sendmsg/sendmmsg under an active net-destination policy: fail closed (non-IP addr →
EAFNOSUPPORT); Bug 2's fail-open is specific to the fs-grant-without-net-allowlist config.
Chroot named-AF_UNIX gate is bypassable: lexical check +
Continue(symlink/TOCTOU) and non-UTF-8None-collapse (fail-open)Component:
crates/sandlock-core/src/network/(connect / sendto / sendmsg / sendmmsg named-AF_UNIX gate)Affected tree:
main@c63cf0eImpact: an untrusted, unprivileged, non-ptracing child under an active
has_unix_fs_gatepolicy can connect/send to a named AF_UNIX socket that is not under any fs-write grant — a full bypass of the only enforcement layer for pathname AF_UNIX (Landlock has no access right for unix connect, perconnect.rs:119-121). Two distinct defects share this gate.Bug 1 — HIGH — chroot branch uses a lexical path check and returns
Continueon allow (symlink + TOCTOU)Where
network/connect.rs:135-136(connect)network/send.rs:112-113(sendto)network/unix.rs:183-184(sendmsg, viaunix_sendmsg_gate)network/send.rs:383(sendmmsg chroot loop)network/verdict.rs:74-77—path_under_anycallschroot::resolve::confine(chroot/resolve.rs:7-28), a purely lexical./..collapse that never touches the filesystem and never follows symlinks.What
In chroot mode a named AF_UNIX target is validated with
path_under_any(&path, &ctx.policy.chroot_writable)on the supervisor's immune copy, and on a match returnsNotifAction::Continue:Continuehands the syscall back to the kernel, which re-reads sun_path from child memory and performs real path resolution, following symlinks. The non-chroot branch is safe by contrast: it pins the real inode withO_PATHunder/proc/<pid>/root, checksreal_path_under_any, and acts on-behalf against/proc/self/fd/<pin>— neverContinue.Note that this "chroot" is virtual: the child is not
chroot(2)/pivot_root'd and shares the host mount namespace (context.rsonly doesunshare(CLONE_NEWUSER)for--user; the chroot is emulated via seccomp path-rewriting of open-family syscalls, andconnect/sendtosun_path is not rewritten). So onContinuethe kernel resolves the child-controlled path against the host root.Failure scenario (reachable, no root, no ptrace)
Policy: chroot active, an fs-write grant on a directory,
has_unix_fs_gateon.path_under_anycopies/checks but before the kernel re-reads onContinue.CLONE_THREADis not denied (only a planned mitigation), so the racer is available. The child passes a granted path at check time, then swaps to/run/docker.sock; the kernel connects to the host socket.Continue→ the kernel follows the link. (For a plain relocated write grant the supervisor materializes the symlink atchroot_root/<grant>while the kernel resolves the literal virtual path against the host root, so this variant needs the identity-mount alignment; the TOCTOU variant above does not.)Why this matters even though other
Continuesites existchroot/dispatch.rs(issue #27 discussion, module docs lines 6-40) already classifies itsContinuesites and accepts the "category 4 Path-rewrite-then-Continue" TOCTOU window because the bound is Landlock (landlock_restrict_selfstill applies to a raced path). The named-AF_UNIX network gate is the same Continue-TOCTOU pattern, but there is no Landlock backstop for pathname AF_UNIX connect — so that safety argument does not extend here.Why tests miss it
The chroot gate tests assert only the lexical verdict (in-grant literal → allow, out-of-grant literal →
EACCES) on staticPathBufs. None plants a symlink inside a write grant, none races a second thread mutating sun_path after the check, and none verifies that the inode the kernel actually reaches equals the inode that was checked. The inode-pinning tests cover only the non-chroot branch, masking that the chroot branch skips pinning entirely.Proposed fix
Drop the lexical +
Continuebranch in chroot mode; reuse the non-chroot on-behalf pattern. Resolve sun_path to its real inode in the child's root view (/proc/<pid>/root+ sun_path) withO_PATH(follows symlinks), map the canonical host path back to a virtual path (chroot::resolve::to_virtual_path), require it under a write grant, and connect/send on-behalf against/proc/self/fd/<pin>— i.e. reuseresolve_named_unix_target+connect_named_unix_on_behalf/sendto_named_unix_on_behalf/sendmsg_named_unix_on_behalf/sendmmsg_named_unix_on_behalf. Never returnContinueon the allow branch.Deny path:
NotifAction::Errno(libc::EACCES)when the pinned real inode does not map under a virtual write grant (ECONNREFUSEDwhen unresolvable); the allow path executes on-behalf on the pinned fd.Bug 2 — MEDIUM —
named_unix_socket_pathreturnsNonefor a non-UTF-8 sun_path → fail-openContinueWhere
network/materialize.rs:206—std::str::from_utf8(raw).ok().map(std::path::PathBuf::from)Noneinto pass-through:connect.rs:153(_ => Continue),send.rs:130(sendto_ => Continue),unix.rs:180(named_unix_socket_path(&addr_bytes)?short-circuitsunix_sendmsg_gate), andmmsg_entry_named_unix_path(the sendmmsg entry becomes invisible to the scan).What
Linux AF_UNIX
sun_pathis an arbitrary byte string; UTF-8 is not required. A single non-UTF-8 byte makesfrom_utf8fail, sonamed_unix_socket_pathreturnsNone— indistinguishable from a genuine abstract socket (sun_path[0] == 0,materialize.rs:198). Every caller treatsNoneas "gate not applicable" and returnsContinue, so the kernel performs the connect/send to the child-controlled byte-exact path with no write-grant check. A non-UTF-8 path can never match a UTF-8 grant prefix, so the correct verdict is always deny. This also violates the code's own documented invariant "we never return Continue on the deny path" (connect.rs:125-126).Failure scenario (reachable, no root, no ptrace)
A child
connect()s/sendto()s an AF_UNIX socket whosesun_pathcontains e.g.0x80and targets a socket that exists outside the write grants.from_utf8fails →None→Continue→ the kernel resolves the byte-exact path and connects.Honest exploitability bound: weaponizing into reaching a forbidden target requires a socket already bound at a non-UTF-8 path outside the write grants — the child cannot create one there itself (bind is Landlock-gated), and standard host sockets have ASCII names. So the practical target set is narrow, which is why this is MEDIUM, not HIGH. It remains a real completeness hole in a deny-by-default gate with no alternate enforcement layer.
Why tests miss it
All named-unix gate tests build
sun_pathfrom ASCII/UTF-8 literals, sofrom_utf8always succeeds. The abstract test (sun_path[0]==0 → None) and the named test (UTF-8 →Some) both pass, hiding that a third class — named-but-non-UTF-8 — silently joins the abstract/Continuebucket.Proposed fix
Parse
sun_pathas raw bytes:std::os::unix::ffi::OsStrExt::from_bytes(raw)→OsStr→PathBuf. KeepNoneonly forsun_path[0] == 0(abstract) and empty. A non-UTF-8 named socket is then classified as named and routed through the gate (on-behalf resolver / the Bug 1 chroot fix), where it fails the grant check.Deny path: any non-abstract, non-empty
sun_path→Some(PathBuf)→ gated; not under a write grant →NotifAction::Errno(libc::EACCES).Scope checked and clean
named_unix_socket_pathguardslen < 3, truncates at first NUL, checks empty — no OOB/overflow/OOM from child-controlled length in this parser.real_path_under_any+ on-behalf) — the pattern Bug 1 should adopt.EAFNOSUPPORT); Bug 2's fail-open is specific to the fs-grant-without-net-allowlist config.