Skip to content

`mv --backup=simple` safety guard fails open when operands are spelled differently, silently destroying the source

Low
sylvestre published GHSA-mcrj-cqrc-m6rh Aug 7, 2026

Package

cargo uu_mv (Rust)

Affected versions

<= 0.9.0

Patched versions

0.10.0

Description

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:

  1. The destination's backup is created: rename("a", "a~") — this overwrites the source.
  2. 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

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Resolution of Path Equivalence

The product is vulnerable to file system contents disclosure through path equivalence. Path equivalence involves the use of special characters in file and directory names. The associated manipulations are intended to generate multiple names for the same object. Learn more on MITRE.

Protection Mechanism Failure

The product does not use or incorrectly uses a protection mechanism that provides sufficient defense against directed attacks against the product. Learn more on MITRE.

Use of Incorrectly-Resolved Name or Reference

The product uses a name or reference to access a resource, but the name/reference resolves to a resource that is outside of the intended control sphere. Learn more on MITRE.

Credits