Summary
mv --backup=simple is supposed to refuse an operation when creating the destination's backup would overwrite the source file. The guard compares the two operands as path strings byte-for-byte instead of comparing file identity, so it does not fire when the same files are named using different spellings — a~ versus ./a, relative versus absolute, and so on. mv then destroys the source file and exits 0 with no diagnostic. GNU coreutils refuses every such case with a non-zero exit and an error.
Per SECURITY.md this is both a bypass of a documented safety guard and an unintended destructive action — a divergence from GNU where the check "fails open" instead of erroring.
Details
The guard is src/uucore/src/lib/features/backup_control.rs:485, called from src/uu/mv/src/mv.rs:368:
pub fn source_is_target_backup(source: &Path, target: &Path, suffix: &str) -> bool {
let source_filename = source.as_os_str();
let mut target_backup_filename = target.as_os_str().to_owned();
target_backup_filename.push(suffix);
source_filename == target_backup_filename
}
It appends the suffix to the destination path and compares the result to the source path as raw bytes. "a~" != "./a" + "~", so the guard is skipped even though both operands name the same files. It fires only when both operands happen to use the same spelling style:
| source |
destination |
result |
a~ |
a |
exit 1 — guard fires |
./a~ |
./a |
exit 1 — guard fires |
$PWD/a~ |
$PWD/a |
exit 1 — guard fires |
a~ |
./a |
exit 0 — data lost |
./a~ |
a |
exit 0 — data lost |
$PWD/a~ |
a |
exit 0 — data lost |
a~ |
$PWD/a |
exit 0 — data lost |
a~ |
../sub/a |
exit 0 — data lost |
The protected cases are the ones typed by hand. The unprotected ones are what scripts generate, where one path is typically a variable ($PWD, realpath, find output) and the other a literal.
Suggested fix. cp already does this correctly — src/uu/cp/src/cp.rs:2121:
let backup_path = backup_control::get_backup_path(options.backup, dest, &options.backup_suffix);
if let Some(backup_path) = backup_path {
if paths_refer_to_same_file(source, &backup_path, true) {
return Err(translate!("cp-error-backing-up-destroy-source", ...).into());
}
...
}
It computes the backup path with get_backup_path and decides with uucore::fs::paths_refer_to_same_file, so cp handles every spelling in the table.
PoC
Verified on:
- 0.9.0, release tag
840c36d (2026-05-29) — the latest release
main tip f66e155d80df3d82798324726e460675a02c5cd5 (2026-07-27) — guard source unchanged between the two
Ubuntu 24.04.2 LTS, Linux 6.8.0 x86_64, ext4. Console output captured with LANG=C.
No special configuration. Default build, no environment variables, any filesystem — the defect is pure path-string logic and so is filesystem- and platform-independent. The only requirement is --backup=simple (or -b with the default suffix) and a source named <destination><suffix>.
$ cd "$(mktemp -d)"
$ : > a # destination, empty
$ echo payload > a~ # source, holds the only copy of the data
$ mv --backup=simple a~ ./a ; echo "exit=$?"
exit=0
$ ls
a
$ cat a
payload no longer exists anywhere.
The literal spelling is correctly refused:
$ mv --backup=simple a~ a ; echo "exit=$?"
mv: backing up 'a' might destroy source; 'a~' not moved
exit=1
Both GNU coreutils 9.7 and 9.11 refuses both forms:
$ mv --backup=simple a~ ./a ; echo "exit=$?"
mv: backing up './a' might destroy source; 'a~' not moved
exit=1
Impact
For mv --backup=simple a~ ./a:
- The destination's backup is created:
rename("a", "a~") — this overwrites the source.
- The move proceeds:
rename("a~", "a") — but a~ now holds the destination's old contents.
The destination ends up unchanged, the source is gone, and the exit status is 0. The operation reports success while doing the opposite of what was requested.
Because the exit code is 0, defensive idioms provide no protection:
mv --backup=simple "$backup" "$target" || rollback # rollback never runs
set -e; mv --backup=simple "$backup" "$target" # never aborts
Concrete case:
mv --backup=simple sshd_config~ ./sshd_config
# exit 0, reported as success
# live file unchanged; the known-good copy destroyed
If the live file had been modified, the operator believes it was restored, the modified file is still in place, and the clean copy — often the only record of the pre-change state — no longer exists.
Security impact.
Recovery fails at the moment it is exercised. Restoring from a backup is what an operator reaches for after a file is tampered with or misconfigured. Here it leaves the suspect file live and consumes the known-good copy, so there is no second attempt.
Remediation automation cannot detect it. Exit 0 satisfies || rollback, set -e, and configuration-management error handling alike, so a script records a restore that never happened and carries on — discarding a staging copy, or restarting a service to pick up a configuration that was never put in place.
The pre-change artifact is destroyed. Backup files are often the only on-disk record of a file's prior contents, and the baseline an investigator would diff against after an incident.
The guard is defeated by spelling, not privilege. A path-equivalence bypass (CWE-41): two spellings of one path are not recognised as equivalent, so a check meant to prevent destruction is skipped. No special permissions, timing, or crafted input is needed.
My Acknowledgement Information
Hongkai Chen of SEFCOM Lab at Arizona State University
Summary
mv --backup=simpleis supposed to refuse an operation when creating the destination's backup would overwrite the source file. The guard compares the two operands as path strings byte-for-byte instead of comparing file identity, so it does not fire when the same files are named using different spellings —a~versus./a, relative versus absolute, and so on.mvthen destroys the source file and exits 0 with no diagnostic. GNU coreutils refuses every such case with a non-zero exit and an error.Per
SECURITY.mdthis is both a bypass of a documented safety guard and an unintended destructive action — a divergence from GNU where the check "fails open" instead of erroring.Details
The guard is
src/uucore/src/lib/features/backup_control.rs:485, called fromsrc/uu/mv/src/mv.rs:368:It appends the suffix to the destination path and compares the result to the source path as raw bytes.
"a~" != "./a" + "~", so the guard is skipped even though both operands name the same files. It fires only when both operands happen to use the same spelling style:a~a./a~./a$PWD/a~$PWD/aa~./a./a~a$PWD/a~aa~$PWD/aa~../sub/aThe protected cases are the ones typed by hand. The unprotected ones are what scripts generate, where one path is typically a variable (
$PWD,realpath,findoutput) and the other a literal.Suggested fix.
cpalready does this correctly —src/uu/cp/src/cp.rs:2121:It computes the backup path with
get_backup_pathand decides withuucore::fs::paths_refer_to_same_file, socphandles every spelling in the table.PoC
Verified on:
840c36d(2026-05-29) — the latest releasemaintipf66e155d80df3d82798324726e460675a02c5cd5(2026-07-27) — guard source unchanged between the twoUbuntu 24.04.2 LTS, Linux 6.8.0 x86_64, ext4. Console output captured with
LANG=C.No special configuration. Default build, no environment variables, any filesystem — the defect is pure path-string logic and so is filesystem- and platform-independent. The only requirement is
--backup=simple(or-bwith the default suffix) and a source named<destination><suffix>.payloadno longer exists anywhere.The literal spelling is correctly refused:
Both GNU coreutils 9.7 and 9.11 refuses both forms:
Impact
For
mv --backup=simple a~ ./a:rename("a", "a~")— this overwrites the source.rename("a~", "a")— buta~now holds the destination's old contents.The destination ends up unchanged, the source is gone, and the exit status is 0. The operation reports success while doing the opposite of what was requested.
Because the exit code is 0, defensive idioms provide no protection:
Concrete case:
If the live file had been modified, the operator believes it was restored, the modified file is still in place, and the clean copy — often the only record of the pre-change state — no longer exists.
Security impact.
Recovery fails at the moment it is exercised. Restoring from a backup is what an operator reaches for after a file is tampered with or misconfigured. Here it leaves the suspect file live and consumes the known-good copy, so there is no second attempt.
Remediation automation cannot detect it. Exit 0 satisfies
|| rollback,set -e, and configuration-management error handling alike, so a script records a restore that never happened and carries on — discarding a staging copy, or restarting a service to pick up a configuration that was never put in place.The pre-change artifact is destroyed. Backup files are often the only on-disk record of a file's prior contents, and the baseline an investigator would diff against after an incident.
The guard is defeated by spelling, not privilege. A path-equivalence bypass (CWE-41): two spellings of one path are not recognised as equivalent, so a check meant to prevent destruction is skipped. No special permissions, timing, or crafted input is needed.
My Acknowledgement Information
Hongkai Chen of SEFCOM Lab at Arizona State University