summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-04-23 13:06:53 +0200
committerDavid Lönnhager <david.l@mullvad.net>2025-04-23 13:06:53 +0200
commit4e5168734f69ad696a84a8436b5dda2712e0266c (patch)
tree053dc47006a7de052d4dfb158695a1c56f7b4e32
parent4c5dbdce39e95206b7cbb85f47318aafa3d2c97e (diff)
parentb958ef269d4ba830056b48c9206c6fd663c25339 (diff)
downloadmullvadvpn-4e5168734f69ad696a84a8436b5dda2712e0266c.tar.xz
mullvadvpn-4e5168734f69ad696a84a8436b5dda2712e0266c.zip
Merge branch 'detect-macos-delete'
-rw-r--r--CHANGELOG.md9
-rw-r--r--Cargo.lock79
-rwxr-xr-xdist-assets/pkg-scripts/postinstall12
-rwxr-xr-xdist-assets/pkg-scripts/preinstall11
-rwxr-xr-xdist-assets/uninstall_macos.sh29
-rw-r--r--mullvad-daemon/Cargo.toml2
-rw-r--r--mullvad-daemon/src/lib.rs10
-rw-r--r--mullvad-daemon/src/macos.rs195
-rw-r--r--talpid-macos/Cargo.toml2
-rw-r--r--talpid-macos/src/apsl-header27
-rw-r--r--talpid-macos/src/bindings.rs192
-rwxr-xr-xtalpid-macos/src/generate-bindings.sh20
-rw-r--r--talpid-macos/src/lib.rs4
-rw-r--r--talpid-macos/src/process.rs95
-rw-r--r--test/test-manager/src/tests/install.rs59
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.
///