8000 mnt: fail closed on untrusted workdir candidates by krishna28238-arch · Pull Request #381 · google/nsjail · GitHub
[go: up one dir, main page]

Skip to content

mnt: fail closed on untrusted workdir candidates - #381

Open
krishna28238-arch wants to merge 1 commit into
google:masterfrom
krishna28238-arch:workdir-verified-fd
Open

krishna28238-arch wants to merge 1 commit into
google:masterfrom
krishna28238-arch:workdir-verified-fd

Conversation

@krishna28238-arch
Copy link
Copy Markdown

While auditing how nsjail picks its staging directories, I found a seam the recent hardening work in this area doesn't cover: findWorkDir() can select and attach the jail staging root onto an entry a local attacker planted first. This PR makes that selection fail closed.

The problem

findWorkDir() builds candidates like /tmp/nsjail.<uid>.<purpose> under world-writable parents (/tmp, /dev/shm, writable dirs found under /). tryCreateDir() accepts a pre-existing entry whenever mkdir() reports EEXIST — regardless of who owns it or whether it is a symlink — and every caller then attaches the staging tmpfs by path: mount(2) in the legacy backend, move_mount(2) plus two later openat() re-opens in the newapi backend. A path-based attach takes whatever entry currently sits at that location.

So on a shared multi-user box, a local user who creates /tmp/nsjail.0.root (a directory they own, or a symlink into their home) before root runs nsjail gets the staging root — and the populated jail root tree — mounted onto their own directory, with the candidate names fully predictable from uid and purpose. This is the same class as #323 (log file opened through a symlink), one layer above what the other PRs in this arc hardened: #292 rejects traversal in the destination strings inside the staging tree, #325 (open) anchors legacy mount destinations to a verified parent, and #334 made flag application fail closed — but the staging root itself was still chosen by an unverified path. #287 explored a descriptor-confined resolution for destinations and was closed; this applies the descriptor idea to the staging root selection itself.

What this PR does

  • util::openVerifiedDir(): creates or reuses a directory and returns an O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW fd only after fstat confirms the inode is owned by the current euid. Symlinks, regular files and foreign-owned directories are refused.
  • findWorkDir() validates every candidate through it and skips a refused candidate (fail closed, in the spirit of mnt: fail closed when explicit security mount flags cannot be applied #334); the verified fd travels back to the caller so nothing re-resolves the path afterwards.
  • newapi backend: move_mount() attaches the jail root onto the verified fd itself (MOVE_MOUNT_T_EMPTY_PATH), and the two openat() re-opens of the staging root gained O_NOFOLLOW.
  • legacy backend: both staging tmpfs mounts target /proc/self/fd/<fd>, pinning the verified inode instead of re-walking /tmp.

Before / after

entry planted at /tmp/nsjail.<uid>.root before after
attacker-owned directory staging root (then the jail root tree) mounted onto it candidate refused, next candidate used
symlink into attacker-controlled dir followed by the legacy mount target and the newapi re-opens refused (O_NOFOLLOW)
regular file accepted, nsjail aborts at mount time refused, falls through to the next candidate

Testing

  • Full make builds clean (with -Werror) on top of master (d8415bc).
  • I extracted the new helper into a standalone test binary linked against the built objects; all six behaviors pass: fresh dir accepted; pre-existing euid-owned dir reused; symlink refused; regular file refused; root-owned directory refused while running as an unprivileged user; and the fallback candidate is still usable after a refusal.
  • End-to-end: with regular files planted at both /tmp/nsjail.<uid>.root and /tmp/nsjail.<uid>.tmp (old code accepts the paths and then dies at mount), nsjail now skips the poisoned candidates, falls back to the /dev/shm candidate, builds the mount tree and runs the payload inside the jail normally.
  • clang-format (LLVM 19): no new violations against the repository baseline.

One note for review: my end-to-end run exercised the legacy backend (the test container has no move_mount, so isAvailable() selects legacy). The newapi fd-target path is compile-checked and its fd logic is covered by the unit test; a native run there would be a useful second opinion.

The diff is +101/−16 over six files, most of it the helper and the two attach sites. Happy to reshape or split anything.

findWorkDir() accepted any pre-existing entry at a candidate path as
long as access(R_OK) succeeded: a symlink or a foreign-owned directory
planted in a shared parent such as /tmp was silently selected as the
staging root, and both mount backends then attached the jail-root
tmpfs to it through path-based operations that follow the planted
entry.

Verify every candidate with a new util::openVerifiedDir() helper:
only a real directory (O_NOFOLLOW) owned by the current euid is
accepted, and the verified fd is returned to the callers. The new-API
backend attaches the detached root tmpfs with move_mount() against
that fd, and the legacy backend mounts its staging trees through
/proc/self/fd handles, so the attach targets the verified inode
instead of re-resolving the path.

This branch has not been deployed

No deployments
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

0