fix: Harden Windows installer temp command scripts (#15634)

* fix: Harden Windows installer temp command scripts

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: restore stop-service after install preparation failure

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(windows): preserve special characters in installer paths

Handle carets and exclamation marks safely across cmd.exe parsing stages.
Add coverage for special-character paths in the elevated installer handoff.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: installer, validate app name

Signed-off-by: fufesou <linlong1266@gmail.com>

* update tests

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-08-04 14:29:04 +08:00
committed by GitHub
parent 3cf32e7066
commit 402ed07b0c
5 changed files with 727 additions and 173 deletions
Generated
-13
View File
@@ -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",
+4 -1
View File
@@ -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"
+135 -159
View File
@@ -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<DisplayInfo>) -> Option<usize> {
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<std::path::PathBuf> {
let mut cmds = cmds;
fn write_vbs(cmds: String, tip: &str) -> ResultType<PathBuf> {
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<std::path::PathB
tmp = dir;
}
}
tmp.push(format!("{}_{}.{}", crate::get_app_name(), tip, ext));
let mut file = std::fs::File::create(&tmp)?;
if ext == "bat" {
let tmp2 = get_undone_file(&tmp)?;
std::fs::File::create(&tmp2).ok();
cmds = format!(
"
{cmds}
if exist \"{path}\" del /f /q \"{path}\"
",
path = tmp2.to_string_lossy()
);
}
// in case cmds mixed with \r\n and \n, make sure all ending with \r\n
// in some windows, \r\n required for cmd file to run
cmds = cmds.replace("\r\n", "\n").replace("\n", "\r\n");
if ext == "vbs" {
let mut v: Vec<u16> = 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<u16> = 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<PathBuf> {
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<String> {
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<String> {
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() {
+288
View File
@@ -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<String> {
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<InstallCommandScript> {
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::<Vec<_>>()
.join(" *")
}
fn verified_install_bootstrap(
script: &InstallCommandScript,
runner_directory: &Path,
) -> ResultType<String> {
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<String> {
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, &parameters, 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::<Vec<_>>()
.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")
}
}
+300
View File
@@ -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<Vec<u8>> {
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<u8>, 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<String> {
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<PathBuf> {
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<PathBuf> {
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<String> {
Ok(path_for_cmd_environment(path)?
.replace('^', "^^")
.replace('!', "^!"))
}
pub(super) fn trusted_install_environment() -> ResultType<String> {
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<String> {
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<Option<ShellComGuard>> {
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<u32> {
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::<Shell::SHELLEXECUTEINFOW>() 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""#));
}
}