diff --git a/Cargo.lock b/Cargo.lock index aa88bc7db..93e1a6837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7212,18 +7212,6 @@ dependencies = [ "realfft", ] -[[package]] -name = "runas" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96d6b6c505282b007a9b009f2aa38b2fd0359b81a0430ceacc60f69ade4c6a0" -dependencies = [ - "libc", - "security-framework-sys", - "which", - "windows-sys 0.48.0", -] - [[package]] name = "rust-ini" version = "0.18.0" @@ -7342,7 +7330,6 @@ dependencies = [ "reqwest", "ringbuf", "rubato", - "runas", "rust-pulsectl", "samplerate", "sciter-rs", diff --git a/Cargo.toml b/Cargo.toml index d2f85f6f9..a7b2aca77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,14 +124,18 @@ windows = { version = "0.61", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System", + "Win32_System_Com", "Win32_System_Diagnostics", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", + "Win32_System_Registry", + "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", ] } winreg = "0.11" windows-service = "0.6" @@ -140,7 +144,6 @@ remote_printer = { path = "libs/remote_printer" } impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" } shared_memory = "0.12" tauri-winrt-notification = "0.1" -runas = "1.2" [target.'cfg(target_os = "macos")'.dependencies] objc = "0.2" diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 98c4da89a..5253895dd 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -98,12 +98,19 @@ use windows_service::{ use winreg::{enums::*, RegKey}; mod acl; +mod installer_handoff; +mod installer_shell; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ set_path_permission, set_path_permission_for_portable_service_shmem_dir, set_path_permission_for_portable_service_shmem_file, validate_path_for_portable_service_shmem_dir, }; +use installer_handoff::run_cmds; +use installer_shell::{ + embedded_shortcut_commands, embedded_tray_shortcut_commands, escape_nested_cmd_ampersands, + shortcut_bytes, validate_install_value, +}; pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; @@ -113,6 +120,17 @@ const REG_NAME_INSTALL_DESKTOPSHORTCUTS: &str = "DESKTOPSHORTCUTS"; const REG_NAME_INSTALL_STARTMENUSHORTCUTS: &str = "STARTMENUSHORTCUTS"; pub const REG_NAME_INSTALL_PRINTER: &str = "PRINTER"; +fn validate_install_app_name(app_name: &str) -> ResultType<()> { + if app_name.is_empty() + || !app_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + bail!("Application name must match [a-zA-Z0-9-]+"); + } + Ok(()) +} + pub fn get_focused_display(displays: Vec) -> Option { unsafe { let hwnd = GetForegroundWindow(); @@ -1499,6 +1517,7 @@ fn get_after_install( ) -> String { let app_name = crate::get_app_name(); let ext = app_name.to_lowercase(); + let nested_exe = escape_nested_cmd_ampersands(exe); // reg delete HKEY_CURRENT_USER\Software\Classes for // https://github.com/rustdesk/rustdesk/commit/f4bdfb6936ae4804fc8ab1cf560db192622ad01a @@ -1534,17 +1553,17 @@ fn get_after_install( {start_menu_shortcuts} {reg_printer} reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f - reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f /ve /t REG_SZ /d \"\\\"{exe}\\\",0\" + reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\",0\" reg add HKEY_CLASSES_ROOT\\.{ext}\\shell /f reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open /f reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f - reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{exe}\\\" --play \\\"%%1\\\"\" + reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\" --play \\\"%%1\\\"\" reg add HKEY_CLASSES_ROOT\\{ext} /f reg add HKEY_CLASSES_ROOT\\{ext} /f /v \"URL Protocol\" /t REG_SZ /d \"\" reg add HKEY_CLASSES_ROOT\\{ext}\\shell /f reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open /f reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f - reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{exe}\\\" \\\"%%1\\\"\" + reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\" \\\"%%1\\\"\" netsh advfirewall firewall add rule name=\"{app_name} Service\" dir=out action=allow program=\"{exe}\" enable=yes netsh advfirewall firewall add rule name=\"{app_name} Service\" dir=in action=allow program=\"{exe}\" enable=yes {create_service} @@ -1578,48 +1597,38 @@ pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> Res let app_name = crate::get_app_name(); let current_exe = std::env::current_exe()?; - - let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); - let cur_exe = current_exe.to_str().unwrap_or("").to_owned(); - let shortcut_icon_location = get_shortcut_icon_location(&path, &cur_exe); - let mk_shortcut = write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\{app_name}.lnk\" - -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - {shortcut_icon_location} -oLink.Save - " - ), - "vbs", + let cur_exe = current_exe + .to_str() + .ok_or_else(|| anyhow!("Current executable path is not valid Unicode"))? + .to_owned(); + for value in [&path, &exe, &cur_exe] { + validate_install_value(value)?; + } + let config_path = Config::file(); + validate_install_value( + config_path + .to_str() + .ok_or_else(|| anyhow!("Configuration path is not valid Unicode"))?, + )?; + let shortcut_icon_location = get_custom_icon(&path, &cur_exe); + if let Some(icon) = shortcut_icon_location.as_deref() { + validate_install_value(icon)?; + } + // The elevated runner expands this to `%~f0.dir`, beside its protected copy. + // Do not stage privileged shortcut artifacts in the user-writable `%TEMP%`. + let tmp_path = "%RUSTDESK_OUTPUT_DIR%".to_owned(); + let mk_shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(&exe, None, shortcut_icon_location.as_deref())?, + &format!("{app_name}.lnk"), "mk_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned(); - // https://superuser.com/questions/392061/how-to-make-a-shortcut-from-cmd - let uninstall_shortcut = write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\Uninstall {app_name}.lnk\" -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - oLink.Arguments = \"--uninstall\" - oLink.IconLocation = \"msiexec.exe\" -oLink.Save - " - ), - "vbs", + ); + let uninstall_shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(&exe, Some("--uninstall"), Some("msiexec.exe"))?, + &format!("Uninstall {app_name}.lnk"), "uninstall_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned(); - let tray_shortcut = get_tray_shortcut(&path, &exe, &cur_exe, &tmp_path)?; + ); + let tray_shortcut_commands = + embedded_tray_shortcut_commands(&app_name, &exe, shortcut_icon_location.as_deref())?; let mut reg_value_desktop_shortcuts = "0".to_owned(); let mut reg_value_start_menu_shortcuts = "0".to_owned(); let mut reg_value_printer = "0".to_owned(); @@ -1660,15 +1669,12 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{start_menu}\\\" // Note: without if exist, the bat may exit in advance on some Windows7 https://github.com/rustdesk/rustdesk/issues/895 let dels = format!( " -if exist \"{mk_shortcut}\" del /f /q \"{mk_shortcut}\" -if exist \"{uninstall_shortcut}\" del /f /q \"{uninstall_shortcut}\" -if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" if exist \"{tmp_path}\\{app_name}.lnk\" del /f /q \"{tmp_path}\\{app_name}.lnk\" if exist \"{tmp_path}\\Uninstall {app_name}.lnk\" del /f /q \"{tmp_path}\\Uninstall {app_name}.lnk\" if exist \"{tmp_path}\\{app_name} Tray.lnk\" del /f /q \"{tmp_path}\\{app_name} Tray.lnk\" " ); - let src_exe = std::env::current_exe()?.to_str().unwrap_or("").to_string(); + let src_exe = cur_exe.clone(); // potential bug here: if run_cmd cancelled, but config file is changed. if let Some(lic) = get_license() { @@ -1681,7 +1687,7 @@ if exist \"{tmp_path}\\{app_name} Tray.lnk\" del /f /q \"{tmp_path}\\{app_name} "".to_owned() } else { format!(" -cscript \"{tray_shortcut}\" +{tray_shortcut_commands} copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" ") }; @@ -1716,11 +1722,11 @@ reg add {subkey} /f /v Publisher /t REG_SZ /d \"{app_name}\" reg add {subkey} /f /v VersionMajor /t REG_DWORD /d {version_major} reg add {subkey} /f /v VersionMinor /t REG_DWORD /d {version_minor} reg add {subkey} /f /v VersionBuild /t REG_DWORD /d {version_build} -reg add {subkey} /f /v UninstallString /t REG_SZ /d \"\\\"{exe}\\\" --uninstall\" +reg add {subkey} /f /v UninstallString /t REG_SZ /d \"\\\"{nested_exe}\\\" --uninstall\" reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} reg add {subkey} /f /v WindowsInstaller /t REG_DWORD /d 0 -cscript \"{mk_shortcut}\" -cscript \"{uninstall_shortcut}\" +{mk_shortcut_commands} +{uninstall_shortcut_commands} {tray_shortcuts} {shortcuts} copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" @@ -1730,7 +1736,8 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" {install_remote_printer} {sleep} ", - display_icon = get_custom_icon(&path, &cur_exe).unwrap_or(exe.to_string()), + display_icon = shortcut_icon_location.as_deref().unwrap_or(exe.as_str()), + nested_exe = escape_nested_cmd_ampersands(&exe), version = crate::VERSION.replace("-", "."), build_date = crate::BUILD_DATE, after_install = get_after_install( @@ -1836,10 +1843,9 @@ pub fn uninstall_me(kill_self: bool) -> ResultType<()> { run_cmds(get_uninstall(kill_self, true), true, "uninstall") } -fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType { - let mut cmds = cmds; +fn write_vbs(cmds: String, tip: &str) -> ResultType { + const UTF16LE_BOM: &[u8] = &[0xFF, 0xFE]; let mut tmp = std::env::temp_dir(); - // When dir contains these characters, the bat file will not execute in elevated mode. if vec!["&", "@", "^"] .drain(..) .any(|s| tmp.to_string_lossy().to_string().contains(s)) @@ -1848,31 +1854,14 @@ fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType = cmds.encode_utf16().collect(); - // utf8 -> utf16le which vbs support it only - file.write_all(to_le(&mut v))?; - } else { - file.write_all(cmds.as_bytes())?; - } + tmp.push(format!("{}_{}.vbs", crate::get_app_name(), tip)); + let mut file = fs::File::create(&tmp)?; + let cmds = cmds.replace("\r\n", "\n").replace('\n', "\r\n"); + let mut utf16: Vec = cmds.encode_utf16().collect(); + file.write_all(UTF16LE_BOM)?; + file.write_all(to_le(&mut utf16))?; file.sync_all()?; - return Ok(tmp); + Ok(tmp) } fn to_le(v: &mut [u16]) -> &[u8] { @@ -1882,37 +1871,6 @@ fn to_le(v: &mut [u16]) -> &[u8] { unsafe { v.align_to().1 } } -fn get_undone_file(tmp: &Path) -> ResultType { - Ok(tmp.with_file_name(format!( - "{}.undone", - tmp.file_name() - .ok_or(anyhow!("Failed to get filename of {:?}", tmp))? - .to_string_lossy() - ))) -} - -fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { - let tmp = write_cmds(cmds, "bat", tip)?; - let tmp2 = get_undone_file(&tmp)?; - let tmp_fn = tmp.to_str().unwrap_or(""); - // https://github.com/rustdesk/rustdesk/issues/6786#issuecomment-1879655410 - // Specify cmd.exe explicitly to avoid the replacement of cmd commands. - let res = runas::Command::new("cmd.exe") - .args(&["/C", &tmp_fn]) - .show(show) - .force_prompt(true) - .status(); - if !show { - allow_err!(std::fs::remove_file(tmp)); - } - let _ = res?; - if tmp2.exists() { - allow_err!(std::fs::remove_file(tmp2)); - bail!("{} failed", tip); - } - Ok(()) -} - pub fn toggle_blank_screen(v: bool) { let v = if v { TRUE } else { FALSE }; unsafe { @@ -2288,7 +2246,7 @@ pub fn create_shortcut(id: &str) -> ResultType<()> { // https://github.com/rustdesk/hbb_common/blob/8b0e25867375ba9e6bff548acf44fe6d6ffa7c0e/src/config.rs#L1384 let filename = id.replace(':', "_"); let shortcut_icon_location = get_shortcut_icon_location("", &exe); - let shortcut = write_cmds( + let shortcut = write_vbs( format!( " Set oWS = WScript.CreateObject(\"WScript.Shell\") @@ -2302,7 +2260,6 @@ Set oLink = oWS.CreateShortcut(sLinkFile) oLink.Save " ), - "vbs", "connect_shortcut", )? .to_str() @@ -3245,29 +3202,52 @@ pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { std::process::exit(0); } +fn get_install_service_commands(path: &str, exe: &str) -> ResultType { + let app_name = crate::get_app_name(); + for value in [path, exe] { + validate_install_value(value)?; + } + let config_path = Config::file(); + validate_install_value( + config_path + .to_str() + .ok_or_else(|| anyhow!("Configuration path is not valid Unicode"))?, + )?; + let shortcut_icon_location = get_custom_icon(path, exe); + if let Some(icon) = shortcut_icon_location.as_deref() { + validate_install_value(icon)?; + } + let tray_shortcut_commands = + embedded_tray_shortcut_commands(&app_name, exe, shortcut_icon_location.as_deref())?; + let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); + Ok(format!( + " +chcp 65001 +taskkill /F /IM {app_name}.exe{filter} +{tray_shortcut_commands} +copy /Y \"%RUSTDESK_OUTPUT_DIR%\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" +{import_config} +{create_service} + ", + import_config = get_import_config(exe), + create_service = get_create_service(exe), + )) +} + pub fn install_service() -> bool { log::info!("Installing service..."); let _installing = crate::platform::InstallingService::new(); let (_, path, _, exe) = get_install_info(); - let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); - let tray_shortcut = get_tray_shortcut(&path, &exe, &exe, &tmp_path).unwrap_or_default(); - let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); Config::set_option("stop-service".into(), "".into()); + let cmds = match get_install_service_commands(&path, &exe) { + Ok(cmds) => cmds, + Err(err) => { + Config::set_option("stop-service".into(), "Y".into()); + log::error!("Failed to prepare service installation: {err}"); + return true; + } + }; crate::ipc::EXIT_RECV_CLOSE.store(false, Ordering::Relaxed); - let cmds = format!( - " -chcp 65001 -taskkill /F /IM {app_name}.exe{filter} -cscript \"{tray_shortcut}\" -copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" -{import_config} -{create_service} -if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" - ", - app_name = crate::get_app_name(), - import_config = get_import_config(&exe), - create_service = get_create_service(&exe), - ); if let Err(err) = run_cmds(cmds, false, "install") { Config::set_option("stop-service".into(), "Y".into()); crate::ipc::EXIT_RECV_CLOSE.store(true, Ordering::Relaxed); @@ -3728,39 +3708,13 @@ pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { Ok(()) } -pub fn get_tray_shortcut( - install_dir: &str, - exe: &str, - icon_source_exe: &str, - tmp_path: &str, -) -> ResultType { - let shortcut_icon_location = get_shortcut_icon_location(install_dir, icon_source_exe); - Ok(write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\{app_name} Tray.lnk\" - -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - oLink.Arguments = \"--tray\" - {shortcut_icon_location} -oLink.Save - ", - app_name = crate::get_app_name(), - ), - "vbs", - "tray_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned()) -} - fn get_import_config(exe: &str) -> String { if config::is_outgoing_only() { return "".to_string(); } + let exe = escape_nested_cmd_ampersands(exe); + let config_path = Config::file(); + let config_path = escape_nested_cmd_ampersands(config_path.to_str().unwrap_or("")); format!(" sc stop {app_name} sc delete {app_name} @@ -3770,7 +3724,6 @@ sc stop {app_name} sc delete {app_name} ", app_name = crate::get_app_name(), - config_path=Config::file().to_str().unwrap_or(""), ) } @@ -3784,6 +3737,7 @@ fn get_create_service(exe: &str) -> String { if exist \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" del /f /q \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" ", app_name = crate::get_app_name()) } else { + let exe = escape_nested_cmd_ampersands(exe); format!(" sc create {app_name} binpath= \"\\\"{exe}\\\" --service\" start= auto DisplayName= \"{app_name} Service\" sc start {app_name} @@ -4681,6 +4635,28 @@ mod tests { assert_eq!(chr, None) } + #[test] + fn install_app_names_enforce_ascii_command_safety() { + assert!(validate_install_app_name("RustDesk-Admin1").is_ok()); + for app_name in ["", "RustDesk_Admin", "RustDesk&whoami", "RustDesk应用"] { + assert!( + validate_install_app_name(app_name).is_err(), + "unsafe application name was accepted: {app_name}" + ); + } + } + + #[test] + fn vbs_files_use_utf16le_with_bom_and_crlf() { + const EXPECTED: &[u8] = &[0xFF, 0xFE, b'a', 0, b'\r', 0, b'\n', 0, b'b', 0]; + let tip = format!("vbs_encoding_{}", uuid::Uuid::new_v4().simple()); + let path = write_vbs("a\nb".to_owned(), &tip).expect("VBS file should be written"); + let bytes = std::fs::read(&path).expect("VBS file should be readable"); + std::fs::remove_file(path).expect("VBS file should be removed"); + + assert_eq!(bytes, EXPECTED); + } + #[cfg(not(target_pointer_width = "64"))] #[test] fn test_get_pids_with_args_from_wmic_output() { diff --git a/src/platform/windows/installer_handoff.rs b/src/platform/windows/installer_handoff.rs new file mode 100644 index 000000000..6e2380cf8 --- /dev/null +++ b/src/platform/windows/installer_handoff.rs @@ -0,0 +1,288 @@ +use super::{ + installer_shell::{ + get_system_executable, path_for_cmd_assignment, path_for_cmd_environment, + run_elevated_and_wait, trusted_install_environment, + BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE, CMD_RELATIVE_PATH, + }, + validate_install_app_name, ResultType, +}; +use hbb_common::{ + bail, log, + sha2::{Digest, Sha256}, +}; +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +const CERTUTIL_RELATIVE_PATH: &str = "certutil.exe"; +const CHCP_RELATIVE_PATH: &str = "chcp.com"; +const FINDSTR_RELATIVE_PATH: &str = "findstr.exe"; +const UTF8_CODE_PAGE: u32 = 65001; +const INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE: u32 = 0x5253_0001; +const INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE: u32 = 0x5253_0002; +const INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE: u32 = 0x5253_0003; +const INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE: u32 = 0x5253_0004; +const BATCH_CODE_PAGE_FAILURE_EXIT_CODE: u32 = 0x5253_0005; +const BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE: u32 = 0x5253_0006; +const BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE: u32 = 0x5253_0007; +const SHA256_HASH_LENGTH: usize = 32; + +type BatchHash = [u8; SHA256_HASH_LENGTH]; + +struct InstallCommandScript { + path: PathBuf, + expected_hash: BatchHash, +} + +impl Drop for InstallCommandScript { + fn drop(&mut self) { + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove temporary installer file {:?}: {err}", + self.path + ); + } + } + } +} + +fn prepare_install_commands(commands: &str) -> ResultType { + let commands = commands.replace("\r\n", "\n").replace('\n', "\r\n"); + let chcp_path = get_system_executable(CHCP_RELATIVE_PATH)?; + let chcp = path_for_cmd_environment(&chcp_path)?; + Ok(format!( + "@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n\ + \"{chcp}\" {UTF8_CODE_PAGE} > nul || exit /b \ + {BATCH_CODE_PAGE_FAILURE_EXIT_CODE}\r\n\ + {}\r\n\ + if exist \"%~f0.dir\" exit /b {BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE}\r\n\ + md \"%~f0.dir\" || exit /b {BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE}\r\n\ + set \"RUSTDESK_OUTPUT_DIR=%~f0.dir\"\r\n{commands}\r\nexit /b 0\r\n", + trusted_install_environment()? + )) +} + +fn write_install_script(cmds: String) -> ResultType { + let directory = std::env::temp_dir(); + path_for_cmd_environment(&directory)?; + let commands = prepare_install_commands(&cmds)?; + let expected_hash = Sha256::digest(commands.as_bytes()).into(); + let path = directory.join(format!( + "rustdesk_install_{}.bat", + uuid::Uuid::new_v4().simple() + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + let script = InstallCommandScript { + path, + expected_hash, + }; + file.write_all(commands.as_bytes())?; + file.sync_all()?; + Ok(script) +} + +fn install_hash_pattern(hash: &BatchHash) -> String { + hash.iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *") +} + +fn verified_install_bootstrap( + script: &InstallCommandScript, + runner_directory: &Path, +) -> ResultType { + let source = path_for_cmd_assignment(&script.path)?; + let runner = runner_directory.join(format!( + "rustdesk_install_{}.bat", + uuid::Uuid::new_v4().simple() + )); + let runner = path_for_cmd_assignment(&runner)?; + let cmd_path = get_system_executable(CMD_RELATIVE_PATH)?; + let certutil_path = get_system_executable(CERTUTIL_RELATIVE_PATH)?; + let findstr_path = get_system_executable(FINDSTR_RELATIVE_PATH)?; + let cmd = path_for_cmd_assignment(&cmd_path)?; + let certutil = path_for_cmd_assignment(&certutil_path)?; + let findstr = path_for_cmd_assignment(&findstr_path)?; + // Short names preserve headroom under the Windows 7 ShellExecuteExW 2,048 + // UTF-16-character parameter limit. Paths are stored with delayed expansion + // disabled, then expanded indirectly so literal `!` survives: S=source, + // R=runner, Q=cmd.exe, H=certutil.exe, F=findstr.exe, C=created flag, E=exit code. + Ok(format!( + "setlocal DisableDelayedExpansion & set \"S={source}\" & set \"R={runner}\" & \ + set \"Q={cmd}\" & set \"H={certutil}\" & set \"F={findstr}\" & \ + set \"C=0\" & set \"E=0\" & setlocal EnableDelayedExpansion & \ + if exist \"!R!\" (set \"E={INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE}\") else (\ + set \"C=1\" & copy /Y \"!S!\" \"!R!\" > nul || \ + (set \"E={INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!H!\" -hashfile \"!R!\" SHA256 > \"!R!.hash\" || \ + set \"E={INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!F!\" /R /I /X /C:\"{}\" \"!R!.hash\" > nul || \ + set \"E={INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!Q!\" /D /E:ON /V:OFF /C \"\"!R!\"\" & \ + set \"E=!errorlevel!\")) & \ + if \"!C!\"==\"1\" (rd /s /q \"!R!.dir\" > nul 2>&1 & \ + del /f /q \"!R!\" \"!R!.*\" > nul 2>&1) & exit /b !E!", + install_hash_pattern(&script.expected_hash), + )) +} + +fn verified_install_parameters(script: &InstallCommandScript) -> ResultType { + let system_directory = get_system_executable("")?; + Ok(format!( + "/D /E:ON /V:ON /C {}", + verified_install_bootstrap(script, &system_directory)? + )) +} + +pub(super) fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { + validate_install_app_name(&crate::get_app_name())?; + let script = write_install_script(cmds)?; + let cmd_path = get_system_executable(CMD_RELATIVE_PATH)?; + let parameters = verified_install_parameters(&script)?; + let exit_code = run_elevated_and_wait(&cmd_path, ¶meters, show)?; + if exit_code != 0 { + bail!( + "{tip} failed with elevated exit code {exit_code}: {}", + elevated_install_failure_reason(exit_code) + ); + } + Ok(()) +} + +fn elevated_install_failure_reason(exit_code: u32) -> &'static str { + match exit_code { + INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE => "protected runner already exists", + INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE => "failed to copy protected runner", + INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE => "failed to hash protected runner", + INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE => "protected runner hash mismatch", + BATCH_CODE_PAGE_FAILURE_EXIT_CODE => "failed to set the installer code page", + BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE => "installer output directory already exists", + BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE => { + "failed to create the installer output directory" + } + BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE => "failed to decode an embedded shortcut", + _ => "installer command failed", + } +} + +#[cfg(test)] +mod tests { + use super::super::installer_shell::{ + embedded_shortcut_commands, shortcut_bytes, WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS, + }; + use super::*; + use ::windows::Win32::System::Threading; + use std::os::windows::process::CommandExt; + + #[test] + fn native_install_handoff_verifies_before_execution() { + let marker = std::env::temp_dir().join(format!( + "rustdesk_install_marker_{}", + uuid::Uuid::new_v4().simple() + )); + let runner_dir = std::env::temp_dir().join(format!( + "rustdesk_install_!RUSTDESK_HANDOFF_EXPAND!&^@()runner_{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir(&runner_dir).expect("runner directory should be created"); + let shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(r"C:\RustDesk.exe", None, None) + .expect("native shortcut should be generated"), + "test.lnk", + "test", + ); + let script = write_install_script(format!( + "if \"%PROGRAMDATA%\"==\"rustdesk_untrusted\" exit /b 77\r\n\ + if \"%PUBLIC%\"==\"rustdesk_untrusted\" exit /b 77\r\n\ + {shortcut_commands}\r\n\ + > \"{}\" echo verified", + marker.display() + )) + .expect("install script should be created"); + let bootstrap = verified_install_bootstrap(&script, &runner_dir) + .expect("native verifier bootstrap should be generated"); + assert_native_handoff_structure(&script, &shortcut_commands, &bootstrap); + + let output = run_install_bootstrap_for_test(&bootstrap); + assert!( + output.status.success(), + "unchanged script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(marker.exists(), "verified install script must execute"); + std::fs::remove_file(&marker).expect("test marker should be removed"); + assert_replaced_install_script_is_rejected(&script, &runner_dir, &marker); + std::fs::remove_dir(runner_dir).expect("runner directory should be empty"); + } + + fn assert_native_handoff_structure( + script: &InstallCommandScript, + shortcut_commands: &str, + bootstrap: &str, + ) { + assert!(shortcut_commands.contains("certutil")); + assert!(shortcut_commands.contains("-decode")); + assert!(!shortcut_commands.to_ascii_lowercase().contains("cscript")); + assert!(!shortcut_commands + .to_ascii_lowercase() + .contains("powershell")); + let win7_hash_pattern = script + .expected_hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *"); + assert!(bootstrap.contains(&format!("/R /I /X /C:\"{win7_hash_pattern}\""))); + let parameters = + verified_install_parameters(script).expect("elevated parameters should be generated"); + assert!(bootstrap.contains("certutil.exe")); + assert!(bootstrap.contains("findstr.exe")); + assert!(!bootstrap.to_ascii_lowercase().contains("powershell")); + assert!(parameters.encode_utf16().count() < WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS); + } + + fn assert_replaced_install_script_is_rejected( + script: &InstallCommandScript, + runner_dir: &Path, + marker: &Path, + ) { + std::fs::write( + &script.path, + format!("> \"{}\" echo hijacked\r\n", marker.display()), + ) + .expect("install script should be replaceable"); + let replaced = verified_install_bootstrap(&script, &runner_dir) + .expect("replacement verifier should be generated"); + let output = run_install_bootstrap_for_test(&replaced); + assert!( + !output.status.success(), + "replaced script unexpectedly passed verification" + ); + assert_eq!( + output.status.code(), + Some(INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE as i32) + ); + assert!(!marker.exists(), "replaced script must not execute"); + } + + fn run_install_bootstrap_for_test(bootstrap: &str) -> std::process::Output { + let cmd = get_system_executable(CMD_RELATIVE_PATH).expect("system cmd.exe should resolve"); + let mut command = std::process::Command::new(cmd); + command + .env("PROGRAMDATA", "rustdesk_untrusted") + .env("PUBLIC", "rustdesk_untrusted") + .env("RUSTDESK_HANDOFF_EXPAND", "expanded"); + command.raw_arg(format!("/D /E:ON /V:ON /C {bootstrap}")); + command + .creation_flags(Threading::CREATE_NO_WINDOW.0) + .output() + .expect("native verifier should run") + } +} diff --git a/src/platform/windows/installer_shell.rs b/src/platform/windows/installer_shell.rs new file mode 100644 index 000000000..7cbd80608 --- /dev/null +++ b/src/platform/windows/installer_shell.rs @@ -0,0 +1,300 @@ +use super::{wide_string, ResultType}; +use hbb_common::{ + anyhow::anyhow, + bail, + base64::{engine::general_purpose::STANDARD, Engine as _}, + log, +}; +use std::{ + ffi::OsString, + io, mem, + os::windows::ffi::OsStringExt, + path::{Path, PathBuf}, +}; +use windows::{ + core::{Interface, PCWSTR}, + Win32::{ + Foundation::{self, CloseHandle, HANDLE}, + System::{Com, SystemInformation, Threading}, + UI::{ + Shell::{ + self, FOLDERID_ProgramData, FOLDERID_Public, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }, + WindowsAndMessaging, + }, + }, +}; + +pub(super) const CMD_RELATIVE_PATH: &str = "cmd.exe"; +pub(super) const BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE: u32 = 0x5253_0008; +pub(super) const WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS: usize = 2048; +const SHORTCUT_ICON_INDEX: i32 = 0; + +pub(super) fn shortcut_bytes( + target_path: &str, + arguments: Option<&str>, + icon_location: Option<&str>, +) -> ResultType> { + let _com = initialize_shell_com()?; + let link: Shell::IShellLinkW = + unsafe { Com::CoCreateInstance(&Shell::ShellLink, None, Com::CLSCTX_INPROC_SERVER) }?; + let target_path = wide_string(target_path); + unsafe { link.SetPath(PCWSTR(target_path.as_ptr())) }?; + if let Some(arguments) = arguments { + let arguments = wide_string(arguments); + unsafe { link.SetArguments(PCWSTR(arguments.as_ptr())) }?; + } + if let Some(icon_location) = icon_location { + let icon_location = wide_string(icon_location); + unsafe { link.SetIconLocation(PCWSTR(icon_location.as_ptr()), SHORTCUT_ICON_INDEX) }?; + } + + let stream = unsafe { Shell::SHCreateMemStream(None) } + .ok_or_else(|| anyhow!("Failed to create shortcut memory stream"))?; + let persist: Com::IPersistStream = link.cast()?; + unsafe { persist.Save(&stream, true) }?; + let mut stat = Com::STATSTG::default(); + unsafe { stream.Stat(&mut stat, Com::STATFLAG_NONAME) }?; + let size = usize::try_from(stat.cbSize).map_err(|_| anyhow!("Shortcut data is too large"))?; + let read_size = u32::try_from(size).map_err(|_| anyhow!("Shortcut data is too large"))?; + let mut bytes = vec![0; size]; + let mut bytes_read = 0; + unsafe { + stream.Seek(0, Com::STREAM_SEEK_SET, None)?; + stream + .Read(bytes.as_mut_ptr().cast(), read_size, Some(&mut bytes_read)) + .ok()?; + } + if bytes_read != read_size { + bail!("Failed to read complete shortcut data"); + } + Ok(bytes) +} + +pub(super) fn embedded_shortcut_commands(bytes: Vec, filename: &str, name: &str) -> String { + let encoded = STANDARD.encode(bytes); + let encoded_path = format!("%~f0.{name}.b64"); + format!( + "> \"{encoded_path}\" echo {encoded}\r\n\ + certutil -f -decode \"{encoded_path}\" \"%RUSTDESK_OUTPUT_DIR%\\{filename}\" > nul || exit /b {BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE}" + ) +} + +pub(super) fn embedded_tray_shortcut_commands( + app_name: &str, + exe: &str, + icon_location: Option<&str>, +) -> ResultType { + let filename = format!("{app_name} Tray.lnk"); + Ok(embedded_shortcut_commands( + shortcut_bytes(exe, Some("--tray"), icon_location)?, + &filename, + "tray_shortcut", + )) +} + +pub(super) fn validate_install_value(value: &str) -> ResultType<()> { + if value.contains(['\0', '"', '%', '\r', '\n', '|', '<', '>']) { + bail!("Installer path or name contains characters unsafe for cmd.exe"); + } + Ok(()) +} + +pub(super) fn get_system_executable(relative_path: &str) -> ResultType { + let mut buffer = vec![0u16; Foundation::MAX_PATH as usize]; + let len = unsafe { SystemInformation::GetSystemDirectoryW(Some(&mut buffer)) } as usize; + if len == 0 { + return Err(io::Error::last_os_error().into()); + } + if len >= buffer.len() { + bail!("Windows system directory path is too long"); + } + buffer.truncate(len); + let mut path = PathBuf::from(OsString::from_wide(&buffer)); + path.push(relative_path); + Ok(path) +} + +fn get_known_folder(id: &windows::core::GUID) -> ResultType { + let value = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, None) }?; + let path = unsafe { value.to_string() }; + unsafe { Com::CoTaskMemFree(Some(value.0.cast())) }; + Ok(PathBuf::from(path?)) +} + +// `%VAR%` is expanded before cmd.exe executes even inside a quoted `set` assignment. +// Reject all `%` so a handoff path cannot alter the elevated bootstrap before hash verification. +// https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/set_1 +pub(super) fn path_for_cmd_environment(path: &Path) -> ResultType<&str> { + let value = path + .to_str() + .ok_or_else(|| anyhow!("Path is not valid Unicode: {:?}", path))?; + if value.contains(['\0', '"', '%', '\r', '\n']) { + bail!("Path is unsafe for an elevated cmd.exe handoff: {:?}", path); + } + Ok(value) +} + +// Bootstrap assignments are parsed before DisableDelayedExpansion takes effect. +// Escape carets first so `^!` preserves each literal exclamation mark. +pub(super) fn path_for_cmd_assignment(path: &Path) -> ResultType { + Ok(path_for_cmd_environment(path)? + .replace('^', "^^") + .replace('!', "^!")) +} + +pub(super) fn trusted_install_environment() -> ResultType { + let system = get_system_executable("")?; + let program_data = get_known_folder(&FOLDERID_ProgramData)?; + let public = get_known_folder(&FOLDERID_Public)?; + trusted_install_environment_from_paths(&system, &program_data, &public) +} + +fn trusted_install_environment_from_paths( + system: &Path, + program_data: &Path, + public: &Path, +) -> ResultType { + let windows = system + .parent() + .ok_or_else(|| anyhow!("System directory has no parent"))?; + let cmd = system.join(CMD_RELATIVE_PATH); + // These paths are parsed once from the protected BAT, with delayed expansion disabled. + let system = path_for_cmd_environment(system)?; + let windows = path_for_cmd_environment(windows)?; + let cmd = path_for_cmd_environment(&cmd)?; + let program_data = path_for_cmd_environment(program_data)?; + let public = path_for_cmd_environment(public)?; + Ok(format!( + "set \"ComSpec={cmd}\" & set \"PATH={system}\" & \ + set \"SystemRoot={windows}\" & set \"WINDIR={windows}\" & \ + set \"ProgramData={program_data}\" & set \"PUBLIC={public}\" & \ + set \"PATHEXT=.COM;.EXE;.BAT;.CMD\" & \ + set \"NoDefaultCurrentDirectoryInExePath=1\"" + )) +} + +struct ShellComGuard; + +impl Drop for ShellComGuard { + fn drop(&mut self) { + unsafe { Com::CoUninitialize() }; + } +} + +fn initialize_shell_com() -> ResultType> { + let result = unsafe { + Com::CoInitializeEx( + None, + Com::COINIT_APARTMENTTHREADED | Com::COINIT_DISABLE_OLE1DDE, + ) + }; + if result == Foundation::RPC_E_CHANGED_MODE { + return Ok(None); + } + if result.is_err() { + bail!( + "Failed to initialize COM: HRESULT 0x{:08X}", + result.0 as u32 + ); + } + Ok(Some(ShellComGuard)) +} + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + if let Err(err) = unsafe { CloseHandle(self.0) } { + log::warn!("Failed to close elevated process handle: {err}"); + } + } +} + +fn elevated_working_directory(executable: &Path) -> ResultType<&Path> { + executable + .parent() + .ok_or_else(|| anyhow!("Elevated executable has no parent directory")) +} + +pub(super) fn run_elevated_and_wait( + executable: &Path, + parameters: &str, + show: bool, +) -> ResultType { + let parameter_chars = parameters.encode_utf16().count(); + if parameter_chars >= WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS { + bail!("Elevated command is too long: {parameter_chars} UTF-16 characters"); + } + let _com = initialize_shell_com()?; + let verb = wide_string("runas"); + let working_directory = wide_string(path_for_cmd_environment(elevated_working_directory( + executable, + )?)?); + let executable = wide_string(path_for_cmd_environment(executable)?); + let parameters = wide_string(parameters); + let mut info = Shell::SHELLEXECUTEINFOW::default(); + info.cbSize = mem::size_of::() as u32; + info.fMask = Shell::SEE_MASK_NOCLOSEPROCESS | Shell::SEE_MASK_NOASYNC; + info.lpVerb = PCWSTR(verb.as_ptr()); + info.lpFile = PCWSTR(executable.as_ptr()); + info.lpParameters = PCWSTR(parameters.as_ptr()); + info.lpDirectory = PCWSTR(working_directory.as_ptr()); + info.nShow = if show { + WindowsAndMessaging::SW_SHOWNORMAL.0 + } else { + WindowsAndMessaging::SW_HIDE.0 + }; + unsafe { Shell::ShellExecuteExW(&mut info) }?; + if info.hProcess.0.is_null() { + bail!("Windows did not return an elevated process handle"); + } + let process = ProcessHandle(info.hProcess); + let wait_result = unsafe { Threading::WaitForSingleObject(process.0, Threading::INFINITE) }; + if wait_result == Foundation::WAIT_FAILED { + return Err(io::Error::last_os_error().into()); + } + if wait_result != Foundation::WAIT_OBJECT_0 { + bail!("Unexpected elevated process wait result: {}", wait_result.0); + } + let mut exit_code = 0; + unsafe { Threading::GetExitCodeProcess(process.0, &mut exit_code) }?; + Ok(exit_code) +} + +// Escape `^` before using it to escape `&` so both survive nested cmd.exe parsing. +// https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/cc754250(v=ws.11) +pub(super) fn escape_nested_cmd_ampersands(value: &str) -> String { + value.replace('^', "^^").replace('&', "^&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_values_enforce_command_safety() { + assert!(validate_install_value(r"C:\safe ! path").is_ok()); + assert!(validate_install_value(r"C:\Program Files (x86)\RustDesk").is_ok()); + assert!(validate_install_value(r"C:\Users\R&D\RustDesk.exe").is_ok()); + assert!(validate_install_value(r"C:\A&^ B\RustDesk.exe").is_ok()); + for character in ['\0', '"', '%', '\r', '\n', '|', '<', '>'] { + let value = format!(r"C:\unsafe{character}path"); + assert!( + validate_install_value(&value).is_err(), + "cmd.exe control character was accepted: {character:?}" + ); + } + } + + #[test] + fn nested_commands_escape_while_protected_environment_preserves_carets() { + assert_eq!( + escape_nested_cmd_ampersands(r"C:\A&^ B\RustDesk.exe"), + r"C:\A^&^^ B\RustDesk.exe" + ); + let path = Path::new(r"C:\Win^Root\System32"); + let environment = trusted_install_environment_from_paths(path, path, path).unwrap(); + assert!(environment.contains(r#"set "PATH=C:\Win^Root\System32""#)); + } +}