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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions