Skip to content

Commit 7247a8b

Browse files
authored
Merge pull request from GHSA-mc8h-8q98-g5hr
* Fix TOCTOU races This involves a new safe API that works from a file descriptor rather than a path. The existing APIs are preserved but no longer recommended. * Bump version to indicate API breaks The crate features have changed, in particular parallel is now not defaulted-on. * Rearrange changelog
1 parent be7a410 commit 7247a8b

13 files changed

+1052
-466
lines changed

CHANGELOG.md

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,68 @@
1-
# unreleased
1+
# 0.8.0
22

3-
- Added feature to enable use of rayon.
4-
- Migrated from winapi to windows-sys
3+
## Security changes
4+
5+
- Fix TOCTOU race conditions both inside the implementation of functions and the
6+
contract: functions now only operate on directories. Callers wanting to
7+
process the contents of a symlink (e.g. for remove_dir_contents) should
8+
resolve the symlink themselves. This is an API break from 0.7.0, but the previous behaviour was insecure.
9+
10+
This is due to the same code pattern as caused CVE-2022-21658 in Rust itself:
11+
it was possible to trick a privileged process doing a recursive delete in an
12+
attacker controlled directory into deleting privileged files, on all operating
13+
systems.
14+
15+
For instance, consider deleting a tree called 'etc' in a parent directory
16+
called 'p'. Between calling `remove_dir_all("a")` and remove_dir_all("a")
17+
actually starting its work, the attacker can move 'p' to 'p-prime', and
18+
replace 'p' with a symlink to '/'. Then the privileged process deletes 'p/etc'
19+
which is actually /etc, and now your system is broken. There are some
20+
mitigations for this exact scenario, such as CWD relative file lookup, but
21+
they are not guaranteed - any code using absolute paths will not have that
22+
protection in place.
23+
24+
The same attack could be performed at any point in the directory tree being
25+
deleted: if 'a' contains a child directory called 'etc', attacking the
26+
deletion by replacing 'a' with a link is possible.
27+
28+
The new code in this release mitigates the attack within the directory tree
29+
being deleted by using file-handle relative operations: to open 'a/etc', the
30+
path 'etc' relative to 'a' is opened, where 'a' is represented by a file
31+
descriptor (Unix) or handle (Windows). With the exception of the entry points
32+
into the directory deletion logic, this is robust against manipulation of the
33+
directory hierarchy, and remove_dir_all will only delete files and directories
34+
contained in the tree it is deleting.
35+
36+
The entry path however is a challenge - as described above, there are some
37+
potential mitigations, but since using them must be done by the calling code,
38+
it is hard to be confident about the security properties of the path based
39+
interface.
40+
41+
The new extension trait `RemoveDir` provides an interface where it is much
42+
harder to get it wrong.
43+
44+
`somedir.remove_dir_contents("name-of-child")`.
45+
46+
Callers can then make their own security evaluation about how to securely get
47+
a directory handle. That is still not particularly obvious, and we're going to
48+
follow up with a helper of some sort (probably in the `fs_at` crate). Once
49+
that is available, the path based entry points will get deprecated.
50+
51+
In the interim, processes that might run with elevated privileges should
52+
figure out how to securely identify the directory they are going to delete, to
53+
avoid the initial race. Pragmatically, other processes should be fine with the
54+
path based entry points : this is the same interface `std::fs::remove_dir_all`
55+
offers, and an unprivileged process running in an attacker controlled
56+
directory can't do anything that the attacker can't already do.
57+
58+
tl;dr: state shared with threat actors makes things dangerous; library
59+
functions cannot assume anything about the particular threat model of a
60+
program and must err on the side of caution.
61+
62+
## Other changes
63+
64+
- Made feature to control use of rayon off-by-default for easier integration by
65+
other crates.
566

667
# 0.5.2
768

Cargo.toml

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,59 @@
11
[package]
22
authors = [
3-
"Erin P. <[email protected]>",
4-
"Robert C. <[email protected]>",
3+
"Erin P. <[email protected]>",
4+
"Robert C. <[email protected]>",
55
]
66
categories = ["filesystem"]
77
description = "A safe, reliable implementation of remove_dir_all for Windows"
88
edition = "2018"
99
include = [
10-
"Cargo.toml",
11-
"LICENCE-APACHE",
12-
"LICENCE-MIT",
13-
"src/**/*",
14-
"README.md",
10+
"Cargo.toml",
11+
"LICENCE-APACHE",
12+
"LICENCE-MIT",
13+
"src/**/*",
14+
"README.md",
1515
]
1616
keywords = ["utility", "filesystem", "remove_dir", "windows"]
1717
license = "MIT OR Apache-2.0"
1818
name = "remove_dir_all"
1919
readme = "README.md"
2020
repository = "https://github.com/XAMPPRocky/remove_dir_all.git"
21-
version = "0.7.1-alpha.0"
21+
version = "0.8.0-alpha.0"
2222

2323
[features]
24-
default = ["parallel"]
25-
parallel = ["rayon"]
24+
default = []
25+
log = ["dep:log"]
26+
parallel = ["dep:rayon"]
2627

2728
[dependencies]
2829
cfg-if = "1.0.0"
30+
fs_at = { version = "0.1" }
31+
lazy_static = "1.4.0"
32+
log = { version = "0.4.11", optional = true }
33+
normpath = "1.0.1"
34+
rayon = { version = "1.4", optional = true }
2935

3036
[target.'cfg(windows)'.dependencies]
31-
log = "0.4.11"
32-
rayon = { version = "1.4", optional = true }
37+
aligned = "0.4.1"
3338

3439
[target.'cfg(windows)'.dependencies.windows-sys]
35-
version = "0.45.0"
3640
features = [
3741
"Win32_Foundation",
3842
"Win32_Storage_FileSystem",
43+
"Win32_System_IO",
44+
"Win32_System_Ioctl",
3945
"Win32_System_SystemServices",
46+
"Win32_System_Threading",
4047
]
48+
version = "0.45.0"
4149

4250
[target.'cfg(not(windows))'.dependencies]
4351
libc = "0.2"
52+
cvt = "0.1.1"
4453

4554
[dev-dependencies]
4655
doc-comment = "0.3"
47-
env_logger = "0.9.0"
56+
env_logger = "0.10.0"
4857
tempfile = "3.1"
58+
test-log = "0.2"
59+
log = "0.4.11"

src/_impl.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use std::{
2+
ffi::OsStr,
3+
fs::File,
4+
io::{ErrorKind, Result},
5+
path::Path,
6+
};
7+
8+
#[cfg(feature = "parallel")]
9+
use rayon::prelude::*;
10+
11+
mod io;
12+
mod path_components;
13+
14+
cfg_if::cfg_if! {
15+
if #[cfg(windows)] {
16+
mod win;
17+
pub(crate) use win::WindowsIo as OsIo;
18+
} else {
19+
mod unix;
20+
pub(crate) use unix::UnixIo as OsIo;
21+
}
22+
}
23+
24+
impl super::RemoveDir for std::fs::File {
25+
fn remove_dir_contents(&mut self, debug_root: Option<&Path>) -> Result<()> {
26+
// thunk over to the free version adding in the os-specific IO trait impl
27+
let debug_root = match debug_root {
28+
None => PathComponents::Path(Path::new("")),
29+
Some(debug_root) => PathComponents::Path(debug_root),
30+
};
31+
_remove_dir_contents::<OsIo>(self, &debug_root)
32+
}
33+
}
34+
35+
/// Entry point for deprecated function
36+
pub(crate) fn _ensure_empty_dir_path<I: io::Io, P: AsRef<Path>>(path: P) -> Result<()> {
37+
// This is as TOCTOU safe as we can make it. Attacks via link replacements
38+
// in interior components of the path is still possible. if the create
39+
// succeeds, mission accomplished. if the create fails, open the dir
40+
// (subject to races again), and then proceed to delete the contents via the
41+
// descriptor.
42+
match std::fs::create_dir(&path) {
43+
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
44+
// Exists and is a dir. Open it
45+
let mut existing_dir = I::open_dir(path.as_ref())?;
46+
existing_dir.remove_dir_contents(Some(path.as_ref()))
47+
}
48+
otherwise => otherwise,
49+
}
50+
}
51+
52+
// Deprecated entry point
53+
pub(crate) fn _remove_dir_contents_path<I: io::Io, P: AsRef<Path>>(path: P) -> Result<()> {
54+
let mut d = I::open_dir(path.as_ref())?;
55+
_remove_dir_contents::<I>(&mut d, &PathComponents::Path(path.as_ref()))
56+
}
57+
58+
/// exterior lifetime interface to dir removal
59+
fn _remove_dir_contents<I: io::Io>(d: &mut File, debug_root: &PathComponents<'_>) -> Result<()> {
60+
let owned_handle = I::duplicate_fd(d)?;
61+
remove_dir_contents_recursive::<I>(owned_handle, debug_root)
62+
}
63+
64+
/// deprecated interface
65+
pub(crate) fn remove_dir_all_path<I: io::Io, P: AsRef<Path>>(path: P) -> Result<()> {
66+
let p = path.as_ref();
67+
// Opportunity 1 for races
68+
let d = I::open_dir(p)?;
69+
let debug_root = PathComponents::Path(if p.has_root() { p } else { Path::new(".") });
70+
remove_dir_contents_recursive::<OsIo>(d, &debug_root)?;
71+
// Opportunity 2 for races
72+
std::fs::remove_dir(&path)
73+
}
74+
75+
use crate::RemoveDir;
76+
77+
use self::path_components::PathComponents;
78+
79+
// Core workhorse, heading towards this being able to be tasks.
80+
#[allow(clippy::map_identity)]
81+
fn remove_dir_contents_recursive<I: io::Io>(
82+
mut d: File,
83+
debug_root: &PathComponents<'_>,
84+
) -> Result<()> {
85+
#[cfg(feature = "log")]
86+
log::trace!("scanning {}", &debug_root);
87+
// We take a os level clone of the FD so that there are no lifetime
88+
// concerns. It would *not* be ok to do readdir on one file twice
89+
// concurrently because of shared kernel state.
90+
let dirfd = I::duplicate_fd(&mut d)?;
91+
cfg_if::cfg_if! {
92+
if #[cfg(feature = "parallel")] {
93+
let iter = fs_at::read_dir(&mut d)?;
94+
let iter = iter.par_bridge();
95+
} else {
96+
let mut iter = fs_at::read_dir(&mut d)?;
97+
}
98+
}
99+
100+
iter.try_for_each(|dir_entry| -> Result<()> {
101+
let dir_entry = dir_entry?;
102+
let name = dir_entry.name();
103+
if name == OsStr::new(".") || name == OsStr::new("..") {
104+
return Ok(());
105+
}
106+
let dir_path = Path::new(name);
107+
let dir_debug_root = PathComponents::Component(debug_root, dir_path);
108+
// Windows optimised: open everything always, which is not bad for
109+
// linux, and portable to OS's and FS's that don't expose inode type in
110+
// the readdir entries.
111+
112+
let mut opts = fs_at::OpenOptions::default();
113+
opts.read(true)
114+
.write(fs_at::OpenOptionsWriteMode::Write)
115+
.follow(false);
116+
117+
let child_result = opts.open_dir_at(&dirfd, name);
118+
let is_dir = match child_result {
119+
Err(e) if !I::is_eloop(&e) => return Err(e),
120+
Err(_) => false,
121+
Ok(child_file) => {
122+
let metadata = child_file.metadata()?;
123+
let is_dir = metadata.is_dir();
124+
I::clear_readonly(&child_file, &dir_debug_root, &metadata)?;
125+
126+
if is_dir {
127+
remove_dir_contents_recursive::<I>(child_file, &dir_debug_root)?;
128+
#[cfg(feature = "log")]
129+
log::trace!("rmdir: {}", &dir_debug_root);
130+
let opts = fs_at::OpenOptions::default();
131+
opts.rmdir_at(&dirfd, name).map_err(|e| {
132+
#[cfg(feature = "log")]
133+
log::debug!("error removing {}", dir_debug_root);
134+
e
135+
})?;
136+
}
137+
is_dir
138+
}
139+
};
140+
if !is_dir {
141+
#[cfg(feature = "log")]
142+
log::trace!("unlink: {}", &dir_debug_root);
143+
opts.unlink_at(&dirfd, name).map_err(|e| {
144+
#[cfg(feature = "log")]
145+
log::debug!("error removing {}", dir_debug_root);
146+
e
147+
})?;
148+
}
149+
150+
#[cfg(feature = "log")]
151+
log::trace!("removed {}", dir_debug_root);
152+
Ok(())
153+
})?;
154+
#[cfg(feature = "log")]
155+
log::trace!("scanned {}", &debug_root);
156+
Ok(())
157+
}

src/_impl/io.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! Private trait to deal with OS variance
2+
3+
use std::{
4+
fmt::Debug,
5+
fs::{File, Metadata},
6+
io,
7+
path::Path,
8+
};
9+
10+
use super::path_components::PathComponents;
11+
12+
pub(crate) trait Io {
13+
type UniqueIdentifier: PartialEq + Debug;
14+
15+
fn duplicate_fd(f: &mut File) -> io::Result<File>;
16+
17+
fn open_dir(p: &Path) -> io::Result<File>;
18+
fn unique_identifier(d: &File) -> io::Result<Self::UniqueIdentifier>;
19+
20+
fn clear_readonly(
21+
f: &File,
22+
dir_debug_root: &'_ PathComponents<'_>,
23+
metadata: &Metadata,
24+
) -> io::Result<()>;
25+
26+
fn is_eloop(e: &io::Error) -> bool;
27+
}

src/_impl/path_components.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use std::{fmt::Display, path::Path};
2+
3+
/// Print a path that is broken into segments.
4+
// explicitly typed to avoid type recursion. 'a is the smallest lifetime present
5+
// : that of the child.
6+
pub(crate) enum PathComponents<'a> {
7+
Path(&'a Path),
8+
Component(&'a PathComponents<'a>, &'a Path),
9+
}
10+
11+
impl<'p> Display for PathComponents<'p> {
12+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13+
match self {
14+
PathComponents::Path(p) => p.display().fmt(f),
15+
PathComponents::Component(p, c) => {
16+
p.fmt(f)?;
17+
f.write_str("/")?;
18+
c.display().fmt(f)
19+
}
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)