Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/common/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,35 @@ fn get_canonical_util_name(util_name: &str) -> &str {

/// Gets the binary path from command line arguments
/// Panics if the binary path cannot be determined
#[cfg(any(
not(any(
target_os = "linux",
all(target_os = "android", target_pointer_width = "64")
)),
target_env = "musl"
#[cfg(all(
not(windows),
any(
not(any(
target_os = "linux",
all(target_os = "android", target_pointer_width = "64")
)),
target_env = "musl"
)
))]
pub fn binary_path(args: &mut impl Iterator<Item = OsString>) -> PathBuf {
PathBuf::from(args.next().unwrap())
// no fallback for empty args. current_exe() (/proc/self/exe) is valid only for hardlinks
}

/// Gets the path of the running executable instead of `argv[0]`.
///
/// cmd.exe passes `argv[0]` the way it was typed, so `False` would not match
/// `false.exe`, while this function has the name as found on disk without resolving symlink,
/// so links named after a utility keep working. A path given explicitly in another case
/// keeps that spelling.
Comment on lines +119 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 problems with this:

If your basename is on disk as BaseName.exe, this will fail to match any commands, because GetModuleFileNameW returns BaseName.exe and not basename.exe.

More importantly, the comment is incorrect. GetModuleFileNameW actually returns the value stored in the PE loader and that can be whatever. Technically, it can be truly whatever, but in practice it's whatever you passed to CreateProcess as lpApplicationName.

For instance, this is an entirely feasible way to use coreutils:

subprocess.run(
    ["basename.exe", "a/b"],
    executable="coreutils.exe",
)

This PR breaks name lookup, because GetModuleFileNameW returns coreutils.exe.


The correct solution is to simply do lowercase hashing which can be trivially implemented. But I would personally be opposed to this. Coreutils aren't just some random Windows tools, and I think it's in the best interest of everyone if they get their (if I may) damn path casing correct. 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is expected because we don't consider BaseName as valid name.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, we already use AT_EXECFN to reject hijacked argv[0] . Is lpApplicationName commonly used?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is expected because we don't consider BaseName as valid name.

I thought this is about insensitive filesystem support? In that frame of mind, storing a BaseName.exe on disk would be equivalent to basename.exe, would it not?

By the way, we already use AT_EXECFN to reject hijacked argv[0] . Is lpApplicationName commonly used?

Huh. Yeah, there are actually THREE different issues with the comment. 😄

It is common. In fact, it's the only reason this PR even seems like it would work: CMD sets it to the normalized file path (and so do other Windows launchers).

If you call CreateProcess("BaseName.exe") naively, then GetModuleFileNameW simply returns whatever is in argv[0] that matched a path on disk. It's not normalized.

So, actually this PR only fixes CMD (and some others), but then also breaks the inverse scenario, creating problems elsewhere.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my thought, user should symlink/hardlink as basename.exe, but it can be called as BaseName.

Is there any API doing same things with AT_EXECFN? If not, I don't agree with this change too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the current version might fit that: as lhecker said, GetModuleFileNameW is the closest thing to AT_EXECFN, so dispatching on it is the same kind of protection as on Linux. lpApplicationName / executable= with a different name gets rejected the same way exec -a is there, and cmd's casing working is only a side effect. I'd fix the comment, lhecker is right that it isn't the name on disk.

@lhecker does that framing work for you, or is there a reason Windows should keep trusting argv[0]?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said, I'm not really familiar with AT_EXECFN and what it's supposed to do here. If I understood the first 2 paragraphs of #15785 correctly (it's just way way way too long to read in full), I think it's for ensuring that a multicall ls.exe cannot be invoked as echo.exe by manipulating argv[0]. I'm not sure how valuable that is on Windows, since AppArmor does not exist like that and I suspect the Windows equivalents don't care about lpApplicationName vs lpCommandLine anyway.

However, if we want to fix that, GetModuleFileNameW would be a good replacement for argv[0]. It would not fix the case-sensitivity issue, however.

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lpApplicationName is a place to set the binary we want to exec. So lpApplicationName=coreutils.exe is invalid if we installed individual binaries on the system. So I think we should specify lpApplicationName=/path/to/hardlink and using GetModuleFileNameW is making a sense.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me: with individual binaries installed, lpApplicationName=coreutils.exe plus a basename.exe argv[0] is the same thing exec -a does on Linux, which we already reject. If @lhecker is fine with that use being rejected, I can reopen this framed as AT_EXECFN parity on Windows, with the comment fixed and no claim about case-insensitivity.

/// Panics if the binary path cannot be determined
#[cfg(windows)]
pub fn binary_path(args: &mut impl Iterator<Item = OsString>) -> PathBuf {
// argv[0] is always skipped, the caller reads the arguments after it.
let argv0 = args.next();
// Backend is `GetModuleFileNameW` which does not resolve symlink.
std::env::current_exe().unwrap_or_else(|_| argv0.unwrap().into())
}
/// Get actual binary path from kernel, not argv0, to prevent `env -a` from bypassing
/// AppArmor, SELinux policies on hard-linked binaries
#[cfg(all(
Expand Down
43 changes: 43 additions & 0 deletions tests/test_util_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,49 @@ fn util_name_single() {
)));
}

#[rstest::rstest]
#[case::util("basename.exe", &["BASENAME", "a/b"], false)]
#[case::multicall("coreutils.exe", &["COREUTILS", "basename", "a/b"], false)]
// Fails if `current_exe()` starts resolving symlinks and returns coreutils.exe.
#[case::symlink("basename.exe", &["BASENAME", "a/b"], true)]
#[cfg(all(windows, feature = "basename"))]
fn util_name_is_the_name_on_disk_on_windows(
#[case] name: &str,
#[case] typed: &[&str],
#[case] symlink: bool,
) {
use std::process::Command;

let scenario = TestScenario::new("basename");
let path = scenario.fixtures.plus(name);
if symlink {
// Needs Developer Mode or admin rights locally. CI has them, so it must not skip there.
if let Err(e) = std::os::windows::fs::symlink_file(&scenario.bin_path, path) {
assert!(!uutests::util::is_ci(), "cannot create a symlink: {e}");
print!("Test skipped; cannot create a symlink: {e}");
return;
}
} else {
// A copy, since hard links can't cross drives.
std::fs::copy(&scenario.bin_path, path).unwrap();
}
// cmd.exe finds `basename.exe` for `BASENAME`, but passes argv[0] as typed.
// Its PATH only holds that file, so it can't run a standalone `basename.exe`.
let cmd = env::var_os("ComSpec").unwrap_or_else(|| "cmd.exe".into());
let output = Command::new(cmd)
.arg("/C")
.args(typed)
.env("PATH", &scenario.fixtures.subdir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, b"b\n");
}

#[test]
#[cfg(unix)]
fn util_invalid_name_help() {
Expand Down
Loading