Skip to content

[sandbox audit] Resolve privileged host-mode paths by descriptor, never by name - #16

Draft
Wauplin wants to merge 2 commits into
security/sandbox-docs-accuracyfrom
security/fd-relative-privileged-fs
Draft

[sandbox audit] Resolve privileged host-mode paths by descriptor, never by name#16
Wauplin wants to merge 2 commits into
security/sandbox-docs-accuracyfrom
security/fd-relative-privileged-fs

Conversation

@Wauplin

@Wauplin Wauplin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

[sandbox audit] — PR 2 of 13 in this repo's stack; merge in order.
Previous: #15 · Next: #17
Review only the commits this PR adds on top of its base; bases collapse to main as the stack lands.

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:

FAIL  read a host file through a final symlink (got 200: root:x:0:0:root:/root:/bin/bash…)
FAIL  read a sibling's file (got 200: b-secret)
FAIL  list a sibling's home (got 200: {"entries":[…"secret"…]})
FAIL  write through a final symlink (got 200)
FAIL  write through an intermediate symlink (got 200)
FAIL  B's file was modified
ok    B reaches its own service through the proxy      <- positive control
FAIL  A reached B's service through a socket symlink

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.

  • ScopedRoot holds an open fd on the sandbox home. A request path is still normalized
    lexically 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 with ELOOP.
  • Ownership and mode go through the resulting descriptor (fchown, fchmod). This deletes
    chown_into_home, which walked paths, followed symlinks, and compared prefixes lexically.
  • Deletion uses unlinkat, so removing a symlink removes the link and never its target;
    recursive deletion walks by descriptor.
  • stat/list use O_PATH, so they report a link instead of its target.
  • Reads require a regular file (O_NONBLOCK + S_ISREG), so a FIFO in a home can no longer
    block a privileged connection thread.
  • The proxy walks .sbx/proxy the same way, pins the socket inode with O_PATH, requires
    S_ISSOCK and the sandbox's uid, then connects through /proc/self/fd/<n> — resolving that
    magic link lands on the vetted inode without re-walking the path, which closes the
    swap-it-after-the-check race. SO_PEERCRED then confirms the peer runs as the sandbox's uid.
  • The proxy port is parsed as a u16 in both modes, so no request-supplied string reaches
    a filesystem path. (In host mode it previously went in verbatim.)

Deliberately not canonicalize()-then-operate: that is racy, and it is the fix a reviewer
is 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

  • 18 unit tests (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).
  • Ordinary operations checked live: write/read round-trip, list, stat, mkdir, delete, and that
    API-written files are readable by the sandbox's own unprivileged code.

Behaviour changes

  • A path with a symlink component now returns 400 with a message saying so, instead of
    following it. Callers who relied on that are relying on the bug.
  • $SBX_PROXY_DIR/<port>.sock must be a real socket owned by the sandbox. uvicorn --uds
    and friends already produce that; a symlinked socket now gets a 502 explaining why.
  • Version bumped to 0.6.0.

Notes

  • Requires /proc for the two /proc/self/fd uses. The server already depends on it
    (pids_of_uid), and it is granted in the Landlock rules.
  • Supersedes the file-API half of Harden pooled sandbox isolation #14, which I am closing — the remaining pieces of it
    (route-surface separation, per-sandbox tokens, fail-closed auth) come as their own PRs.

Wauplin and others added 2 commits September 8, 2026 15:11
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>
@Wauplin Wauplin changed the title Resolve privileged host-mode paths by descriptor, never by name [sandbox audit] Resolve privileged host-mode paths by descriptor, never by name Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant