[sandbox audit] Resolve privileged host-mode paths by descriptor, never by name - #16
Draft
Wauplin wants to merge 2 commits into
Draft
[sandbox audit] Resolve privileged host-mode paths by descriptor, never by name#16Wauplin wants to merge 2 commits into
Wauplin wants to merge 2 commits into
Conversation
The file API and the port proxy both run as root, outside every sandbox's Landlock domain, and both resolved names that the sandbox itself controls. That made the server a confused deputy: a sandbox could plant a symlink in its own home and have a privileged operation follow it out. Both were reachable in practice. A pooled sandbox could get the file API to read, write, chown and delete outside its home -- including a sibling's home and arbitrary host files -- and could get the port proxy to connect to a sibling's service, or to any socket visible to root on the host. Exploiting either needs a legitimate caller to invoke the endpoint for the squatted name, so they are confused-deputy problems rather than direct escapes, but they do break confidentiality and integrity between pooled sandboxes. Adds `fsutil`, a small descriptor-relative layer, and moves both call sites onto it: - `ScopedRoot` holds an open fd on the sandbox home. Every request path is normalized lexically (as before, so no path can *name* an escape) and then walked one component at a time with `openat` + `O_NOFOLLOW`, so a symlink anywhere in the path fails with ELOOP instead of being followed. - Ownership and mode are applied through the resulting descriptor (`fchown`, `fchmod`) rather than by path, so `chown_into_home`'s path-based walk -- which followed symlinks and compared prefixes lexically -- is gone. - Deletion uses `unlinkat`, so removing a symlink removes the link and never touches its target; recursive deletion walks by descriptor. - `stat` and `list` use `O_PATH` so they report a link rather than its target. - Reads require a regular file: `O_NONBLOCK` plus an `S_ISREG` check, so a FIFO in a home can no longer block a privileged connection thread. - The port proxy walks `.sbx/proxy` the same way, pins the socket inode with `O_PATH`, requires `S_ISSOCK` and the sandbox's uid, and then connects through `/proc/self/fd/<n>` so the vetted inode cannot be swapped for a symlink between the check and the connect. `SO_PEERCRED` then confirms the peer really runs as the sandbox's uid. - The proxy port is parsed as a `u16` in both modes, so no request-supplied string is interpolated into a filesystem path. Dedicated mode keeps ordinary path semantics: the job *is* the sandbox, so there is no second tenant to confuse the server about. The asymmetry is commented where it appears. Behaviour change: a path whose component is a symlink now returns 400 with a message saying so, where it previously followed the link. `$SBX_PROXY_DIR` sockets must be real sockets owned by the sandbox. Validation: - 18 unit tests (`cargo test`), covering final and intermediate symlinks per operation, delete-through-symlink, stat/list reporting the link, FIFO refusal, truncate-vs-ranged-write, and the proxy's socket checks. - `scripts/symlink-regression.sh`: a root-container end-to-end run with two real sandboxes that reproduces both escapes. Verified to fail on the parent commit (8 failures, including reading /etc/passwd, modifying a sibling's file, and reaching a sibling's service through a socket symlink) and to pass here, with a positive control proving the proxy still works. - `cargo build --release --target x86_64-unknown-linux-musl`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file API and port proxy no longer follow symlinks, so remove their entries from README Known limitations and describe the descriptor-relative behaviour in the host-mode file-path paragraph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The file API and the port proxy both run as root, outside every sandbox's Landlock domain,
and both resolved names that the sandbox itself controls. That makes the server a confused
deputy: a sandbox plants a symlink in its own home, then a legitimate caller invokes the
endpoint for that name and the privileged operation follows the link out.
Both were reachable in practice. Verified on the parent commit, with two real sandboxes on
one host:
So: cross-sandbox read, write and ownership change through the file API, and cross-sandbox
service access through the proxy. The proxy's reach is not limited to siblings — it is any
socket root can see on the host.
Approach
New
src/fsutil.rs, a small descriptor-relative layer, with both call sites moved onto it.The invariant it enforces: a privileged host-mode operation never resolves a name it does
not already hold a descriptor for.
ScopedRootholds an open fd on the sandbox home. A request path is still normalizedlexically first (so it cannot name an escape), then walked one component at a time with
openat+O_NOFOLLOW— a symlink anywhere in the path fails withELOOP.fchown,fchmod). This deleteschown_into_home, which walked paths, followed symlinks, and compared prefixes lexically.unlinkat, so removing a symlink removes the link and never its target;recursive deletion walks by descriptor.
stat/listuseO_PATH, so they report a link instead of its target.O_NONBLOCK+S_ISREG), so a FIFO in a home can no longerblock a privileged connection thread.
.sbx/proxythe same way, pins the socket inode withO_PATH, requiresS_ISSOCKand the sandbox's uid, then connects through/proc/self/fd/<n>— resolving thatmagic link lands on the vetted inode without re-walking the path, which closes the
swap-it-after-the-check race.
SO_PEERCREDthen confirms the peer runs as the sandbox's uid.u16in both modes, so no request-supplied string reachesa filesystem path. (In host mode it previously went in verbatim.)
Deliberately not
canonicalize()-then-operate: that is racy, and it is the fix a revieweris most likely to suggest.
Dedicated mode keeps ordinary path semantics — the job is the sandbox, so there is no second
tenant to be confused about. The asymmetry is commented where it appears.
Validation
cargo test): final and intermediate symlinks per operation,delete-through-symlink, stat/list reporting the link, FIFO refusal, truncate vs ranged write,
parent-chain creation, and the proxy's port parsing, socket-type, ownership and
connect-through-pinned-fd behaviour.
scripts/symlink-regression.sh— root-container end-to-end with two real sandboxes,reproducing both escapes. Verified to fail on the parent commit (output above) and to
pass here, twice, with a positive control asserting the proxy still works. Every negative
check asserts its own setup succeeded first, so none of them can pass vacuously.
cargo build --release --target x86_64-unknown-linux-musl(the published target).API-written files are readable by the sandbox's own unprivileged code.
Behaviour changes
following it. Callers who relied on that are relying on the bug.
$SBX_PROXY_DIR/<port>.sockmust be a real socket owned by the sandbox.uvicorn --udsand friends already produce that; a symlinked socket now gets a 502 explaining why.
Notes
/procfor the two/proc/self/fduses. The server already depends on it(
pids_of_uid), and it is granted in the Landlock rules.(route-surface separation, per-sandbox tokens, fail-closed auth) come as their own PRs.