Skip to content

mnt: anchor legacy mount destinations to an O_NOFOLLOW parent fd - #325

Open
artemkulyk wants to merge 2 commits into
google:masterfrom
artemkulyk:fix/mnt-dst-final-symlink
Open

artemkulyk wants to merge 2 commits into
google:masterfrom
artemkulyk:fix/mnt-dst-final-symlink

Conversation

@artemkulyk

Copy link
Copy Markdown

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 with open(path, O_CREAT|O_RDONLY) / mkdir(path), and tryMountRW() attaches with mount(2) — all three resolve a final-component symlink.
  • mountSymlink() plants symlinks at destinations with an unvalidated src, 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 both isSafeContainmentPath(dst) and isSafeContainmentPath(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_NOFOLLOW fd walk, openat(O_CREAT|O_NOFOLLOW), move_mount without MOVE_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

  • Replace util::createDirRecursively() with util::openParentNoFollow(): the same per-component mkdirat/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() and mountSymlink() operate fd-relative: mkdirat(), openat(..., O_CREAT|O_NOFOLLOW), symlinkat().
  • mount(2) remains path-based, but is now preceded by fstatat(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).
  • Containment is structural (fd-anchored), so prefix collisions (/x vs /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_symlink mounts keep working — the change only stops later destination resolution from traversing a planted symlink.

Tests

New make test-containment target (also added to make test), for both --experimental_mnt=old and =new:

  • benign bind pairs (directory + file, and bound-directory content) still mount;
  • the original escape (symlink mount + bind at the same destination), the same pair via a mandatory:false config, 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);
  • prefix ambiguity: a planted symlink at /escdir does not affect /escdir-2;
  • trailing-slash destinations mount normally on the legacy backend; the new-API backend has always rejected them (fail closed, asserted).

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 -Werror on all touched files — clean
  • g++ -std=c++20 -O1 tests/path_containment_test.cc && ./path_containment_test — OK
  • Full make test: 28 tests pass, then aborts at a pre-existing, unrelated failure on this kernel (setpriv test expects 255, got 0 — present without this patch); test-containment runs standalone for clean validation

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

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