Skip to content

Chroot named-AF_UNIX gate bypass: lexical check + Continue (symlink/TOCTOU) and non-UTF-8 None-collapse (fail-open) #143

Description

@dzerik

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-77path_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.

  1. 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.
  2. 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:206std::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 Noneindistinguishable 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 → NoneContinue → 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)OsStrPathBuf. 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_pathSome(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions