diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-04-23 13:06:53 +0200 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-04-23 13:06:53 +0200 |
| commit | 4e5168734f69ad696a84a8436b5dda2712e0266c (patch) | |
| tree | 053dc47006a7de052d4dfb158695a1c56f7b4e32 | |
| parent | 4c5dbdce39e95206b7cbb85f47318aafa3d2c97e (diff) | |
| parent | b958ef269d4ba830056b48c9206c6fd663c25339 (diff) | |
| download | mullvadvpn-4e5168734f69ad696a84a8436b5dda2712e0266c.tar.xz mullvadvpn-4e5168734f69ad696a84a8436b5dda2712e0266c.zip | |
Merge branch 'detect-macos-delete'
| -rw-r--r-- | CHANGELOG.md | 9 | ||||
| -rw-r--r-- | Cargo.lock | 79 | ||||
| -rwxr-xr-x | dist-assets/pkg-scripts/postinstall | 12 | ||||
| -rwxr-xr-x | dist-assets/pkg-scripts/preinstall | 11 | ||||
| -rwxr-xr-x | dist-assets/uninstall_macos.sh | 29 | ||||
| -rw-r--r-- | mullvad-daemon/Cargo.toml | 2 | ||||
| -rw-r--r-- | mullvad-daemon/src/lib.rs | 10 | ||||
| -rw-r--r-- | mullvad-daemon/src/macos.rs | 195 | ||||
| -rw-r--r-- | talpid-macos/Cargo.toml | 2 | ||||
| -rw-r--r-- | talpid-macos/src/apsl-header | 27 | ||||
| -rw-r--r-- | talpid-macos/src/bindings.rs | 192 | ||||
| -rwxr-xr-x | talpid-macos/src/generate-bindings.sh | 20 | ||||
| -rw-r--r-- | talpid-macos/src/lib.rs | 4 | ||||
| -rw-r--r-- | talpid-macos/src/process.rs | 95 | ||||
| -rw-r--r-- | test/test-manager/src/tests/install.rs | 59 |
15 files changed, 703 insertions, 43 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c6a9ec3f..556eeacdc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ Line wrap the file at 100 chars. Th #### Linux - Fix syntax error in Apparmor profile. +#### macOS +- Fully uninstall the app when it is removed by being dropped in the bin. + +### Security +#### macOS +- Fix potential local privilege escalation when app was incorrectly removed by being dropped + in the bin but still leaving behind a launch daemon. This fixes CVE-2025-46351 reported by + Egor Filatov (Positive Technologies). + ## [2025.6-beta1] - 2025-04-15 ### Added diff --git a/Cargo.lock b/Cargo.lock index 791221baf7..e9aba835aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,9 +348,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "blake3" @@ -2043,6 +2043,17 @@ dependencies = [ ] [[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.0", + "inotify-sys", + "libc", +] + +[[package]] name = "inotify-sys" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2353,9 +2364,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libdbus-sys" @@ -2388,7 +2399,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "libc", ] @@ -2577,6 +2588,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi", "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2726,6 +2738,7 @@ dependencies = [ "mullvad-types", "mullvad-version", "nix 0.23.2", + "notify 8.0.0", "objc2", "regex", "serde", @@ -2735,6 +2748,7 @@ dependencies = [ "talpid-core", "talpid-dbus", "talpid-future", + "talpid-macos", "talpid-platform-metadata", "talpid-routing", "talpid-time", @@ -3160,7 +3174,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06a7491dd91b71643f65546389f25506da70723d1f1ec8c8d6d20444d1c23f27" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "log", "nftnl-sys", ] @@ -3206,7 +3220,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3219,7 +3233,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3238,7 +3252,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "crossbeam-channel", "filetime", "fsevent-sys", @@ -3252,6 +3266,31 @@ dependencies = [ ] [[package]] +name = "notify" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" +dependencies = [ + "bitflags 2.9.0", + "filetime", + "fsevent-sys", + "inotify 0.11.0", + "kqueue", + "libc", + "log", + "mio 1.0.2", + "notify-types", + "walkdir", + "windows-sys 0.59.0", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] name = "nseventforwarder" version = "0.0.0" dependencies = [ @@ -3310,7 +3349,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "block2", "libc", "objc2", @@ -3326,7 +3365,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "block2", "objc2", "objc2-foundation", @@ -3356,7 +3395,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "block2", "libc", "objc2", @@ -3368,7 +3407,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "block2", "objc2", "objc2-foundation", @@ -3380,7 +3419,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "block2", "objc2", "objc2-foundation", @@ -3876,7 +3915,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.6.0", + "bitflags 2.9.0", "lazy_static", "num-traits", "rand 0.8.5", @@ -4367,7 +4406,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "errno 0.3.8", "libc", "linux-raw-sys", @@ -4654,7 +4693,7 @@ dependencies = [ "hickory-resolver", "libc", "log", - "notify", + "notify 6.1.1", "once_cell", "percent-encoding", "pin-project", @@ -4954,7 +4993,7 @@ name = "talpid-core" version = "0.0.0" dependencies = [ "async-trait", - "bitflags 2.6.0", + "bitflags 2.9.0", "chrono", "duct", "futures", @@ -5110,7 +5149,7 @@ dependencies = [ name = "talpid-routing" version = "0.0.0" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", "futures", "ipnetwork", "jnix", @@ -6569,7 +6608,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.0", ] [[package]] diff --git a/dist-assets/pkg-scripts/postinstall b/dist-assets/pkg-scripts/postinstall index 3f498baeb8..6f6f6f9904 100755 --- a/dist-assets/pkg-scripts/postinstall +++ b/dist-assets/pkg-scripts/postinstall @@ -19,17 +19,8 @@ exec > $LOG_DIR/postinstall.log 2>&1 echo "Running postinstall at $(date)" -# Run CLI to force macOS to check the certificate, and shut down the already running daemon, if one -# exists. -# This is a temporary workaround. After 2023.3, we switched from signing with Amagicom AB to -# Mullvad VPN AB certificates. This could potentially be removed when older versions are no longer -# supported. -"$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad" &>/dev/null || true -"$TMPDIR/mullvad-setup" prepare-restart &>/dev/null || \ - echo "Failed to send 'prepare-restart' command to old mullvad-daemon" - # NOTE: This path must be kept in sync with the path defined -# in mullvad-daemon/src/macos_launch_daemon.rs +# in mullvad-daemon/src/macos_launch_daemon.rs and preinstall DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist" DAEMON_PLIST=$(cat <<-EOM @@ -79,7 +70,6 @@ else FISH_COMPLETIONS_DIR="/usr/local/share/fish/vendor_completions.d/" fi -launchctl unload -w $DAEMON_PLIST_PATH cp "$LOG_DIR/daemon.log" "$LOG_DIR/old-install-daemon.log" \ || echo "Failed to copy old daemon log" diff --git a/dist-assets/pkg-scripts/preinstall b/dist-assets/pkg-scripts/preinstall index ed65b866aa..95a42235d1 100755 --- a/dist-assets/pkg-scripts/preinstall +++ b/dist-assets/pkg-scripts/preinstall @@ -22,9 +22,14 @@ exec > $LOG_DIR/preinstall.log 2>&1 echo "Running preinstall at $(date)" -# We need to run this is after extracting the new files and running "mullvad" in postinstall rather -# than in preinstall. -cp "$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad-setup" "$TMPDIR/" || echo "Failed to copy mullvad-setup" +# Stop the existing daemon +"$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad-setup" prepare-restart &>/dev/null || \ + echo "Failed to send 'prepare-restart' command to old mullvad-daemon" + +# NOTE: This path must be kept in sync with the path defined +# in mullvad-daemon/src/macos_launch_daemon.rs and postinstall +DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist" +launchctl unload -w $DAEMON_PLIST_PATH || echo "Failed to unload old mullvad-daemon" # Migrate cache files from <=2020.8-beta2 paths OLD_CACHE_DIR="/var/root/Library/Caches/mullvad-vpn" diff --git a/dist-assets/uninstall_macos.sh b/dist-assets/uninstall_macos.sh index c4ef471fee..0983ee43ae 100755 --- a/dist-assets/uninstall_macos.sh +++ b/dist-assets/uninstall_macos.sh @@ -2,8 +2,21 @@ set -ue -read -r -p "Are you sure you want to stop and uninstall Mullvad VPN? (y/n) " -if [[ "$REPLY" =~ [Yy]$ ]]; then +ASSUMEYES="n" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --yes) ASSUMEYES="y";; + *) + echo "Unknown parameter: $1" + exit 1 + ;; + esac + shift +done + +[[ $ASSUMEYES == "y" ]] || read -r -p "Are you sure you want to stop and uninstall Mullvad VPN? (y/n) " +if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then echo "Uninstalling Mullvad VPN ..." else echo "Aborting uninstall" @@ -40,8 +53,8 @@ sudo pkgutil --forget net.mullvad.vpn || true echo "Removing login item ..." osascript -e 'tell application "System Events" to delete login item "Mullvad VPN"' 2>/dev/null || true -read -r -p "Do you want to delete the log and cache files the app has created? (y/n) " -if [[ "$REPLY" =~ [Yy]$ ]]; then +[[ $ASSUMEYES == "y" ]] || read -r -p "Do you want to delete the log and cache files the app has created? (y/n) " +if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then sudo rm -rf /var/log/mullvad-vpn /var/root/Library/Caches/mullvad-vpn /Library/Caches/mullvad-vpn for user in /Users/*; do user_log_dir="$user/Library/Logs/Mullvad VPN" @@ -52,8 +65,8 @@ if [[ "$REPLY" =~ [Yy]$ ]]; then done fi -read -r -p "Do you want to delete the Mullvad VPN settings? (y/n) " -if [[ "$REPLY" =~ [Yy]$ ]]; then +[[ $ASSUMEYES == "y" ]] || read -r -p "Do you want to delete the Mullvad VPN settings? (y/n) " +if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then sudo rm -rf /etc/mullvad-vpn for user in /Users/*; do user_settings_dir="$user/Library/Application Support/Mullvad VPN" @@ -63,3 +76,7 @@ if [[ "$REPLY" =~ [Yy]$ ]]; then fi done fi + +# When run from a non-standard directory, like when detecting that the app bundle is gone, +# we must also delete the uninstall script itself +rm -f "$0" || true diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml index 66edca63a2..ce5fd923da 100644 --- a/mullvad-daemon/Cargo.toml +++ b/mullvad-daemon/Cargo.toml @@ -67,6 +67,8 @@ talpid-dbus = { path = "../talpid-dbus" } [target.'cfg(target_os="macos")'.dependencies] objc2 = { version = "0.5.2", features = ["exception"] } +notify = "8.0.0" +talpid-macos = { path = "../talpid-macos" } [target.'cfg(windows)'.dependencies] ctrlc = "3.0" diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index f5ca13f976..f90cdfa00e 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -962,6 +962,16 @@ impl Daemon { api_availability.unsuspend(); + #[cfg(target_os = "macos")] + { + let account_manager = daemon.account_manager.clone(); + tokio::task::spawn(async { + if let Err(error) = macos::handle_app_bundle_removal(account_manager).await { + log::error!("Failed to handle app removal: {error}"); + } + }); + } + Ok(daemon) } diff --git a/mullvad-daemon/src/macos.rs b/mullvad-daemon/src/macos.rs index 1db0dcb83d..b15b9d7416 100644 --- a/mullvad-daemon/src/macos.rs +++ b/mullvad-daemon/src/macos.rs @@ -1,4 +1,15 @@ -use std::io; +use std::{ffi::OsStr, fmt, io, path::Path, process::Stdio}; + +use anyhow::{anyhow, Context}; +use libc::{pid_t, PROX_FDTYPE_VNODE}; +use notify::{RecursiveMode, Watcher}; +use std::io::Write; +use talpid_macos::process::{ + get_file_desc_vnode_path, list_pids, process_bsdinfo, process_file_descriptors, process_path, +}; +use tokio::{fs::File, process::Command}; + +use crate::device::AccountManagerHandle; /// Bump filehandle limit pub fn bump_filehandle_limit() { @@ -35,3 +46,185 @@ pub fn bump_filehandle_limit() { ); } } + +/// Detect when the app bundle is deleted +pub async fn handle_app_bundle_removal( + account_manager_handle: AccountManagerHandle, +) -> anyhow::Result<()> { + /// Uninstall script to run if the .app disappears + const UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../dist-assets/uninstall_macos.sh"); + + /// Path to extract the uninstall script to. + /// This directory must be owned by root to prevent privilege escalation. + const UNINSTALL_SCRIPT_PATH: &str = "/var/root/uninstall_mullvad.sh"; + + /// Mullvad app install path + const APP_PATH: &str = "/Applications/Mullvad VPN.app"; + + let daemon_path = std::env::current_exe().context("Failed to get daemon path")?; + + // Ignore app removal if the daemon isn't installed in the app directory + if !daemon_path.starts_with(APP_PATH) { + log::trace!("Stopping handle_app_bundle_removal as the daemon is not installed"); + return Ok(()); + } + + let (fs_notify_tx, mut fs_notify_rx) = tokio::sync::mpsc::channel(1); + let mut fs_watcher = + notify::recommended_watcher(move |event: notify::Result<notify::Event>| { + // Ignore access events + let is_access_event = event.map(|evt| evt.kind.is_access()).unwrap_or(false); + + // Check if the daemon binary still exists + if !is_access_event && !daemon_path.exists() { + _ = fs_notify_tx.try_send(()); + } + }) + .context("Failed to start filesystem watcher")?; + + fs_watcher + .watch(Path::new(APP_PATH), RecursiveMode::Recursive) + .context(anyhow!("Failed to watch {APP_PATH}"))?; + + fs_notify_rx + .recv() + .await + .context("Filesystem watcher stopped unexpectedly")?; + drop(fs_watcher); + + // Create file to log output from uninstallation process. + // This is useful since the daemon will be killed during uninstallation. + let mut log_file = async { + let log_path: std::path::PathBuf = mullvad_paths::log_dir()?.join("uninstall.log"); + + let file = File::create(log_path).await?; + anyhow::Ok(file.into_std().await) + } + .await + .inspect_err(|e| { + log::warn!("Failed to create uninstaller log-file: {e:#?}"); + }) + .ok(); + + // Log to both daemon log and uninstaller log. + let mut log = |msg: fmt::Arguments<'_>| { + log::info!("{msg}"); + if let Some(log_file) = &mut log_file { + let _ = writeln!(log_file, "{msg}"); + } + }; + + log(format_args!("{APP_PATH} was removed.")); + + // TODO: This check can be removed once we no longer care about downgrades to + // versions that didn't unload the daemon in preinstall instead of postinstall. + // E.g., a year after we released version 2025.7 + if mullvad_installer_is_running() { + log(format_args!( + "Found installer process. Ignoring app removal" + )); + return Ok(()); + } else { + log(format_args!( + "Did not find installer process. Running uninstaller" + )); + } + + tokio::fs::write(UNINSTALL_SCRIPT_PATH, UNINSTALL_SCRIPT) + .await + .context("Failed to write uninstall script")?; + + // If reset_firewall errors, log the error and continue anyway. + log(format_args!("Resetting firewall")); + if let Err(error) = reset_firewall() { + log(format_args!("{error:#?}")); + } + + // Remove the current device from the account + log(format_args!("Logging out")); + if let Err(error) = account_manager_handle.logout().await { + log(format_args!("Failed to remove device: {error:#?}")); + } + + // This will kill the daemon. + log(format_args!("Running {UNINSTALL_SCRIPT_PATH:?}")); + let mut cmd = Command::new("/bin/bash"); + cmd + .arg(UNINSTALL_SCRIPT_PATH) + // Don't prompt for confirmation. + .arg("--yes") + // Spawn as its own process group. + // This prevents the command from being killed when the daemon is killed. + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + if let Some(log_file) = log_file { + cmd.stdout(log_file.try_clone().context("Failed to clone log fd")?); + cmd.stderr(log_file); + }; + cmd.spawn().context("Failed to spawn uninstaller script")?; + + Ok(()) +} + +fn reset_firewall() -> anyhow::Result<()> { + talpid_core::firewall::Firewall::new() + .context("Failed to create firewall instance")? + .reset_policy() + .context("Failed to reset firewall policy") +} + +/// Figure out if a Mullvad installer is active +fn mullvad_installer_is_running() -> bool { + let Ok(pids) = list_pids() else { + // If we can't retrieve any PIDs, assume installer isn't running + return false; + }; + pids.into_iter() + .any(|pid| process_has_mullvad_installer(pid).unwrap_or(false)) +} + +/// Figure out if the 'pid' process is privileged and has a file open that matches a Mullvad pkg +fn process_has_mullvad_installer(pid: pid_t) -> io::Result<bool> { + // Ignore process if it isn't running as root + // This is because the filename is easily spoofable + if process_bsdinfo(pid)?.pbi_uid != 0 { + return Ok(false); + } + + // We're only interested in installer processes + let process_path = process_path(pid)?; + if !process_path.starts_with("/System") + || process_path.file_name() != Some(OsStr::new("installd")) + { + return Ok(false); + } + + // Figure out if one of the file descriptors refers to a Mullvad installer + for fd in process_file_descriptors(pid)? { + // Only check vnodes + if fd.proc_fdtype != PROX_FDTYPE_VNODE as u32 { + continue; + } + + let Ok(path) = get_file_desc_vnode_path(pid, &fd) else { + continue; + }; + + // Check if file refers to a Mullvad .pkg + let lower_path = path.to_bytes().to_ascii_lowercase(); + let is_pkg = lower_path.ends_with(b".pkg"); + let seq_to_find = b"mullvad"; + + if is_pkg + && lower_path + .windows(seq_to_find.len()) + .any(|seq| seq == &seq_to_find[..]) + { + return Ok(true); + } + } + Ok(false) +} diff --git a/talpid-macos/Cargo.toml b/talpid-macos/Cargo.toml index 7868707b4c..7b910f5e6d 100644 --- a/talpid-macos/Cargo.toml +++ b/talpid-macos/Cargo.toml @@ -11,5 +11,5 @@ rust-version.workspace = true workspace = true [target.'cfg(target_os="macos")'.dependencies] -libc = "0.2" +libc = "0.2.172" log = { workspace = true } diff --git a/talpid-macos/src/apsl-header b/talpid-macos/src/apsl-header new file mode 100644 index 0000000000..eebfa36fa6 --- /dev/null +++ b/talpid-macos/src/apsl-header @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Mullvad VPN AB. All rights reserved. + * + * @APPLE_OSREFERENCE_LICENSE_HEADER_START@ + * + * This file contains Original Code and/or Modifications of Original Code + * as defined in and that are subject to the Apple Public Source License + * Version 2.0 (the 'License'). You may not use this file except in + * compliance with the License. The rights granted to you under the License + * may not be used to create, or enable the creation or redistribution of, + * unlawful or unlicensed copies of an Apple operating system, or to + * circumvent, violate, or enable the circumvention or violation of, any + * terms of an Apple operating system software license agreement. + * + * Please obtain a copy of the License at + * http://www.opensource.apple.com/apsl/ and read it before using this file. + * + * The Original Code and all software distributed under the License are + * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER + * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, + * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. + * Please see the License for the specific language governing rights and + * limitations under the License. + * + * @APPLE_OSREFERENCE_LICENSE_HEADER_END@ + */ diff --git a/talpid-macos/src/bindings.rs b/talpid-macos/src/bindings.rs new file mode 100644 index 0000000000..4076cd2c29 --- /dev/null +++ b/talpid-macos/src/bindings.rs @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Mullvad VPN AB. All rights reserved. + * + * @APPLE_OSREFERENCE_LICENSE_HEADER_START@ + * + * This file contains Original Code and/or Modifications of Original Code + * as defined in and that are subject to the Apple Public Source License + * Version 2.0 (the 'License'). You may not use this file except in + * compliance with the License. The rights granted to you under the License + * may not be used to create, or enable the creation or redistribution of, + * unlawful or unlicensed copies of an Apple operating system, or to + * circumvent, violate, or enable the circumvention or violation of, any + * terms of an Apple operating system software license agreement. + * + * Please obtain a copy of the License at + * http://www.opensource.apple.com/apsl/ and read it before using this file. + * + * The Original Code and all software distributed under the License are + * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER + * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, + * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. + * Please see the License for the specific language governing rights and + * limitations under the License. + * + * @APPLE_OSREFERENCE_LICENSE_HEADER_END@ + */ +/* automatically generated by rust-bindgen 0.70.1 */ + +pub const PROX_FDTYPE_VNODE: u32 = 1; +pub const PROC_PIDFDVNODEPATHINFO: u32 = 2; +pub type __uint32_t = ::std::os::raw::c_uint; +pub type __int64_t = ::std::os::raw::c_longlong; +pub type __darwin_gid_t = __uint32_t; +pub type __darwin_off_t = __int64_t; +pub type __darwin_uid_t = __uint32_t; +pub type gid_t = __darwin_gid_t; +pub type off_t = __darwin_off_t; +pub type uid_t = __darwin_uid_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct fsid { + pub val: [i32; 2usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of fsid"][::std::mem::size_of::<fsid>() - 8usize]; + ["Alignment of fsid"][::std::mem::align_of::<fsid>() - 4usize]; + ["Offset of field: fsid::val"][::std::mem::offset_of!(fsid, val) - 0usize]; +}; +pub type fsid_t = fsid; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct proc_fileinfo { + pub fi_openflags: u32, + pub fi_status: u32, + pub fi_offset: off_t, + pub fi_type: i32, + pub fi_guardflags: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of proc_fileinfo"][::std::mem::size_of::<proc_fileinfo>() - 24usize]; + ["Alignment of proc_fileinfo"][::std::mem::align_of::<proc_fileinfo>() - 8usize]; + ["Offset of field: proc_fileinfo::fi_openflags"] + [::std::mem::offset_of!(proc_fileinfo, fi_openflags) - 0usize]; + ["Offset of field: proc_fileinfo::fi_status"] + [::std::mem::offset_of!(proc_fileinfo, fi_status) - 4usize]; + ["Offset of field: proc_fileinfo::fi_offset"] + [::std::mem::offset_of!(proc_fileinfo, fi_offset) - 8usize]; + ["Offset of field: proc_fileinfo::fi_type"] + [::std::mem::offset_of!(proc_fileinfo, fi_type) - 16usize]; + ["Offset of field: proc_fileinfo::fi_guardflags"] + [::std::mem::offset_of!(proc_fileinfo, fi_guardflags) - 20usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct vinfo_stat { + pub vst_dev: u32, + pub vst_mode: u16, + pub vst_nlink: u16, + pub vst_ino: u64, + pub vst_uid: uid_t, + pub vst_gid: gid_t, + pub vst_atime: i64, + pub vst_atimensec: i64, + pub vst_mtime: i64, + pub vst_mtimensec: i64, + pub vst_ctime: i64, + pub vst_ctimensec: i64, + pub vst_birthtime: i64, + pub vst_birthtimensec: i64, + pub vst_size: off_t, + pub vst_blocks: i64, + pub vst_blksize: i32, + pub vst_flags: u32, + pub vst_gen: u32, + pub vst_rdev: u32, + pub vst_qspare: [i64; 2usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of vinfo_stat"][::std::mem::size_of::<vinfo_stat>() - 136usize]; + ["Alignment of vinfo_stat"][::std::mem::align_of::<vinfo_stat>() - 8usize]; + ["Offset of field: vinfo_stat::vst_dev"][::std::mem::offset_of!(vinfo_stat, vst_dev) - 0usize]; + ["Offset of field: vinfo_stat::vst_mode"] + [::std::mem::offset_of!(vinfo_stat, vst_mode) - 4usize]; + ["Offset of field: vinfo_stat::vst_nlink"] + [::std::mem::offset_of!(vinfo_stat, vst_nlink) - 6usize]; + ["Offset of field: vinfo_stat::vst_ino"][::std::mem::offset_of!(vinfo_stat, vst_ino) - 8usize]; + ["Offset of field: vinfo_stat::vst_uid"][::std::mem::offset_of!(vinfo_stat, vst_uid) - 16usize]; + ["Offset of field: vinfo_stat::vst_gid"][::std::mem::offset_of!(vinfo_stat, vst_gid) - 20usize]; + ["Offset of field: vinfo_stat::vst_atime"] + [::std::mem::offset_of!(vinfo_stat, vst_atime) - 24usize]; + ["Offset of field: vinfo_stat::vst_atimensec"] + [::std::mem::offset_of!(vinfo_stat, vst_atimensec) - 32usize]; + ["Offset of field: vinfo_stat::vst_mtime"] + [::std::mem::offset_of!(vinfo_stat, vst_mtime) - 40usize]; + ["Offset of field: vinfo_stat::vst_mtimensec"] + [::std::mem::offset_of!(vinfo_stat, vst_mtimensec) - 48usize]; + ["Offset of field: vinfo_stat::vst_ctime"] + [::std::mem::offset_of!(vinfo_stat, vst_ctime) - 56usize]; + ["Offset of field: vinfo_stat::vst_ctimensec"] + [::std::mem::offset_of!(vinfo_stat, vst_ctimensec) - 64usize]; + ["Offset of field: vinfo_stat::vst_birthtime"] + [::std::mem::offset_of!(vinfo_stat, vst_birthtime) - 72usize]; + ["Offset of field: vinfo_stat::vst_birthtimensec"] + [::std::mem::offset_of!(vinfo_stat, vst_birthtimensec) - 80usize]; + ["Offset of field: vinfo_stat::vst_size"] + [::std::mem::offset_of!(vinfo_stat, vst_size) - 88usize]; + ["Offset of field: vinfo_stat::vst_blocks"] + [::std::mem::offset_of!(vinfo_stat, vst_blocks) - 96usize]; + ["Offset of field: vinfo_stat::vst_blksize"] + [::std::mem::offset_of!(vinfo_stat, vst_blksize) - 104usize]; + ["Offset of field: vinfo_stat::vst_flags"] + [::std::mem::offset_of!(vinfo_stat, vst_flags) - 108usize]; + ["Offset of field: vinfo_stat::vst_gen"] + [::std::mem::offset_of!(vinfo_stat, vst_gen) - 112usize]; + ["Offset of field: vinfo_stat::vst_rdev"] + [::std::mem::offset_of!(vinfo_stat, vst_rdev) - 116usize]; + ["Offset of field: vinfo_stat::vst_qspare"] + [::std::mem::offset_of!(vinfo_stat, vst_qspare) - 120usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct vnode_info { + pub vi_stat: vinfo_stat, + pub vi_type: ::std::os::raw::c_int, + pub vi_pad: ::std::os::raw::c_int, + pub vi_fsid: fsid_t, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of vnode_info"][::std::mem::size_of::<vnode_info>() - 152usize]; + ["Alignment of vnode_info"][::std::mem::align_of::<vnode_info>() - 8usize]; + ["Offset of field: vnode_info::vi_stat"][::std::mem::offset_of!(vnode_info, vi_stat) - 0usize]; + ["Offset of field: vnode_info::vi_type"] + [::std::mem::offset_of!(vnode_info, vi_type) - 136usize]; + ["Offset of field: vnode_info::vi_pad"][::std::mem::offset_of!(vnode_info, vi_pad) - 140usize]; + ["Offset of field: vnode_info::vi_fsid"] + [::std::mem::offset_of!(vnode_info, vi_fsid) - 144usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct vnode_info_path { + pub vip_vi: vnode_info, + pub vip_path: [::std::os::raw::c_char; 1024usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of vnode_info_path"][::std::mem::size_of::<vnode_info_path>() - 1176usize]; + ["Alignment of vnode_info_path"][::std::mem::align_of::<vnode_info_path>() - 8usize]; + ["Offset of field: vnode_info_path::vip_vi"] + [::std::mem::offset_of!(vnode_info_path, vip_vi) - 0usize]; + ["Offset of field: vnode_info_path::vip_path"] + [::std::mem::offset_of!(vnode_info_path, vip_path) - 152usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct vnode_fdinfowithpath { + pub pfi: proc_fileinfo, + pub pvip: vnode_info_path, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of vnode_fdinfowithpath"][::std::mem::size_of::<vnode_fdinfowithpath>() - 1200usize]; + ["Alignment of vnode_fdinfowithpath"][::std::mem::align_of::<vnode_fdinfowithpath>() - 8usize]; + ["Offset of field: vnode_fdinfowithpath::pfi"] + [::std::mem::offset_of!(vnode_fdinfowithpath, pfi) - 0usize]; + ["Offset of field: vnode_fdinfowithpath::pvip"] + [::std::mem::offset_of!(vnode_fdinfowithpath, pvip) - 24usize]; +}; diff --git a/talpid-macos/src/generate-bindings.sh b/talpid-macos/src/generate-bindings.sh new file mode 100755 index 0000000000..5aac30142c --- /dev/null +++ b/talpid-macos/src/generate-bindings.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# This generates new bindings from 'proc_info.h'. +# bindgen is required: cargo install bindgen-cli + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +MACOS_SDK_PATH="$(xcrun --sdk macosx --show-sdk-path)" +PROC_INFO_PATH="$MACOS_SDK_PATH/usr/include/sys/proc_info.h" + +cp ./apsl-header ./bindings.rs + +bindgen "$PROC_INFO_PATH" \ + --allowlist-item "^PROC_PIDFDVNODEPATHINFO" \ + --allowlist-item "^PROX_FDTYPE_VNODE" \ + --allowlist-item "^vnode_fdinfowithpath" \ + >> ./bindings.rs diff --git a/talpid-macos/src/lib.rs b/talpid-macos/src/lib.rs index 964dd689e0..5a282660d3 100644 --- a/talpid-macos/src/lib.rs +++ b/talpid-macos/src/lib.rs @@ -5,3 +5,7 @@ /// Processes pub mod process; + +/// OS bindings generated by 'generate_bindings.rs' +#[allow(non_camel_case_types)] +mod bindings; diff --git a/talpid-macos/src/process.rs b/talpid-macos/src/process.rs index 69722e054c..0e72d2f7ca 100644 --- a/talpid-macos/src/process.rs +++ b/talpid-macos/src/process.rs @@ -1,9 +1,16 @@ -use libc::{c_void, pid_t, proc_listallpids, proc_pidpath}; +use libc::{ + c_void, pid_t, proc_bsdinfo, proc_fdinfo, proc_listallpids, proc_pidfdinfo, proc_pidinfo, + proc_pidpath, PROC_PIDLISTFDS, PROC_PIDTBSDINFO, +}; use std::{ + ffi::{c_int, CStr, CString}, io, path::{Path, PathBuf}, + ptr, }; +use crate::bindings::{vnode_fdinfowithpath, PROC_PIDFDVNODEPATHINFO, PROX_FDTYPE_VNODE}; + /// Return the first process identifier matching a specified path, if one exists. pub fn pid_of_path(find_path: impl AsRef<Path>) -> Option<pid_t> { match list_pids() { @@ -65,3 +72,89 @@ pub fn process_path(pid: pid_t) -> io::Result<PathBuf> { .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid process path"))?, )) } + +/// Return file descriptors associated with `pid` +// reference: lsof source code: https://github.com/apple-oss-distributions/lsof/blob/c48c28f51e82a5d682a4459bdbdc42face73468f/lsof/dialects/darwin/libproc/dproc.c#L623 +pub fn process_file_descriptors(pid: pid_t) -> io::Result<Vec<proc_fdinfo>> { + // SAFETY: Passing nil arguments is safe and returns the required buffer size for the given pid + let fds_buf_size = unsafe { proc_pidinfo(pid, PROC_PIDLISTFDS, 0, ptr::null_mut(), 0) }; + + if fds_buf_size < 0 { + return Err(io::Error::last_os_error()); + } + + let fds_num = fds_buf_size as usize / std::mem::size_of::<proc_fdinfo>(); + + // SAFETY: This is a pure C struct which we're expected to zero-initialize + let empty_fdinfo = unsafe { std::mem::zeroed::<proc_fdinfo>() }; + + let mut file_desc_buf = vec![empty_fdinfo; fds_num as usize]; + + // SAFETY: fds_buf is large enough to contain `fds_num` + let fds_buf_size = unsafe { + proc_pidinfo( + pid, + PROC_PIDLISTFDS, + 0, + file_desc_buf.as_mut_ptr() as _, + fds_buf_size as c_int, + ) + }; + if fds_buf_size < 0 { + return Err(io::Error::last_os_error()); + } + + // Truncate file descriptor vector based on new count + let new_fds_num = fds_buf_size as usize / std::mem::size_of::<proc_fdinfo>(); + assert!(new_fds_num <= fds_num); + file_desc_buf.truncate(new_fds_num); + + Ok(file_desc_buf) +} + +/// Return the file path that belongs to a vnode file descriptor type for a given process. +pub fn get_file_desc_vnode_path(pid: pid_t, info: &proc_fdinfo) -> io::Result<CString> { + assert!(info.proc_fdtype == PROX_FDTYPE_VNODE as _); + + // SAFETY: This is a pure C struct which we're expected to zero-initialize + let mut vnode: vnode_fdinfowithpath = unsafe { std::mem::zeroed() }; + + // SAFETY: Our buffer is initialized, aligned, and large enough to contain the result. + let err = unsafe { + proc_pidfdinfo( + pid, + info.proc_fd, + PROC_PIDFDVNODEPATHINFO as _, + &mut vnode as *mut _ as _, + std::mem::size_of_val(&vnode) as _, + ) + }; + if err <= 0 { + return Err(io::Error::last_os_error()); + } + + // SAFETY: `proc_pidfdinfo` returned a null-terminated path here + let cstr_path = unsafe { CStr::from_ptr(vnode.pvip.vip_path.as_ptr()) }; + Ok(cstr_path.to_owned()) +} + +/// Return the 'proc_bsdinfo' associated with a given process identifier +pub fn process_bsdinfo(pid: pid_t) -> io::Result<proc_bsdinfo> { + // SAFETY: This is a pure C struct which we're expected to zero-initialize + let mut info: proc_bsdinfo = unsafe { std::mem::zeroed() }; + + // SAFETY: Our buffer (info) is initialized, aligned, and large enough to contain the result. + let err = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO as _, + 0, + &mut info as *mut proc_bsdinfo as *mut c_void, + std::mem::size_of_val(&info) as _, + ) + }; + if err <= 0 { + return Err(io::Error::last_os_error()); + } + Ok(info) +} diff --git a/test/test-manager/src/tests/install.rs b/test/test-manager/src/tests/install.rs index 8c257d1899..b2620842c3 100644 --- a/test/test-manager/src/tests/install.rs +++ b/test/test-manager/src/tests/install.rs @@ -237,6 +237,65 @@ pub async fn test_uninstall_app( Ok(()) } +/// Test that the Mullvad daemon cleans itself up when deleted by being dragged and dropped into the +/// bin. +#[test_function(priority = -160, target_os = "macos")] +pub async fn test_detect_app_removal( + _ctx: TestContext, + rpc: ServiceClient, + mut mullvad_client: MullvadProxyClient, +) -> anyhow::Result<()> { + let uninstalled_device = mullvad_client + .get_device() + .await + .context("failed to get device data")? + .logged_in() + .context("Client is not logged in to a valid account")? + .device + .id; + + rpc.exec("/bin/rm", ["-rf", "/Applications/Mullvad VPN.app"]) + .await + .context("Failed to delete Mullvad app")?; + + let mut attempt = 0; + const MAX_ATTEMPTS: usize = 30; + + loop { + let app_traces = rpc.find_mullvad_app_traces().await?; + + if app_traces.is_empty() { + assert_eq!( + rpc.mullvad_daemon_get_status().await?, + ServiceStatus::NotRunning, + "daemon should be stopped after cleanup" + ); + + // verify that device was removed + let device_client = super::account::new_device_client() + .await + .context("Failed to create device client")?; + let devices = super::account::list_devices_with_retries(&device_client) + .await + .expect("failed to list devices"); + assert!( + !devices.iter().any(|device| device.id == uninstalled_device), + "device id {} still exists after uninstall", + uninstalled_device, + ); + + return Ok(()); + } + + attempt += 1; + if attempt == MAX_ATTEMPTS { + bail!("Uninstall script didn't run when app was removed"); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + /// Install the multiple times starting from a connected state with auto-connect /// disabled, failing if the app starts in a disconnected state. /// |
