Repository navigation
mnt: anchor legacy mount destinations to an O_NOFOLLOW parent fd - #325
Open
artemkulyk wants to merge 2 commits into
Open
artemkulyk wants to merge 2 commits into
artemkulyk wants to merge 2 commits into
Conversation
mount(2) and open(O_CREAT) resolve the final path component of the destination, so a symlink planted at <staging>/<dst> (by an earlier symlink mount or inside a bound directory) moved the mount or the creation outside the staging tmpfs while isSafeContainmentPath() still returned true: createDirRecursively() only NOFOLLOW-guarded intermediate components and never touched the final one. Replace createDirRecursively() with openParentNoFollow(): the same per-component mkdirat/openat(O_NOFOLLOW) walk, but it walks every component of the destination's parent and returns the parent fd plus the unresolved final component. createMountTarget() and mountSymlink() now operate fd-relative (mkdirat / openat with O_NOFOLLOW / symlinkat), and mount(2) is preceded by a fstatat(AT_SYMLINK_NOFOLLOW) check that refuses a symlinked final component. Staging is single-threaded inside a private tmpfs, so check-then-mount cannot race. This mirrors the containment the new-mount-API backend already provides (fd-walked parents, move_mount without MOVE_MOUNT_T_SYMLINKS).
Add a make test-containment target (also part of make test) that asserts, for the legacy and new-mount-API backends: benign bind pairs still mount; a symlink planted at the final destination component by an earlier symlink mount, by a trailing-slash destination, or via the contents of a bound directory is neither followed by mount(2) nor used by open(O_CREAT) (host-side file absence); a planted symlink at /escdir does not affect /escdir-2 (prefix ambiguity); trailing-slash destinations stay inside the staging root on the legacy backend (the new-API backend has always rejected them, fail closed). Sync the path_containment_test.cc standalone harness to the new openParentNoFollow policy and cover the final-component contract.
This branch has not been deployed
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.
Problem
In the default legacy mount backend, a MountPt destination that passes
util::isSafeContainmentPath()can still resolve outside the staging root when a symlink sits at the final destination component:util::createDirRecursively()NOFOLLOW-guards only the intermediate components of<staging>/<dst>; its loop exits before the final component, which is never checked.createMountTarget()creates the mountpoint withopen(path, O_CREAT|O_RDONLY)/mkdir(path), andtryMountRW()attaches withmount(2)— all three resolve a final-component symlink.mountSymlink()plants symlinks at destinations with an unvalidatedsrc, and symlinks can equally arrive inside the contents of any bound directory.So a symlink planted at
<staging>/<dst>by an earlier symlink mount (or by bound directory contents) redirects a later mount, or the mountpoint creation, to an arbitrary path outside the staging tmpfs — while bothisSafeContainmentPath(dst)andisSafeContainmentPath(dstpath)return true. The security invariant "isSafeContainmentPath(dst) == true⇒ the destination lands inside the staging root" does not hold.The new-mount-API backend already enforces the invariant (per-component
O_NOFOLLOWfd walk,openat(O_CREAT|O_NOFOLLOW),move_mountwithoutMOVE_MOUNT_T_SYMLINKS); only the legacy backend — the default — is affected. This was found during a security review of the mount staging path and reported through Google's VRP intake for this repository, which suggested contributing the fix upstream.Fix
util::createDirRecursively()withutil::openParentNoFollow(): the same per-componentmkdirat/openat(O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW)walk, but it walks every component of the destination's parent and returns the parent fd plus the unresolved final basename. Empty/trailing-slash tails are encoded as.(the walked directory itself), mirroring the new-API backend's convention;./../empty basenames are rejected.createMountTarget()andmountSymlink()operate fd-relative:mkdirat(),openat(..., O_CREAT|O_NOFOLLOW),symlinkat().mount(2)remains path-based, but is now preceded byfstatat(parent_fd, basename, AT_SYMLINK_NOFOLLOW)— a symlinked final component fails the mount point. Staging is single-threaded inside a private tmpfs, so check-then-mount cannot race, and every error path fails closed (mandatory mounts abort, non-mandatory are skipped — unchanged semantics)./xvs/x-2), repeated separators, absolute paths, and normalization forms cannot bypass it; no string-prefix comparison is involved.Legitimate configurations are preserved: normal binds, tmpfs/proc mounts, trailing-slash destinations (legacy backend normalizes them), and
is_symlinkmounts keep working — the change only stops later destination resolution from traversing a planted symlink.Tests
New
make test-containmenttarget (also added tomake test), for both--experimental_mnt=oldand=new:mandatory:falseconfig, and the bound-content variant each assert host-side file absence — they fail before the fix (the host file is created) and pass after (fail closed);/escdirdoes not affect/escdir-2;tests/path_containment_test.cc(standalone harness) is synced to the new helper's policy, including the final-component contract and intermediate-symlink fail-closed cases.Validation
make -j4— Ubuntu 26.04, kernel 7.0.0-28-generic, aarch64 (fix also built and verified on kernels 5.15.0-185 / 6.1.0-50 / 6.8.0-134)sudo make test-containment— 15/15 PASS after the fix; with only the test commit applied, the escape tests fail (host file created)clang-format --style=file --dry-run -Werroron all touched files — cleang++ -std=c++20 -O1 tests/path_containment_test.cc && ./path_containment_test— OKmake test: 28 tests pass, then aborts at a pre-existing, unrelated failure on this kernel (setprivtest expects 255, got 0 — present without this patch);test-containmentruns standalone for clean validation