Skip to content

Commit 2885c47

Browse files
committed
Auto merge of #87704 - ChrisDenton:win-resolve-exe, r=yaahc
Windows: Resolve `process::Command` program without using the current directory Currently `std::process::Command` searches many directories for the executable to run, including the current directory. This has lead to a [CVE for `ripgrep`](https://cve.circl.lu/cve/CVE-2021-3013) but presumably other command line utilities could be similarly vulnerable if they run commands. This was [discussed on the internals forum](https://internals.rust-lang.org/t/std-command-resolve-to-avoid-security-issues-on-windows/14800). Also discussed was [which directories should be searched](https://internals.rust-lang.org/t/windows-where-should-command-new-look-for-executables/15015). EDIT: This PR originally removed all implicit paths. They've now been added back as laid out in the rest of this comment. ## Old Search Strategy The old search strategy is [documented here][1]. Additionally Rust adds searching the child's paths (see also #37519). So the full list of paths that were searched was: 1. The directories that are listed in the child's `PATH` environment variable. 2. The directory from which the application loaded. 3. The current directory for the parent process. 4. The 32-bit Windows system directory. 5. The 16-bit Windows system directory. 6. The Windows directory. 7. The directories that are listed in the PATH environment variable. ## New Search Strategy The new strategy removes the current directory from the searched paths. 1. The directories that are listed in the child's PATH environment variable. 2. The directory from which the application loaded. 3. The 32-bit Windows system directory. 4. The Windows directory. 5. The directories that are listed in the parent's PATH environment variable. Note that it also removes the 16-bit system directory, mostly because there isn't a function to get it. I do not anticipate this being an issue in modern Windows. ## Impact Removing the current directory should fix CVE's like the one linked above. However, it's possible some Windows users of affected Rust CLI applications have come to expect the old behaviour. This change could also affect small Windows-only script-like programs that assumed the current directory would be used. The user would need to use `.\file.exe` instead of the bare application name. This PR could break tests, especially those that test the exact output of error messages (e.g. Cargo) as this does change the error messages is some cases. [1]: https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa#parameters
2 parents 93542a8 + d9a1f9a commit 2885c47

File tree

5 files changed

+219
-32
lines changed

5 files changed

+219
-32
lines changed

library/std/src/process/tests.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -297,23 +297,23 @@ fn test_interior_nul_in_progname_is_error() {
297297

298298
#[test]
299299
fn test_interior_nul_in_arg_is_error() {
300-
match Command::new("echo").arg("has-some-\0\0s-inside").spawn() {
300+
match Command::new("rustc").arg("has-some-\0\0s-inside").spawn() {
301301
Err(e) => assert_eq!(e.kind(), ErrorKind::InvalidInput),
302302
Ok(_) => panic!(),
303303
}
304304
}
305305

306306
#[test]
307307
fn test_interior_nul_in_args_is_error() {
308-
match Command::new("echo").args(&["has-some-\0\0s-inside"]).spawn() {
308+
match Command::new("rustc").args(&["has-some-\0\0s-inside"]).spawn() {
309309
Err(e) => assert_eq!(e.kind(), ErrorKind::InvalidInput),
310310
Ok(_) => panic!(),
311311
}
312312
}
313313

314314
#[test]
315315
fn test_interior_nul_in_current_dir_is_error() {
316-
match Command::new("echo").current_dir("has-some-\0\0s-inside").spawn() {
316+
match Command::new("rustc").current_dir("has-some-\0\0s-inside").spawn() {
317317
Err(e) => assert_eq!(e.kind(), ErrorKind::InvalidInput),
318318
Ok(_) => panic!(),
319319
}

library/std/src/sys/windows/c.rs

+2
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,7 @@ if #[cfg(not(target_vendor = "uwp"))] {
734734
lpSecurityAttributes: LPSECURITY_ATTRIBUTES,
735735
) -> BOOL;
736736
pub fn SetThreadStackGuarantee(_size: *mut c_ulong) -> BOOL;
737+
pub fn GetWindowsDirectoryW(lpBuffer: LPWSTR, uSize: UINT) -> UINT;
737738
}
738739
}
739740
}
@@ -773,6 +774,7 @@ extern "system" {
773774
pub fn LeaveCriticalSection(CriticalSection: *mut CRITICAL_SECTION);
774775
pub fn DeleteCriticalSection(CriticalSection: *mut CRITICAL_SECTION);
775776

777+
pub fn GetSystemDirectoryW(lpBuffer: LPWSTR, uSize: UINT) -> UINT;
776778
pub fn RemoveDirectoryW(lpPathName: LPCWSTR) -> BOOL;
777779
pub fn SetFileAttributesW(lpFileName: LPCWSTR, dwFileAttributes: DWORD) -> BOOL;
778780
pub fn SetLastError(dwErrCode: DWORD);

library/std/src/sys/windows/path.rs

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
use super::{c, fill_utf16_buf, to_u16s};
2-
use crate::ffi::OsStr;
2+
use crate::ffi::{OsStr, OsString};
33
use crate::io;
44
use crate::mem;
5-
use crate::path::Path;
6-
use crate::path::Prefix;
5+
use crate::path::{Path, PathBuf, Prefix};
76
use crate::ptr;
87

98
#[cfg(test)]
@@ -32,6 +31,25 @@ pub fn is_verbatim_sep(b: u8) -> bool {
3231
b == b'\\'
3332
}
3433

34+
/// Returns true if `path` looks like a lone filename.
35+
pub(crate) fn is_file_name(path: &OsStr) -> bool {
36+
!path.bytes().iter().copied().any(is_sep_byte)
37+
}
38+
pub(crate) fn has_trailing_slash(path: &OsStr) -> bool {
39+
let is_verbatim = path.bytes().starts_with(br"\\?\");
40+
let is_separator = if is_verbatim { is_verbatim_sep } else { is_sep_byte };
41+
if let Some(&c) = path.bytes().last() { is_separator(c) } else { false }
42+
}
43+
44+
/// Appends a suffix to a path.
45+
///
46+
/// Can be used to append an extension without removing an existing extension.
47+
pub(crate) fn append_suffix(path: PathBuf, suffix: &OsStr) -> PathBuf {
48+
let mut path = OsString::from(path);
49+
path.push(suffix);
50+
path.into()
51+
}
52+
3553
pub fn parse_prefix(path: &OsStr) -> Option<Prefix<'_>> {
3654
use Prefix::{DeviceNS, Disk, Verbatim, VerbatimDisk, VerbatimUNC, UNC};
3755

library/std/src/sys/windows/process.rs

+141-26
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,24 @@ use crate::cmp;
77
use crate::collections::BTreeMap;
88
use crate::convert::{TryFrom, TryInto};
99
use crate::env;
10-
use crate::env::split_paths;
10+
use crate::env::consts::{EXE_EXTENSION, EXE_SUFFIX};
1111
use crate::ffi::{OsStr, OsString};
1212
use crate::fmt;
13-
use crate::fs;
1413
use crate::io::{self, Error, ErrorKind};
1514
use crate::mem;
1615
use crate::num::NonZeroI32;
17-
use crate::os::windows::ffi::OsStrExt;
16+
use crate::os::windows::ffi::{OsStrExt, OsStringExt};
1817
use crate::os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle};
19-
use crate::path::Path;
18+
use crate::path::{Path, PathBuf};
2019
use crate::ptr;
2120
use crate::sys::c;
2221
use crate::sys::c::NonZeroDWORD;
23-
use crate::sys::cvt;
2422
use crate::sys::fs::{File, OpenOptions};
2523
use crate::sys::handle::Handle;
24+
use crate::sys::path;
2625
use crate::sys::pipe::{self, AnonPipe};
2726
use crate::sys::stdio;
27+
use crate::sys::{cvt, to_u16s};
2828
use crate::sys_common::mutex::StaticMutex;
2929
use crate::sys_common::process::{CommandEnv, CommandEnvs};
3030
use crate::sys_common::{AsInner, IntoInner};
@@ -258,31 +258,19 @@ impl Command {
258258
needs_stdin: bool,
259259
) -> io::Result<(Process, StdioPipes)> {
260260
let maybe_env = self.env.capture_if_changed();
261-
// To have the spawning semantics of unix/windows stay the same, we need
262-
// to read the *child's* PATH if one is provided. See #15149 for more
263-
// details.
264-
let program = maybe_env.as_ref().and_then(|env| {
265-
if let Some(v) = env.get(&EnvKey::new("PATH")) {
266-
// Split the value and test each path to see if the
267-
// program exists.
268-
for path in split_paths(&v) {
269-
let path = path
270-
.join(self.program.to_str().unwrap())
271-
.with_extension(env::consts::EXE_EXTENSION);
272-
if fs::metadata(&path).is_ok() {
273-
return Some(path.into_os_string());
274-
}
275-
}
276-
}
277-
None
278-
});
279261

280262
let mut si = zeroed_startupinfo();
281263
si.cb = mem::size_of::<c::STARTUPINFO>() as c::DWORD;
282264
si.dwFlags = c::STARTF_USESTDHANDLES;
283265

284-
let program = program.as_ref().unwrap_or(&self.program);
285-
let mut cmd_str = make_command_line(program, &self.args, self.force_quotes_enabled)?;
266+
let child_paths = if let Some(env) = maybe_env.as_ref() {
267+
env.get(&EnvKey::new("PATH")).map(|s| s.as_os_str())
268+
} else {
269+
None
270+
};
271+
let program = resolve_exe(&self.program, child_paths)?;
272+
let mut cmd_str =
273+
make_command_line(program.as_os_str(), &self.args, self.force_quotes_enabled)?;
286274
cmd_str.push(0); // add null terminator
287275

288276
// stolen from the libuv code.
@@ -321,9 +309,10 @@ impl Command {
321309
si.hStdOutput = stdout.as_raw_handle();
322310
si.hStdError = stderr.as_raw_handle();
323311

312+
let program = to_u16s(&program)?;
324313
unsafe {
325314
cvt(c::CreateProcessW(
326-
ptr::null(),
315+
program.as_ptr(),
327316
cmd_str.as_mut_ptr(),
328317
ptr::null_mut(),
329318
ptr::null_mut(),
@@ -361,6 +350,132 @@ impl fmt::Debug for Command {
361350
}
362351
}
363352

353+
// Resolve `exe_path` to the executable name.
354+
//
355+
// * If the path is simply a file name then use the paths given by `search_paths` to find the executable.
356+
// * Otherwise use the `exe_path` as given.
357+
//
358+
// This function may also append `.exe` to the name. The rationale for doing so is as follows:
359+
//
360+
// It is a very strong convention that Windows executables have the `exe` extension.
361+
// In Rust, it is common to omit this extension.
362+
// Therefore this functions first assumes `.exe` was intended.
363+
// It falls back to the plain file name if a full path is given and the extension is omitted
364+
// or if only a file name is given and it already contains an extension.
365+
fn resolve_exe<'a>(exe_path: &'a OsStr, child_paths: Option<&OsStr>) -> io::Result<PathBuf> {
366+
// Early return if there is no filename.
367+
if exe_path.is_empty() || path::has_trailing_slash(exe_path) {
368+
return Err(io::Error::new_const(
369+
io::ErrorKind::InvalidInput,
370+
&"program path has no file name",
371+
));
372+
}
373+
// Test if the file name has the `exe` extension.
374+
// This does a case-insensitive `ends_with`.
375+
let has_exe_suffix = if exe_path.len() >= EXE_SUFFIX.len() {
376+
exe_path.bytes()[exe_path.len() - EXE_SUFFIX.len()..]
377+
.eq_ignore_ascii_case(EXE_SUFFIX.as_bytes())
378+
} else {
379+
false
380+
};
381+
382+
// If `exe_path` is an absolute path or a sub-path then don't search `PATH` for it.
383+
if !path::is_file_name(exe_path) {
384+
if has_exe_suffix {
385+
// The application name is a path to a `.exe` file.
386+
// Let `CreateProcessW` figure out if it exists or not.
387+
return Ok(exe_path.into());
388+
}
389+
let mut path = PathBuf::from(exe_path);
390+
391+
// Append `.exe` if not already there.
392+
path = path::append_suffix(path, EXE_SUFFIX.as_ref());
393+
if path.try_exists().unwrap_or(false) {
394+
return Ok(path);
395+
} else {
396+
// It's ok to use `set_extension` here because the intent is to
397+
// remove the extension that was just added.
398+
path.set_extension("");
399+
return Ok(path);
400+
}
401+
} else {
402+
ensure_no_nuls(exe_path)?;
403+
// From the `CreateProcessW` docs:
404+
// > If the file name does not contain an extension, .exe is appended.
405+
// Note that this rule only applies when searching paths.
406+
let has_extension = exe_path.bytes().contains(&b'.');
407+
408+
// Search the directories given by `search_paths`.
409+
let result = search_paths(child_paths, |mut path| {
410+
path.push(&exe_path);
411+
if !has_extension {
412+
path.set_extension(EXE_EXTENSION);
413+
}
414+
if let Ok(true) = path.try_exists() { Some(path) } else { None }
415+
});
416+
if let Some(path) = result {
417+
return Ok(path);
418+
}
419+
}
420+
// If we get here then the executable cannot be found.
421+
Err(io::Error::new_const(io::ErrorKind::NotFound, &"program not found"))
422+
}
423+
424+
// Calls `f` for every path that should be used to find an executable.
425+
// Returns once `f` returns the path to an executable or all paths have been searched.
426+
fn search_paths<F>(child_paths: Option<&OsStr>, mut f: F) -> Option<PathBuf>
427+
where
428+
F: FnMut(PathBuf) -> Option<PathBuf>,
429+
{
430+
// 1. Child paths
431+
// This is for consistency with Rust's historic behaviour.
432+
if let Some(paths) = child_paths {
433+
for path in env::split_paths(paths).filter(|p| !p.as_os_str().is_empty()) {
434+
if let Some(path) = f(path) {
435+
return Some(path);
436+
}
437+
}
438+
}
439+
440+
// 2. Application path
441+
if let Ok(mut app_path) = env::current_exe() {
442+
app_path.pop();
443+
if let Some(path) = f(app_path) {
444+
return Some(path);
445+
}
446+
}
447+
448+
// 3 & 4. System paths
449+
// SAFETY: This uses `fill_utf16_buf` to safely call the OS functions.
450+
unsafe {
451+
if let Ok(Some(path)) = super::fill_utf16_buf(
452+
|buf, size| c::GetSystemDirectoryW(buf, size),
453+
|buf| f(PathBuf::from(OsString::from_wide(buf))),
454+
) {
455+
return Some(path);
456+
}
457+
#[cfg(not(target_vendor = "uwp"))]
458+
{
459+
if let Ok(Some(path)) = super::fill_utf16_buf(
460+
|buf, size| c::GetWindowsDirectoryW(buf, size),
461+
|buf| f(PathBuf::from(OsString::from_wide(buf))),
462+
) {
463+
return Some(path);
464+
}
465+
}
466+
}
467+
468+
// 5. Parent paths
469+
if let Some(parent_paths) = env::var_os("PATH") {
470+
for path in env::split_paths(&parent_paths).filter(|p| !p.as_os_str().is_empty()) {
471+
if let Some(path) = f(path) {
472+
return Some(path);
473+
}
474+
}
475+
}
476+
None
477+
}
478+
364479
impl Stdio {
365480
fn to_handle(&self, stdio_id: c::DWORD, pipe: &mut Option<AnonPipe>) -> io::Result<Handle> {
366481
match *self {

library/std/src/sys/windows/process/tests.rs

+52
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,55 @@ fn windows_env_unicode_case() {
128128
}
129129
}
130130
}
131+
132+
// UWP applications run in a restricted environment which means this test may not work.
133+
#[cfg(not(target_vendor = "uwp"))]
134+
#[test]
135+
fn windows_exe_resolver() {
136+
use super::resolve_exe;
137+
use crate::io;
138+
139+
// Test a full path, with and without the `exe` extension.
140+
let mut current_exe = env::current_exe().unwrap();
141+
assert!(resolve_exe(current_exe.as_ref(), None).is_ok());
142+
current_exe.set_extension("");
143+
assert!(resolve_exe(current_exe.as_ref(), None).is_ok());
144+
145+
// Test lone file names.
146+
assert!(resolve_exe(OsStr::new("cmd"), None).is_ok());
147+
assert!(resolve_exe(OsStr::new("cmd.exe"), None).is_ok());
148+
assert!(resolve_exe(OsStr::new("cmd.EXE"), None).is_ok());
149+
assert!(resolve_exe(OsStr::new("fc"), None).is_ok());
150+
151+
// Invalid file names should return InvalidInput.
152+
assert_eq!(resolve_exe(OsStr::new(""), None).unwrap_err().kind(), io::ErrorKind::InvalidInput);
153+
assert_eq!(
154+
resolve_exe(OsStr::new("\0"), None).unwrap_err().kind(),
155+
io::ErrorKind::InvalidInput
156+
);
157+
// Trailing slash, therefore there's no file name component.
158+
assert_eq!(
159+
resolve_exe(OsStr::new(r"C:\Path\to\"), None).unwrap_err().kind(),
160+
io::ErrorKind::InvalidInput
161+
);
162+
163+
/*
164+
Some of the following tests may need to be changed if you are deliberately
165+
changing the behaviour of `resolve_exe`.
166+
*/
167+
168+
let paths = env::var_os("PATH").unwrap();
169+
env::set_var("PATH", "");
170+
171+
assert_eq!(resolve_exe(OsStr::new("rustc"), None).unwrap_err().kind(), io::ErrorKind::NotFound);
172+
173+
let child_paths = Some(paths.as_os_str());
174+
assert!(resolve_exe(OsStr::new("rustc"), child_paths).is_ok());
175+
176+
// The resolver looks in system directories even when `PATH` is empty.
177+
assert!(resolve_exe(OsStr::new("cmd.exe"), None).is_ok());
178+
179+
// The application's directory is also searched.
180+
let current_exe = env::current_exe().unwrap();
181+
assert!(resolve_exe(current_exe.file_name().unwrap().as_ref(), None).is_ok());
182+
}

0 commit comments

Comments
 (0)