Skip to content

chmod: fix TOCTOU race in recursive traversal#11918

Open
mattsu2020 wants to merge 21 commits intouutils:mainfrom
mattsu2020:chmod_fix
Open

chmod: fix TOCTOU race in recursive traversal#11918
mattsu2020 wants to merge 21 commits intouutils:mainfrom
mattsu2020:chmod_fix

Conversation

@mattsu2020
Copy link
Copy Markdown
Contributor

@mattsu2020 mattsu2020 commented Apr 20, 2026

Summary

  • Fix TOCTOU race in chmod -R where an attacker could replace a directory entry with a symlink between stat and chmod operations
  • Use fchmodat2 syscall (Linux 6.6+) with AT_SYMLINK_NOFOLLOW to prevent the race, with fallback to fchmodat for older kernels
  • Define SYS_fchmodat2 locally since libc doesn't expose it on all architectures (e.g. aarch64 musl)
  • Update check-safe-traversal.sh to recognize fchmodat2 as a valid safe traversal syscall

Changes

src/uucore/src/lib/features/safe_traversal.rs

  • chmod_at() now uses fchmodat2 (raw syscall 452) with AT_SYMLINK_NOFOLLOW on Linux when symlink-following is disabled, falling back to fchmodat on ENOSYS
  • Define SYS_FCHMODAT2 locally as 452 instead of relying on libc::SYS_fchmodat2 which is missing on some architectures

src/uu/chmod/src/chmod.rs

  • safe_chmod_file() now takes an explicit SymlinkBehavior parameter instead of deriving it from self.dereference
  • Always pass SymlinkBehavior::NoFollow for regular files/dirs during recursion (TOCTOU-safe: we already confirmed via stat the entry is not a symlink)
  • Pass SymlinkBehavior::Follow for file symlinks only when -L is specified

util/check-safe-traversal.sh

  • Trace fchmodat2 in strace filter for chmod tests
  • Accept either fchmodat or fchmodat2 as valid safe traversal syscalls
  • Count fchmodat2 in safe syscall statistics

Test

  • cargo check -p uucore passes
  • cargo test -p uu_chmod — all tests pass
  • cargo test -p coreutils -- chmod — all integration tests pass

Use fchmodat2 (Linux 6.6+) with AT_SYMLINK_NOFOLLOW to prevent an
attacker from replacing a directory entry with a symlink between the
stat and chmod calls. Falls back to fchmodat on older kernels.
libc 0.2.182 does not define SYS_fchmodat2 on all architectures
(e.g. aarch64 musl). Define the syscall number (452) locally since
it is part of the Linux stable ABI.
The commit fixes a formatting issue in the `safe_chmod_file` function call for file symlinks in the chmod utility. The function call was improperly formatted with incorrect line breaks and spacing, which has been corrected to improve code readability and maintain consistency with the project's formatting standards.
On Linux 6.6+, chmod_at() uses fchmodat2 instead of fchmodat when
AT_SYMLINK_NOFOLLOW is needed. Update check-safe-traversal.sh to
trace and accept fchmodat2 as a valid safe traversal syscall.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

GNU testsuite comparison:

GNU test failed: tests/tail/pid-pipe. tests/tail/pid-pipe is passing on 'main'. Maybe you have to rebase?
Skip an intermittent issue tests/cut/bounded-memory (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/date/date-locale-hour (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/symlink (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/tail/tail-n0f is now passing!

Use fchmodat2 (Linux 6.6+) with AT_SYMLINK_NOFOLLOW to prevent an
attacker from replacing a directory entry with a symlink between the
stat and chmod calls. Falls back to fchmodat on older kernels.
libc 0.2.182 does not define SYS_fchmodat2 on all architectures
(e.g. aarch64 musl). Define the syscall number (452) locally since
it is part of the Linux stable ABI.
The commit fixes a formatting issue in the `safe_chmod_file` function call for file symlinks in the chmod utility. The function call was improperly formatted with incorrect line breaks and spacing, which has been corrected to improve code readability and maintain consistency with the project's formatting standards.
On Linux 6.6+, chmod_at() uses fchmodat2 instead of fchmodat when
AT_SYMLINK_NOFOLLOW is needed. Update check-safe-traversal.sh to
trace and accept fchmodat2 as a valid safe traversal syscall.
@sylvestre
Copy link
Copy Markdown
Contributor

Thanks for the fix — TOCTOU in chmod -R is a real issue. I traced the current version and confirmed it calls bare fchmodat(dirfd, name, mode) without AT_SYMLINK_NOFOLLOW (because self.dereference defaults to true for -R), so the race is reachable. A few suggestions before merging:

1. Add a // SAFETY: comment on the syscall call

The project convention (CLAUDE.md) is explicit: "Minimal unsafe — Only for FFI calls, with // SAFETY: comments". The unsafe { libc::syscall(...) } block currently has none.

2. Hardcoded SYS_FCHMODAT2 = 452 is not universal

452 is correct on asm-generic arches (x86_64, i386, arm, arm64, riscv), but MIPS/SPARC/PowerPC/Alpha use different numbering ranges. Prefer libc::SYS_fchmodat2 where exposed, with 452 as a fallback on arches where it's missing (the PR motivation). Something like:

#[cfg(any(target_arch = "x86_64", target_arch = "x86",
          target_arch = "arm", target_arch = "aarch64",
          target_arch = "riscv64", target_arch = "riscv32"))]
const SYS_FCHMODAT2: libc::c_long = 452;

or a cfg_if! that picks libc::SYS_fchmodat2 when defined.

3. musl + kernel < 6.6 regresses

On ENOSYS, the fallback to fchmodat(NoFollow) relies on glibc's /proc/self/fd/N emulation. musl doesn't emulate this — it returns EOPNOTSUPP. On musl with old kernels the fix turns "buggy but working" into "fails outright". Worth either documenting or implementing an explicit O_PATH + /proc/self/fd fallback.

4. Cache the ENOSYS result

Right now every chmod_at call on an old kernel pays one failed fchmodat2 syscall. An AtomicBool flag (set on first ENOSYS) lets subsequent calls skip straight to fchmodat.

5. Add a Rust test that demonstrates the race is closed

Nothing currently exercises the NoFollow semantics of the new path. A minimal test:

// Create a/f (regular file) and a sentinel victim outside.
// Swap a/f for a symlink to the sentinel.
// Call DirFd::chmod_at("f", 0o777, NoFollow) and assert
// the sentinel's mode is unchanged.

Plus a direct unit test for chmod_at(link, mode, NoFollow) leaving the link's target mode untouched.

6. Residual TOCTOU in the symlink branch

handle_symlink_during_safe_recursion (chmod.rs:560-570) does fs::metadata(path) then safe_chmod_file(..., self.dereference.into()). With -L, that's still a Follow after a path-based stat. Only reachable with explicit -L, so arguably intended — but a comment stating that would be helpful.

7. Drop the unused nix::Mode round-trip on the syscall path

let mode = Mode::from_bits_truncate(mode as libc::mode_t);
// ...
mode.bits() as libc::mode_t

On the fchmodat2 branch you can pass mode as libc::mode_t directly; the nix::Mode conversion is only needed for the fchmodat fallback.


Core direction looks right. Items 1, 2, and 5 feel like blockers; the rest are improvements.

- Add // SAFETY: comment on unsafe syscall block per project convention
- Restrict fchmodat2 (syscall 452) to asm-generic architectures only
  (x86_64, x86, arm, aarch64, riscv); MIPS/SPARC/PowerPC/Alpha use
  different numbering
- Add test verifying NoFollow chmod doesn't modify symlink target
@sylvestre
Copy link
Copy Markdown
Contributor

maybe do the improvements too
thanks

- Add O_PATH + /proc/self/fd fallback for musl on kernel < 6.6
  where fchmodat returns EOPNOTSUPP for AT_SYMLINK_NOFOLLOW
- Cache ENOSYS result with AtomicBool to skip fchmodat2 on old kernels
- Remove unnecessary nix::Mode round-trip on the fchmodat2 path
- Document residual TOCTOU in handle_symlink_during_safe_recursion
  as intentional for -L mode
use std::os::unix::io::FromRawFd;

let fd = unsafe {
libc::openat(
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.

.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid proc path"))?;

Ok(())
let ret = unsafe { libc::chmod(proc_cstr.as_ptr(), mode as libc::mode_t) };
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.

Comment thread src/uucore/src/lib/features/safe_traversal.rs Outdated

openat_count=$(grep -c "openat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
unlinkat_count=$(grep -c "unlinkat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
fchmodat_count=$(grep -c "fchmodat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
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.

why remove fchmodat ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This pattern counts both together. We have expanded the fchmodat count to include fchmodat2 rather than excluding it.

Co-authored-by: oech3 <79379754+oech3@users.noreply.github.com>
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.

3 participants