Skip to content

mnt: descriptor-confine mount destination resolution#287

Open
deprrous wants to merge 1 commit into
google:masterfrom
deprrous:agent/mount-destination-confinement-v8
Open

mnt: descriptor-confine mount destination resolution#287
deprrous wants to merge 1 commit into
google:masterfrom
deprrous:agent/mount-destination-confinement-v8

Conversation

@deprrous

Copy link
Copy Markdown

Summary

This change keeps mount destination resolution and post-attach verification bound to descriptors across both mount backends.

It:

  • resolves destination prefixes and final-component handling under an anchored descriptor
  • preserves legitimate in-root final-component symlinks instead of rejecting them wholesale
  • captures attached mount identity from the attached mount descriptor rather than reopening the destination by pathname
  • keeps legacy fallback behavior intact when mount_setattr() returns ENOSYS
  • adds focused regression coverage for destination confinement, attach identity, and pre-capture exchange handling

Why

Mount destination handling currently relies on attacker-influenced filesystem state in ways that make the confinement boundary and post-attach verification harder to reason about. This patch removes those ambient-path reopen points and makes the destination/mount identity invariants explicit in code and tests.

Validation

  • make -j4 nsjail
  • make test_mount_destination_confinement
  • make test_mount_attach_identity
  • make test_mount_precapture_identity
  • make test_legacy_mount_setattr_enosys
  • git diff --check

@deprrous
deprrous marked this pull request as ready for review July 21, 2026 08:54
@M0nd0R

M0nd0R commented Jul 23, 2026

Copy link
Copy Markdown

Nice work — descriptor-confining destination resolution and pinning post-attach identity to the attached mount fd is the right way to close these TOCTOU windows, and the test matrix (both backends, disable_clone_newuser, strace race injection, and the mount-identity oracles) is impressively thorough.

A few things before this is easy to land:

  1. Could this be split into reviewable commits? Right now the security fix, the legacy stage-and-move rewrite, and the ~1,500-line test harness are one 3k-line commit, which is hard to review and bisect.
  2. Some functions (isGenericMountOption, computeRemountFlags) appear to move from mnt_newapi.cc into mnt.cc — can you confirm the move is behavior-preserving?
  3. For portability: statx/STATX_MNT_ID and openat2 are guarded with __has_include / ENOSYS fallbacks — have you validated the fallback paths on an older kernel/libc without these?

Overall the invariants (.. rejection, 40-symlink cap, capturing identity from the attached fd rather than re-walking the path) look sound to me.

@deprrous

Copy link
Copy Markdown
Author

Thanks for the careful review.

A few clarifications:

  1. I have not rewritten the branch history yet. Since this is still a single-codepath change and there is not maintainer guidance yet on whether a force-pushed split is preferred, I left the current commit graph alone for now to avoid unnecessary churn. If maintainers would like it split into logical commits, I can do that next.

  2. isGenericMountOption() was moved into mnt.cc intentionally and without semantic changes so both backends can share the same generic mount-option parsing. computeRemountFlags() did not move into mnt.cc; it now remains legacy-local in mnt_legacy.cc, while the new-mount-api path applies the final flags via mount_setattr() on the attached mount fd rather than recomputing legacy remount flags.

  3. Portability-wise, the checked-in branch already includes explicit coverage for the legacy mount_setattr(...)=ENOSYS path via make test_legacy_mount_setattr_enosys. I have not yet claimed full old-kernel/old-libc validation for the openat2 / statx fallbacks themselves; those runtime fallbacks are in the patch, but I have not posted a separate portability-only follow-up yet. If that proof is important before merge, I can add a focused fault-injected check next without changing the core fix.

I also reran the current destination-confinement and attach-identity matrices locally again today while checking your notes; the current branch behavior is consistent with the intended invariants.

@deprrous

Copy link
Copy Markdown
Author

Small follow-up after another local pass over the edge cases discussed here.

I checked a few things that were not covered as explicitly in my previous comment:

  • Top-level destination exchange: I tried swapping a top-level target at the mount hook point for both old and new backends. In the supported custom-root setup, the attacker could not mutate the root mount-tree entry, and the jailed command still ran normally.
  • Search-only directories: I tested existing file and directory targets under execute-only/search-only parent directories across old/new backends and both user-namespace profiles. I could not reproduce an EACCES regression from the descriptor reopen path.
  • Runtime fallbacks: representative openat2=ENOSYS and statx=ENOSYS fault-injected checks passed locally, in addition to the existing legacy mount_setattr=ENOSYS target.

I also rebuilt the normal release binary after the hook-enabled tests and checked that the test hook compiles back down to a no-op outside NSJAIL_TEST_HOOKS builds.

I still have not done a physical old-kernel/old-libc boot test, so I do not want to overclaim that part. The branch is also still one functional commit; I can split it if a maintainer would prefer that for review.

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.

2 participants