Skip to content

chown, chgrp, chmod: bind the recursive operand to a verified descriptor - #14483

Open
sylvestre wants to merge 3 commits into
uutils:mainfrom
sylvestre:chown-verify-operand-fd
Open

sylvestre wants to merge 3 commits into
uutils:mainfrom
sylvestre:chown-verify-operand-fd

Conversation

@sylvestre

Copy link
Copy Markdown
Contributor

No description provided.

traverse() classified the operand from pathname metadata -- is it a
directory, does --from match, is it '/' -- and then re-resolved the same
pathname twice: once with DirFd::open(path, Follow) for the fchown, and
again in safe_dive_into() for the descent. Neither open was checked
against the metadata the decisions were made on.

An attacker able to rename inside the operand's parent could therefore
swap the classified directory for a symlink after the checks, and every
fd-anchored mutation below ran inside the symlink target: fchown on the
root, chown_at/chmod_at on the descendants. --from and --preserve-root
were evaluated against the decoy and never re-checked, so the filter was
bypassed rather than merely the traversal.

Open the operand once, fstat the descriptor and compare (dev, ino)
against that metadata, and fail closed if they differ. The descent reuses
that same descriptor, so the pathname is resolved exactly once, and
whether to descend at all is now decided from the metadata rather than
from fresh is_symlink()/is_dir() lookups.

b286558 fixed the descent open (open_subdir with the caller's symlink
policy); this is the same fail-closed pattern one level up, at the
acquisition of the operand itself.

Measured with a rename-exchange harness racing chgrp -R against a
symlink to an off-tree directory: before, the off-tree victim was
regrouped within 1-4 invocations on every run; after, 1500 invocations
left it untouched. GNU chgrp resists the same harness. The static flag
matrix (-P/-H/-L, symlink and directory operands, --from, --preserve-root,
missing operand) is byte-identical before and after.
walk_dir_with_context checked is_symlink() and is_dir() on the operand
pathname and then opened that pathname again with a hardcoded
SymlinkBehavior::Follow. An attacker able to rename inside the operand's
parent could swap the directory just checked for a symlink in between,
and the whole descent ran inside the symlink target -- under -P, which
asks for no symlink to be traversed at all.

Open with should_follow_symlink instead, the policy the checks were made
under: O_NOFOLLOW makes the open fail closed when the operand became a
symlink, while -H/-L, where following the operand is what was asked for,
are unchanged. This mirrors what the descent already does for entries met
inside the tree.

Measured with a rename-exchange harness racing chmod -R -P against a
symlink to an off-tree directory: before, an off-tree file was re-moded
within 1-8 invocations on every run; after, 1500 invocations left it
untouched. GNU chmod resists the same harness under -P. Under -H and -L
both GNU and uutils follow, as intended.

Still path-based, and not addressed here: the operand's own chmod_file
call resolves the pathname a third time, so a swap landing in that
narrower window re-modes the symlink target itself (not its contents).
Closing it needs the operand's mode change to go through the descriptor.
The pre-existing "cannot access" messages in the recursive walk were
still hardcoded English. Route them through translate!() with the
perms-cannot-access and perms-too-many-symlink-levels keys.
Copilot AI lite review requested due to automatic review settings September 9, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues remain in recursive operand binding, symlink traversal, and regression coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR hardens recursive permission and ownership operations against pathname replacement, updates localized diagnostics, and aligns chmod traversal with symlink policies.

Changes:

  • Verifies descriptors for recursive chown/chgrp.
  • Updates shared permission diagnostics.
  • Adjusts chmod recursive root handling.
File summaries
File Summary Final findings
src/uucore/src/lib/features/perms.rs Descriptor verification and recursive ownership traversal. Critical (1 vote): recursive file operands remain path-based. Moderate (2 votes): root traversal mishandles -H/-L --no-dereference. Moderate (2 votes): replacement test does not exercise same-path recursive traversal.
src/uucore/locales/en-US.ftl Shared recursive permission diagnostics. No final review comments.
src/uu/chmod/src/chmod.rs Symlink-policy-aware recursive directory opening. Critical (3 votes): opened descriptor is not verified against checked metadata. Nit (1 vote): comment misstates default symlink behavior.
Review details

Suppressed comments (1)

src/uu/chmod/src/chmod.rs:655

  • The default passed to configure_symlink_and_recursion for chmod is TraverseSymlinks::First (chmod.rs:188-189), and the existing tests label bare -R as -H; -P is not the recursive default. This comment therefore misstates the symlink behavior being explained and could lead future changes to apply NoFollow to default root traversal.
        // above used: the pathname is resolved again here, so under `-P` (the `-R`
        // default) O_NOFOLLOW fails the open rather than redirecting the descent into a
        // swapped-in symlink. `-H`/`-L` still follow, which is what they ask for.
  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/uu/chmod/src/chmod.rs
// swapped-in symlink. `-H`/`-L` still follow, which is what they ask for.
if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() {
match DirFd::open(file_path, SymlinkBehavior::Follow) {
match DirFd::open(file_path, should_follow_symlink.into()) {
Comment on lines +323 to +324
let operand_fd = if meta.is_dir() {
match DirFd::open(path, SymlinkBehavior::Follow) {
Comment on lines +489 to 491
if !meta.is_dir() {
// No children to visit, matching WalkDir's min_depth(1).
return 0;
Comment on lines +1111 to +1114
// Same pathname, different object underneath: the descriptor no longer matches
// the metadata every decision was made on.
let other_fd = DirFd::open(&other, SymlinkBehavior::Follow).unwrap();
assert!(!fd_is(&other_fd, &meta).unwrap());
@codspeed-hq

codspeed-hq Bot commented Sep 9, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 13.18%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 365 untouched benchmarks
⏩ 50 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation true_consecutive_calls 350.2 ns 294.7 ns +18.85%
Simulation hostname_ip_lookup[100000] 178 µs 165.2 µs +7.78%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing sylvestre:chown-verify-operand-fd (a891e99) with main (da3bb21)

Open in CodSpeed

Footnotes

  1. 50 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/date/date-locale-hour (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/tail/inotify-dir-recreate (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/tail/pid-pipe (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/tail/tail-n0f (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/pr/bounded-memory (passes in this run but fails in the 'main' branch)
Note: The gnu test tests/rm/many-dir-entries-vs-OOM is now being skipped but was previously passing.
Note: The gnu test tests/seq/seq-epipe is now being skipped but was previously passing.
Note: The gnu test tests/env/env-signal-handler was skipped on 'main' but is now failing.

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