Skip to content

Commit 7e43680

Browse files
Dolphindaltmattsu2020
authored andcommitted
install: prevent TOCTOU race attack (uutils#10067)
* install: prevent TOCTOU race attack * cspell: add TOCTOU acronym to jargon word list
1 parent edf1866 commit 7e43680

File tree

5 files changed

+39
-42
lines changed

5 files changed

+39
-42
lines changed

.vscode/cspell.dictionaries/jargon.wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ inacc
184184
maint
185185
proc
186186
procs
187+
TOCTOU
187188

188189
# * constants
189190
xffff

src/uu/install/locales/en-US.ftl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ install-error-chown-failed = failed to chown { $path }: { $error }
3030
install-error-invalid-target = invalid target { $path }: No such file or directory
3131
install-error-target-not-dir = target { $path } is not a directory
3232
install-error-backup-failed = cannot backup { $from } to { $to }
33-
install-error-install-failed = cannot install { $from } to { $to }
33+
install-error-install-failed = cannot install { $from } to { $to }: { $error }
3434
install-error-strip-failed = strip program failed: { $error }
3535
install-error-strip-abnormal = strip process terminated abnormally - exit code: { $code }
3636
install-error-metadata-failed = metadata error

src/uu/install/locales/fr-FR.ftl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ install-error-chown-failed = échec du chown { $path } : { $error }
3030
install-error-invalid-target = cible invalide { $path } : Aucun fichier ou répertoire de ce type
3131
install-error-target-not-dir = la cible { $path } n'est pas un répertoire
3232
install-error-backup-failed = impossible de sauvegarder { $from } vers { $to }
33-
install-error-install-failed = impossible d'installer { $from } vers { $to }
33+
install-error-install-failed = impossible d'installer { $from } vers { $to }: { $error }
3434
install-error-strip-failed = échec du programme strip : { $error }
3535
install-error-strip-abnormal = le processus strip s'est terminé anormalement - code de sortie : { $code }
3636
install-error-metadata-failed = erreur de métadonnées

src/uu/install/src/install.rs

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use filetime::{FileTime, set_file_times};
1414
use selinux::SecurityContext;
1515
use std::ffi::OsString;
1616
use std::fmt::Debug;
17-
use std::fs::File;
1817
use std::fs::{self, metadata};
18+
use std::fs::{File, OpenOptions};
1919
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
2020
use std::process;
2121
use thiserror::Error;
@@ -36,7 +36,7 @@ use uucore::translate;
3636
use uucore::{format_usage, show, show_error, show_if_err};
3737

3838
#[cfg(unix)]
39-
use std::os::unix::fs::{FileTypeExt, MetadataExt};
39+
use std::os::unix::fs::MetadataExt;
4040
#[cfg(unix)]
4141
use std::os::unix::prelude::OsStrExt;
4242

@@ -88,8 +88,8 @@ enum InstallError {
8888
#[error("{}", translate!("install-error-backup-failed", "from" => .0.quote(), "to" => .1.quote()))]
8989
BackupFailed(PathBuf, PathBuf, #[source] std::io::Error),
9090

91-
#[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote()))]
92-
InstallFailed(PathBuf, PathBuf, #[source] std::io::Error),
91+
#[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote(), "error" => .2.clone()))]
92+
InstallFailed(PathBuf, PathBuf, String),
9393

9494
#[error("{}", translate!("install-error-strip-failed", "error" => .0.clone()))]
9595
StripProgramFailed(String),
@@ -796,22 +796,6 @@ fn perform_backup(to: &Path, b: &Behavior) -> UResult<Option<PathBuf>> {
796796
}
797797
}
798798

799-
/// Copy a non-special file using [`fs::copy`].
800-
///
801-
/// # Parameters
802-
/// * `from` - The source file path.
803-
/// * `to` - The destination file path.
804-
///
805-
/// # Returns
806-
///
807-
/// Returns an empty Result or an error in case of failure.
808-
fn copy_normal_file(from: &Path, to: &Path) -> UResult<()> {
809-
if let Err(err) = fs::copy(from, to) {
810-
return Err(InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into());
811-
}
812-
Ok(())
813-
}
814-
815799
/// Copy a file from one path to another. Handles the certain cases of special
816800
/// files (e.g character specials).
817801
///
@@ -838,8 +822,10 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> {
838822
)
839823
.into());
840824
}
841-
// fs::copy fails if destination is a invalid symlink.
842-
// so lets just remove all existing files at destination before copy.
825+
826+
// Remove existing file at destination to allow overwriting
827+
// Note: create_new() below provides TOCTOU protection; if something
828+
// appears at this path between the remove and create, it will fail safely
843829
if let Err(e) = fs::remove_file(to) {
844830
if e.kind() != std::io::ErrorKind::NotFound {
845831
show_error!(
@@ -849,25 +835,13 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> {
849835
}
850836
}
851837

852-
let ft = match metadata(from) {
853-
Ok(ft) => ft.file_type(),
854-
Err(err) => {
855-
return Err(
856-
InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into(),
857-
);
858-
}
859-
};
860-
861-
// Stream-based copying to get around the limitations of std::fs::copy
862-
#[cfg(unix)]
863-
if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() {
864-
let mut handle = File::open(from)?;
865-
let mut dest = File::create(to)?;
866-
copy_stream(&mut handle, &mut dest)?;
867-
return Ok(());
868-
}
838+
let mut handle = File::open(from)?;
839+
// create_new provides TOCTOU protection
840+
let mut dest = OpenOptions::new().write(true).create_new(true).open(to)?;
869841

870-
copy_normal_file(from, to)?;
842+
copy_stream(&mut handle, &mut dest).map_err(|err| {
843+
InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err.to_string())
844+
})?;
871845

872846
Ok(())
873847
}

tests/by-util/test_install.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2545,3 +2545,25 @@ fn test_install_unprivileged_option_u_skips_chown() {
25452545
assert!(at.file_exists(dst_ok));
25462546
assert_eq!(at.metadata(dst_ok).uid(), geteuid());
25472547
}
2548+
2549+
#[test]
2550+
fn test_install_normal_file_replaces_symlink() {
2551+
let scene = TestScenario::new(util_name!());
2552+
let at = &scene.fixtures;
2553+
2554+
at.write("source", "new content");
2555+
at.write("sensitive", "important data");
2556+
2557+
// Create symlink at destination
2558+
at.symlink_file("sensitive", "dest");
2559+
2560+
// Install should replace symlink with normal file (not follow it)
2561+
scene.ucmd().arg("source").arg("dest").succeeds();
2562+
2563+
// Verify dest is now a normal file, not a symlink
2564+
assert!(at.file_exists("dest"));
2565+
assert_eq!(at.read("dest"), "new content");
2566+
2567+
// Verify sensitive file was NOT modified
2568+
assert_eq!(at.read("sensitive"), "important data");
2569+
}

0 commit comments

Comments
 (0)