Skip to content

Commit 867b6a0

Browse files
committed
mv,cp: fix xattr TOCTOU by using file descriptor-based operations
Closes: #10014
1 parent 1b4f47d commit 867b6a0

8 files changed

Lines changed: 457 additions & 46 deletions

File tree

src/uu/cp/src/cp.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::os::unix::net::UnixListener;
1616
use std::path::{Path, PathBuf, StripPrefixError};
1717
use std::{fmt, io};
1818
#[cfg(all(unix, not(target_os = "android")))]
19-
use uucore::fsxattr::{copy_xattrs, copy_xattrs_skip_selinux};
19+
use uucore::fsxattr::{copy_xattrs_fd, copy_xattrs_skip_selinux};
2020
use uucore::translate;
2121

2222
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
@@ -1715,6 +1715,8 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy
17151715
/// or if xattr copying fails.
17161716
#[cfg(all(unix, not(target_os = "android")))]
17171717
fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> {
1718+
use std::fs::File;
1719+
use uucore::fsxattr::copy_xattrs;
17181720
let metadata = fs::symlink_metadata(dest)?;
17191721

17201722
// Check if the destination file is currently read-only for the user.
@@ -1734,6 +1736,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe
17341736
// When -Z is used, skip copying security.selinux xattr so that
17351737
// the default context can be set instead of preserving from source
17361738
copy_xattrs_skip_selinux(source, dest)
1739+
} else if metadata.is_file() {
1740+
// Use file descriptor-based operations for regular files to avoid TOCTOU races.
1741+
// Directories cannot be opened with write mode for xattr operations
1742+
// Symlinks (especially dangling ones) cannot be opened via File::open
1743+
let source_file = File::open(source)?;
1744+
let dest_file = OpenOptions::new().write(true).open(dest)?;
1745+
copy_xattrs_fd(&source_file, &dest_file)
17371746
} else {
17381747
copy_xattrs(source, dest)
17391748
};
@@ -2591,7 +2600,45 @@ fn copy_file(
25912600
fs::set_permissions(dest, dest_permissions).ok();
25922601
}
25932602

2594-
let copy_attributes_result = if options.dereference(source_in_command_line) {
2603+
let copy_attributes_result = if source_is_stream && options.copy_contents {
2604+
// When copying contents from a stream (like a FIFO with --copy-contents),
2605+
// we can't re-access the source as it may block. Use the metadata we
2606+
// already have to preserve ownership and permissions.
2607+
#[cfg(unix)]
2608+
{
2609+
if let Preserve::Yes { .. } = options.attributes.ownership {
2610+
use std::os::unix::prelude::MetadataExt;
2611+
use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown};
2612+
2613+
let dest_uid = source_metadata.uid();
2614+
let dest_gid = source_metadata.gid();
2615+
if let Ok(dest_meta) = dest.symlink_metadata() {
2616+
let try_chown = |uid| {
2617+
wrap_chown(
2618+
dest,
2619+
&dest_meta,
2620+
uid,
2621+
Some(dest_gid),
2622+
false,
2623+
Verbosity {
2624+
groups_only: false,
2625+
level: VerbosityLevel::Silent,
2626+
},
2627+
)
2628+
};
2629+
// gnu compatibility: cp doesn't report an error if it fails to set the
2630+
// ownership, and will fall back to changing only the gid if possible.
2631+
if try_chown(Some(dest_uid)).is_err() {
2632+
let _ = try_chown(None);
2633+
}
2634+
}
2635+
}
2636+
if let Preserve::Yes { .. } = options.attributes.mode {
2637+
fs::set_permissions(dest, source_metadata.permissions()).ok();
2638+
}
2639+
}
2640+
Ok(())
2641+
} else if options.dereference(source_in_command_line) {
25952642
// Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories),
25962643
// fall back to the original source path
25972644
let src_for_attrs = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical)
@@ -2605,12 +2652,6 @@ fn copy_file(
26052652
false,
26062653
options.set_selinux_context,
26072654
)
2608-
} else if source_is_stream && !source.exists() {
2609-
// Some stream files may not exist after we have copied it,
2610-
// like anonymous pipes. Thus, we can't really copy its
2611-
// attributes. However, this is already handled in the stream
2612-
// copy function (see `copy_stream` under platform/linux.rs).
2613-
Ok(())
26142655
} else {
26152656
copy_attributes(
26162657
source,

src/uu/cp/src/platform/linux.rs

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66

77
use libc::{SEEK_DATA, SEEK_HOLE};
88
use std::fs::{File, OpenOptions};
9-
use std::io::Read;
9+
use std::io::{self, Read};
1010
use std::os::unix::fs::FileExt;
1111
use std::os::unix::fs::MetadataExt;
1212
use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
1313
use std::os::unix::io::AsRawFd;
1414
use std::path::Path;
1515
use uucore::buf_copy;
16-
use uucore::mode::get_umask;
1716
use uucore::translate;
1817

1918
use crate::{
@@ -50,6 +49,25 @@ enum CopyMethod {
5049
SparseCopyWithoutHole,
5150
}
5251

52+
/// Copy file contents with restrictive permissions initially to prevent race conditions.
53+
/// Creates the destination file with mode 0600 & !umask, copies the content,
54+
/// and lets the caller set the final permissions.
55+
fn copy_file_with_secure_permissions<P>(source: P, dest: P) -> io::Result<u64>
56+
where
57+
P: AsRef<Path>,
58+
{
59+
let mut src_file = File::open(&source)?;
60+
// Create destination with restrictive permissions initially (to prevent race conditions)
61+
// Mode 0o600 means read/write for owner only
62+
let mut dst_file = OpenOptions::new()
63+
.create(true)
64+
.write(true)
65+
.truncate(true)
66+
.mode(0o600)
67+
.open(&dest)?;
68+
io::copy(&mut src_file, &mut dst_file)
69+
}
70+
5371
/// Use the Linux `ioctl_ficlone` API to do a copy-on-write clone.
5472
///
5573
/// `fallback` controls what to do if the system call fails.
@@ -59,16 +77,36 @@ where
5977
P: AsRef<Path>,
6078
{
6179
let src_file = File::open(&source)?;
62-
let dst_file = File::create(&dest)?;
80+
// Create destination with restrictive permissions initially (to prevent race conditions)
81+
// Mode 0o600 means read/write for owner only
82+
let dst_file = OpenOptions::new()
83+
.create(true)
84+
.write(true)
85+
.truncate(false)
86+
.mode(0o600)
87+
.open(&dest)?;
6388
let src_fd = src_file.as_raw_fd();
6489
let dst_fd = dst_file.as_raw_fd();
6590
let result = unsafe { libc::ioctl(dst_fd, libc::FICLONE, src_fd) };
6691
if result == 0 {
6792
return Ok(());
6893
}
94+
let err = io::Error::last_os_error();
6995
match fallback {
70-
CloneFallback::Error => Err(std::io::Error::last_os_error()),
71-
CloneFallback::FSCopy => std::fs::copy(source, dest).map(|_| ()),
96+
CloneFallback::Error => {
97+
// FICLONE fails with EINVAL if the destination is not empty.
98+
// Truncate and retry once before giving up.
99+
if err.raw_os_error() == Some(libc::EINVAL) {
100+
dst_file.set_len(0)?;
101+
let retry = unsafe { libc::ioctl(dst_fd, libc::FICLONE, src_fd) };
102+
if retry == 0 {
103+
return Ok(());
104+
}
105+
return Err(io::Error::last_os_error());
106+
}
107+
Err(err)
108+
}
109+
CloneFallback::FSCopy => copy_file_with_secure_permissions(source, dest).map(|_| ()),
72110
CloneFallback::SparseCopy => sparse_copy(source, dest),
73111
CloneFallback::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
74112
}
@@ -125,7 +163,14 @@ where
125163
P: AsRef<Path>,
126164
{
127165
let src_file = File::open(source)?;
128-
let dst_file = File::create(dest)?;
166+
// Create destination with restrictive permissions initially (to prevent race conditions)
167+
// Mode 0o600 means read/write for owner only
168+
let dst_file = OpenOptions::new()
169+
.create(true)
170+
.write(true)
171+
.truncate(true)
172+
.mode(0o600)
173+
.open(dest)?;
129174
let dst_fd = dst_file.as_raw_fd();
130175

131176
let size = src_file.metadata()?.size();
@@ -175,7 +220,14 @@ where
175220
P: AsRef<Path>,
176221
{
177222
let mut src_file = File::open(source)?;
178-
let dst_file = File::create(dest)?;
223+
// Create destination with restrictive permissions initially (to prevent race conditions)
224+
// Mode 0o600 means read/write for owner only
225+
let dst_file = OpenOptions::new()
226+
.create(true)
227+
.write(true)
228+
.truncate(true)
229+
.mode(0o600)
230+
.open(dest)?;
179231
let dst_fd = dst_file.as_raw_fd();
180232

181233
let size: usize = src_file.metadata()?.size().try_into().unwrap();
@@ -237,18 +289,18 @@ where
237289
// TODO Update the code below to respect the case where
238290
// `--preserve=ownership` is not true.
239291
let mut src_file = File::open(&source)?;
240-
let mode = 0o622 & !get_umask();
292+
// Create with restrictive permissions initially to prevent race conditions
293+
// Mode 0o600 means read/write for owner only
294+
// Use truncate(true) to ensure we overwrite existing content
241295
let mut dst_file = OpenOptions::new()
242296
.create(true)
243297
.write(true)
244-
.mode(mode)
298+
.truncate(true)
299+
.mode(0o600)
245300
.open(&dest)?;
246301

247302
let dest_is_stream = is_stream(&dst_file.metadata()?);
248-
if !dest_is_stream {
249-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
250-
dst_file.set_len(0)?;
251-
}
303+
// No need to set_len(0) since we're already using truncate(true)
252304

253305
let num_bytes_copied = buf_copy::copy_stream(&mut src_file, &mut dst_file)
254306
.map_err(|e| std::io::Error::other(format!("{e}")))?;
@@ -287,7 +339,9 @@ pub(crate) fn copy_on_write(
287339
}
288340

289341
match copy_method {
290-
CopyMethod::FSCopy => std::fs::copy(source, dest).map(|_| ()),
342+
CopyMethod::FSCopy => {
343+
copy_file_with_secure_permissions(source, dest).map(|_| ())
344+
}
291345
_ => sparse_copy(source, dest),
292346
}
293347
}
@@ -303,7 +357,7 @@ pub(crate) fn copy_on_write(
303357
if let Ok(debug) = result {
304358
copy_debug = debug;
305359
}
306-
std::fs::copy(source, dest).map(|_| ())
360+
copy_file_with_secure_permissions(source, dest).map(|_| ())
307361
}
308362
}
309363
(ReflinkMode::Never, SparseMode::Auto) => {
@@ -322,7 +376,7 @@ pub(crate) fn copy_on_write(
322376

323377
match copy_method {
324378
CopyMethod::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
325-
_ => std::fs::copy(source, dest).map(|_| ()),
379+
_ => copy_file_with_secure_permissions(source, dest).map(|_| ()),
326380
}
327381
}
328382
}

src/uu/cp/src/platform/macos.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// spell-checker:ignore reflink
66
use std::ffi::CString;
77
use std::fs::{self, File, OpenOptions};
8+
use std::io;
89
use std::os::unix::ffi::OsStrExt;
910
use std::os::unix::fs::OpenOptionsExt;
1011
use std::path::Path;
@@ -13,13 +14,30 @@ use uucore::buf_copy;
1314
use uucore::display::Quotable;
1415
use uucore::translate;
1516

16-
use uucore::mode::get_umask;
17-
1817
use crate::{
1918
CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode,
2019
is_stream,
2120
};
2221

22+
/// Copy file contents with restrictive permissions initially to prevent race conditions.
23+
/// Creates the destination file with mode 0600 & !umask, copies the content,
24+
/// and lets the caller set the final permissions.
25+
fn copy_file_with_secure_permissions<P>(source: P, dest: P) -> io::Result<u64>
26+
where
27+
P: AsRef<Path>,
28+
{
29+
let mut src_file = File::open(&source)?;
30+
// Create destination with restrictive permissions initially (to prevent race conditions)
31+
// Mode 0o600 means read/write for owner only
32+
let mut dst_file = OpenOptions::new()
33+
.create(true)
34+
.write(true)
35+
.truncate(true)
36+
.mode(0o600)
37+
.open(&dest)?;
38+
io::copy(&mut src_file, &mut dst_file)
39+
}
40+
2341
/// Copies `source` to `dest` using copy-on-write if possible.
2442
pub(crate) fn copy_on_write(
2543
source: &Path,
@@ -92,24 +110,24 @@ pub(crate) fn copy_on_write(
92110
copy_debug.reflink = OffloadReflinkDebug::Yes;
93111
if source_is_stream {
94112
let mut src_file = File::open(source)?;
95-
let mode = 0o622 & !get_umask();
113+
// Create with restrictive permissions initially to prevent race conditions
114+
// Mode 0o600 means read/write for owner only
96115
let mut dst_file = OpenOptions::new()
97116
.create(true)
98117
.write(true)
99-
.mode(mode)
118+
.truncate(true)
119+
.mode(0o600)
100120
.open(dest)?;
101121

102122
let dest_is_stream = is_stream(&dst_file.metadata()?);
103-
if !dest_is_stream {
104-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
105-
dst_file.set_len(0)?;
106-
}
123+
// No need to set_len(0) since we're already using truncate(true)
107124

108125
buf_copy::copy_stream(&mut src_file, &mut dst_file)
109126
.map_err(|_| std::io::Error::from(std::io::ErrorKind::Other))
110127
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
111128
} else {
112-
fs::copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
129+
copy_file_with_secure_permissions(source, dest)
130+
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
113131
}
114132
}
115133

0 commit comments

Comments
 (0)