feat(windows): Elevate with UAC automatically on startup (#2913)

Automatically write the wintun.dll file on startup and then detect
whether we need to elevate to admin privileges.

I check for privileges by making a test tunnel, so I did #2758 as part
of this, which bundles the DLL inside the exe, and then the exe deploys
it.

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2023-12-18 17:54:45 -06:00
committed by GitHub
parent a40d550bb0
commit 64f76f5edb
14 changed files with 238 additions and 163 deletions

1
rust/Cargo.lock generated
View File

@@ -1989,6 +1989,7 @@ dependencies = [
"connlib-shared",
"firezone-cli-utils",
"keyring",
"ring 0.17.7",
"secrecy",
"serde",
"serde_json",

View File

@@ -8,8 +8,6 @@ use std::{
};
use tokio::sync::mpsc;
const TUNNEL_UUID: &str = "e9245bc1-b8c1-44ca-ab1d-c6aad4f13b9c";
// TODO: Make sure all these get dropped gracefully on disconnect
pub struct Tun {
_adapter: Arc<wintun::Adapter>,
@@ -21,25 +19,24 @@ pub struct Tun {
impl Tun {
pub fn new(config: &InterfaceConfig) -> Result<Self> {
const TUNNEL_UUID: &str = "e9245bc1-b8c1-44ca-ab1d-c6aad4f13b9c";
const TUNNEL_NAME: &str = "Firezone Tunnel";
// The unsafe is here because we're loading a DLL from disk and it has arbitrary C code in it.
// As a defense, we could verify the hash before loading it. This would protect against accidental corruption, but not against attacks. (Because of TOCTOU)
// The Windows client, in `wintun_install` hashes the DLL at startup, before calling connlib, so it's unlikely for the DLL to be accidentally corrupted by the time we get here.
let wintun = unsafe { wintun::load_from_path("./wintun.dll") }?;
let uuid = uuid::Uuid::from_str(TUNNEL_UUID)?;
let adapter = match wintun::Adapter::create(
&wintun,
"Firezone",
"Firezone Tunnel",
Some(uuid.as_u128()),
) {
Ok(x) => x,
Err(e) => {
tracing::error!(
"wintun::Adapter::create failed, probably need admin powers: {}",
e
);
return Err(e.into());
}
};
let adapter =
match wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())) {
Ok(x) => x,
Err(e) => {
tracing::error!(
"wintun::Adapter::create failed, probably need admin powers: {}",
e
);
return Err(e.into());
}
};
adapter.set_address(config.ipv4)?;

View File

@@ -15,8 +15,22 @@ If the client stops running while signed in, then the token may be stored in Win
# Device ID
- [ ] Given the AppData dir for the client doesn't exist, when you run the client, then the client will generate a UUIDv4 (random) and store it in AppData
- [ ] Given the UUID is stored in AppData, when you run the client, then it will load the UUID and compute its SHA256 hash
- [ ] Given the client is running, when a session starts, then the hexadecimal SHA256 hash of the UUID will be used as the device ID
- [ ] Given the UUID is stored in AppData, when you run the client, then it will load the UUID
- [ ] Given the client is running, when a session starts, then the UUID will be used as the device ID
# DLL
- [ ] Given wintun.dll does not exist in the same directory as the exe, when you run the exe, then it will create wintun.dll
- [ ] Given wintun.dll has extra bytes appended to the end, when you run the exe, then it will re-write wintun.dll
- [ ] Given wintun.dll does not have the expected SHA256, when you run the exe, then it will re-write wintun.dll
- [ ] Given wintun.dll has the expected SHA256, when you run the exe, then it will not re-write wintun.dll
# Launching
- [ ] Given the client is not running, when you open a deep link, then the client will not start
- [ ] Given the client is not running, when you run the exe with normal privileges, then the client will unpack wintun.dll next to the exe if needed, try to start a bogus probe tunnel, and re-launch itself with elevated privilege
- [ ] Given the client is not running, when you run the exe as admin, then the client will unpack wintun.dll next to the exe if needed, try to start a bogus probe tunnel, and keep running
- [ ] Given the client is running, when you open a deep link as part of sign-in, then the client will sign in without a second UAC prompt
# Permissions

View File

@@ -16,6 +16,7 @@ connlib-client-shared = { workspace = true }
connlib-shared = { workspace = true }
firezone-cli-utils = { workspace = true }
keyring = "2.0.5"
ring = "0.17"
secrecy.workspace = true
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -1,18 +1,6 @@
fn main() -> anyhow::Result<()> {
let win = tauri_build::WindowsAttributes::new().app_manifest(WINDOWS_MANIFEST);
let win = tauri_build::WindowsAttributes::new();
let attr = tauri_build::Attributes::new().windows_attributes(win);
tauri_build::try_build(attr)?;
Ok(())
}
// If we ask for admin privilege in the manifest, we can't run in Cygwin,
// which makes debugging hard on my dev system.
// So always ask for it in Release, which is simpler for users, and in Release
// mode we run as a GUI so we lose stdout/stderr anyway.
// If you need admin privileges for debugging, you can right-click the debug exe.
#[cfg(debug_assertions)]
const WINDOWS_MANIFEST: &str = include_str!("firezone-windows-client-debug.manifest");
#[cfg(not(debug_assertions))]
const WINDOWS_MANIFEST: &str = include_str!("firezone-windows-client-release.manifest");

View File

@@ -1,14 +0,0 @@
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

View File

@@ -1,22 +0,0 @@
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<!-- Ask Windows to always run us with admin privilege -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@@ -1,18 +1,23 @@
use anyhow::Result;
use clap::Parser;
use cli::CliCommands as Cmd;
use std::{os::windows::process::CommandExt, process::Command};
mod cli;
mod debug_commands;
mod deep_link;
mod device_id;
mod elevation;
mod gui;
mod logging;
mod settings;
mod wintun_install;
/// Prevents a problem where changing the args to `gui::run` breaks static analysis on non-Windows targets, where the gui is stubbed out
#[allow(dead_code)]
pub(crate) struct GuiParams {
/// True if we were re-launched with elevated permissions. If the user launched us directly with elevated permissions, this is false.
flag_elevated: bool,
/// True if we should slow down I/O operations to test how the GUI handles slow I/O
inject_faults: bool,
}
@@ -21,13 +26,41 @@ pub(crate) struct GuiParams {
/// `C:/Users/$USER/AppData/Local/dev.firezone.client`
pub(crate) struct AppLocalDataDir(std::path::PathBuf);
// Hides Powershell's console on Windows
// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub(crate) fn run() -> Result<()> {
let cli = cli::Cli::parse();
match cli.command {
None => gui::run(GuiParams {
inject_faults: cli.inject_faults,
}),
None => {
if elevation::check()? {
// We're already elevated, just run the GUI
gui::run(GuiParams {
flag_elevated: false,
inject_faults: cli.inject_faults,
})
} else {
// We're not elevated, ask Powershell to re-launch us, then exit
let current_exe = tauri_utils::platform::current_exe()?;
if current_exe.display().to_string().contains('\"') {
anyhow::bail!("The exe path must not contain double quotes, it makes it hard to elevate with Powershell");
}
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.arg("-Command")
.arg("Start-Process")
.arg("-FilePath")
.arg(format!(r#""{}""#, current_exe.display()))
.arg("-Verb")
.arg("RunAs")
.arg("-ArgumentList")
.arg("elevated")
.spawn()?;
Ok(())
}
}
Some(Cmd::Debug) => {
println!("debug");
Ok(())
@@ -35,7 +68,26 @@ pub(crate) fn run() -> Result<()> {
Some(Cmd::DebugPipeServer) => debug_commands::pipe_server(),
Some(Cmd::DebugToken) => debug_commands::token(),
Some(Cmd::DebugWintun) => debug_commands::wintun(cli),
// If we already tried to elevate ourselves, don't try again
Some(Cmd::Elevated) => gui::run(GuiParams {
flag_elevated: true,
inject_faults: cli.inject_faults,
}),
Some(Cmd::OpenDeepLink(deep_link)) => debug_commands::open_deep_link(&deep_link.url),
Some(Cmd::RegisterDeepLink) => debug_commands::register_deep_link(),
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
#[test]
fn exe_path() -> Result<()> {
// e.g. `\\\\?\\C:\\cygwin64\\home\\User\\projects\\firezone\\rust\\target\\debug\\deps\\firezone_windows_client-5f44800b2dafef90.exe`
let path = tauri_utils::platform::current_exe()?.display().to_string();
assert!(path.contains("target"));
assert!(!path.contains('\"'), "`{}`", path);
Ok(())
}
}

View File

@@ -15,6 +15,7 @@ pub enum CliCommands {
DebugPipeServer,
DebugToken,
DebugWintun,
Elevated,
OpenDeepLink(DeepLink),
RegisterDeepLink,
}

View File

@@ -4,7 +4,6 @@
use crate::client::cli::Cli;
use anyhow::Result;
use keyring::Entry;
use std::sync::Arc;
use tokio::runtime::Runtime;
// TODO: In tauri-plugin-deep-link, this is the identifier in tauri.conf.json
@@ -35,10 +34,7 @@ pub fn token() -> Result<()> {
}
pub fn open_deep_link(path: &url::Url) -> Result<()> {
let subscriber = tracing_subscriber::FmtSubscriber::builder()
.with_max_level(tracing::Level::TRACE)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
tracing_subscriber::fmt::init();
let rt = Runtime::new()?;
rt.block_on(crate::client::deep_link::open(PIPE_NAME, path))?;
@@ -49,10 +45,7 @@ pub fn open_deep_link(path: &url::Url) -> Result<()> {
// although I believe it's considered best practice on Windows to use named pipes for
// single-instance apps.
pub fn pipe_server() -> Result<()> {
let subscriber = tracing_subscriber::FmtSubscriber::builder()
.with_max_level(tracing::Level::TRACE)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
tracing_subscriber::fmt::init();
let rt = Runtime::new()?;
rt.block_on(async {
@@ -71,58 +64,12 @@ pub fn register_deep_link() -> Result<()> {
}
pub fn wintun(_: Cli) -> Result<()> {
for _ in 0..3 {
println!("Creating adapter...");
test_wintun_once()?;
tracing_subscriber::fmt::init();
if crate::client::elevation::check()? {
tracing::info!("Elevated");
} else {
tracing::warn!("Not elevated")
}
Ok(())
}
fn test_wintun_once() -> Result<()> {
//Must be run as Administrator because we create network adapters
//Load the wintun dll file so that we can call the underlying C functions
//Unsafe because we are loading an arbitrary dll file
let wintun = unsafe { wintun::load_from_path("./wintun.dll") }?;
//Try to open an adapter with the name "Demo"
let adapter = match wintun::Adapter::open(&wintun, "Demo") {
Ok(a) => a,
Err(_) => {
//If loading failed (most likely it didn't exist), create a new one
wintun::Adapter::create(&wintun, "Demo", "Example manor hatch stash", None)?
}
};
//Specify the size of the ring buffer the wintun driver should use.
let session = Arc::new(adapter.start_session(wintun::MAX_RING_CAPACITY)?);
//Get a 20 byte packet from the ring buffer
let mut packet = session.allocate_send_packet(20)?;
let bytes: &mut [u8] = packet.bytes_mut();
//Write IPV4 version and header length
bytes[0] = 0x40;
//Finish writing IP header
bytes[9] = 0x69;
bytes[10] = 0x04;
bytes[11] = 0x20;
//...
//Send the packet to wintun virtual adapter for processing by the system
session.send_packet(packet);
// Sleep for a few seconds in case we want to confirm the adapter shows up in Device Manager.
std::thread::sleep(std::time::Duration::from_secs(5));
//Stop any readers blocking for data on other threads
//Only needed when a blocking reader is preventing shutdown Ie. it holds an Arc to the
//session, blocking it from being dropped
session.shutdown()?;
//the session is stopped on drop
//drop(session);
//drop(adapter)
//And the adapter closes its resources when dropped
Ok(())
}

View File

@@ -0,0 +1,39 @@
use crate::client::wintun_install;
use std::str::FromStr;
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("couldn't install wintun.dll")]
DllInstall(#[from] wintun_install::Error),
#[error("couldn't load wintun.dll")]
DllLoad,
#[error("UUID parse error - This should be impossible since the UUID is hard-coded")]
Uuid,
}
/// Creates a bogus wintun tunnel to check whether we have permissions to create wintun tunnels.
/// Extracts wintun.dll if needed.
///
/// Returns true if already elevated, false if not elevated, error if we can't be sure
pub(crate) fn check() -> Result<bool, Error> {
// Almost the same as the code in tun_windows.rs in connlib
const TUNNEL_UUID: &str = "72228ef4-cb84-4ca5-a4e6-3f8636e75757";
const TUNNEL_NAME: &str = "Firezone Elevation Check";
match wintun_install::ensure_dll() {
Ok(_) => {}
Err(wintun_install::Error::PermissionDenied) => return Ok(false),
Err(e) => return Err(Error::DllInstall(e)),
}
// The unsafe is here because we're loading a DLL from disk and it has arbitrary C code in it.
// TODO: As a defense, we could verify the hash before loading it. This would protect against accidental corruption, but not against attacks. (Because of TOCTOU)
let wintun = unsafe { wintun::load_from_path("./wintun.dll") }.map_err(|_| Error::DllLoad)?;
let uuid = uuid::Uuid::from_str(TUNNEL_UUID).map_err(|_| Error::Uuid)?;
// Wintun hides the exact Windows error, so let's assume the only way Adapter::create can fail is if we're not elevated.
if wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())).is_err() {
return Ok(false);
}
Ok(true)
}

View File

@@ -41,7 +41,10 @@ const TAURI_ID: &str = "dev.firezone.client";
/// Runs the Tauri GUI and returns on exit or unrecoverable error
pub(crate) fn run(params: client::GuiParams) -> Result<()> {
let client::GuiParams { inject_faults } = params;
let client::GuiParams {
flag_elevated,
inject_faults,
} = params;
// Needed for the deep link server
let rt = tokio::runtime::Runtime::new()?;
@@ -100,7 +103,7 @@ pub(crate) fn run(params: client::GuiParams) -> Result<()> {
}
}
})
.setup(|app| {
.setup(move |app| {
// Change to data dir so the file logger will write there and not in System32 if we're launching from an app link
let cwd = app_local_data_dir(&app.handle())?.0.join("data");
std::fs::create_dir_all(&cwd)?;
@@ -114,6 +117,8 @@ pub(crate) fn run(params: client::GuiParams) -> Result<()> {
// It's hard to set it up before Tauri's setup, because Tauri knows where all the config and data go in AppData and I don't want to replicate their logic.
let logging_handles = client::logging::setup(&advanced_settings.log_filter)?;
tracing::info!("started log");
// I checked this on my dev system to make sure Powershell is doing what I expect and passing the argument back to us after relaunch
tracing::debug!("flag_elevated: {flag_elevated}");
let app_handle = app.handle();
let _ctlr_task = tokio::spawn(async move {

View File

@@ -0,0 +1,94 @@
//! "Installs" wintun.dll at runtime by copying it into whatever folder the exe is in
use ring::digest;
use std::{
fs,
io::{self, Read},
path::Path,
};
struct DllBytes {
/// Bytes embedded in the client with `include_bytes`
bytes: &'static [u8],
/// Expected SHA256 hash
expected_sha256: &'static str,
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("current exe path unknown")]
CurrentExePathUnknown,
#[error("permission denied")]
PermissionDenied,
#[error("platform not supported")]
PlatformNotSupported,
#[error("write failed: `{0:?}`")]
WriteFailed(io::Error),
}
/// Installs the DLL alongside the current exe, if needed
/// The reason not to do it in the current working dir is that deep links may launch
/// with a current working dir of `C:\Windows\System32`
/// The reason not to do it in AppData is that learning our AppData path before Tauri
/// setup is difficult.
/// The reason not to do it in `C:\Program Files` is that running in portable mode
/// is useful for development, even though it's not supported for production.
pub(crate) fn ensure_dll() -> Result<(), Error> {
let dll_bytes = get_dll_bytes().ok_or(Error::PlatformNotSupported)?;
let path = tauri_utils::platform::current_exe()
.map_err(|_| Error::CurrentExePathUnknown)?
.with_file_name("wintun.dll");
tracing::debug!("wintun.dll path = {path:?}");
// This hash check is not meant to protect against attacks. It only lets us skip redundant disk writes, and it updates the DLL if needed.
if !dll_already_exists(&path, &dll_bytes) {
fs::write(&path, dll_bytes.bytes).map_err(|e| match e.kind() {
io::ErrorKind::PermissionDenied => Error::PermissionDenied,
_ => Error::WriteFailed(e),
})?;
}
Ok(())
}
fn dll_already_exists(path: &Path, dll_bytes: &DllBytes) -> bool {
let mut f = match fs::File::open(path) {
Err(_) => return false,
Ok(x) => x,
};
let actual_len = usize::try_from(f.metadata().unwrap().len()).unwrap();
let expected_len = dll_bytes.bytes.len();
// If the dll is 100 MB instead of 0.5 MB, this allows us to skip a 100 MB read
if actual_len != expected_len {
return false;
}
let mut buf = vec![0u8; expected_len];
if f.read_exact(&mut buf).is_err() {
return false;
}
let expected = ring::test::from_hex(dll_bytes.expected_sha256).unwrap();
let actual = digest::digest(&digest::SHA256, &buf);
expected == actual.as_ref()
}
/// Returns the platform-specific bytes of wintun.dll, or None if we don't support the compiled platform.
fn get_dll_bytes() -> Option<DllBytes> {
get_platform_dll_bytes()
}
#[cfg(target_arch = "x86_64")]
fn get_platform_dll_bytes() -> Option<DllBytes> {
Some(DllBytes {
bytes: include_bytes!("../../../wintun/bin/amd64/wintun.dll"),
expected_sha256: "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce",
})
}
#[cfg(target_arch = "aarch64")]
fn get_platform_dll_bytes() -> Option<DllBytes> {
// wintun supports aarch64 but it's not in the Firezone repo yet
None
}

View File

@@ -1,28 +0,0 @@
//! "Installs" wintun.dll at runtime by copying it into whatever folder the exe is in
pub(crate) struct _DllBytes {
/// Bytes embedded in the client with `include_bytes`
bytes: &'static [u8],
/// Expected SHA256 hash
expected_sha256: &'static str,
}
/// Returns the platform-specific bytes of wintun.dll, or None if we don't support the compiled platform.
pub(crate) fn _get_dll_bytes() -> Option<_DllBytes> {
_get_platform_dll_bytes()
}
#[cfg(target_arch = "x86_64")]
fn _get_platform_dll_bytes() -> Option<_DllBytes> {
// SHA256 e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce
Some(_DllBytes {
bytes: include_bytes!("../../wintun/bin/amd64/wintun.dll"),
expected_sha256: "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce",
})
}
#[cfg(target_arch = "aarch64")]
fn _get_platform_dll_bytes() -> Option<&'static [u8]> {
// wintun supports aarch64 but it's not in the Firezone repo yet
None
}