From 5368cca65d4774720886dfd21a6ff37729df445d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:39:02 +0200 Subject: [PATCH 1/3] mason: implement basal-launch crate, AppContainer profile, and Windows confinement --- .cargo/config.toml | 3 + Cargo.lock | 8 + Cargo.toml | 2 + crates/basal-launch/Cargo.toml | 33 ++ crates/basal-launch/src/lib.rs | 9 + crates/basal-launch/src/windows/deviation.rs | 159 ++++++ crates/basal-launch/src/windows/error.rs | 108 ++++ crates/basal-launch/src/windows/job.rs | 135 +++++ crates/basal-launch/src/windows/launch.rs | 434 +++++++++++++++ crates/basal-launch/src/windows/mod.rs | 121 +++++ crates/basal-launch/src/windows/native.rs | 245 +++++++++ crates/basal-launch/src/windows/process.rs | 157 ++++++ crates/basal-launch/src/windows/profile.rs | 309 +++++++++++ crates/basal-launch/src/windows/token.rs | 541 +++++++++++++++++++ 14 files changed, 2264 insertions(+) create mode 100644 crates/basal-launch/Cargo.toml create mode 100644 crates/basal-launch/src/lib.rs create mode 100644 crates/basal-launch/src/windows/deviation.rs create mode 100644 crates/basal-launch/src/windows/error.rs create mode 100644 crates/basal-launch/src/windows/job.rs create mode 100644 crates/basal-launch/src/windows/launch.rs create mode 100644 crates/basal-launch/src/windows/mod.rs create mode 100644 crates/basal-launch/src/windows/native.rs create mode 100644 crates/basal-launch/src/windows/process.rs create mode 100644 crates/basal-launch/src/windows/profile.rs create mode 100644 crates/basal-launch/src/windows/token.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 78cd64f..0e4fab3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,6 @@ [env] # Allocation accounting is unused; its global mutex serializes SQLite callers. LIBSQLITE3_FLAGS = "-DSQLITE_DEFAULT_MEMSTATUS=0" + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/Cargo.lock b/Cargo.lock index 75a7c6a..f0fe5da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,14 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "basal-launch" +version = "0.1.0" +dependencies = [ + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "basal-module" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f9ee08a..aabcda6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/basal-core", "crates/basal-testkit", "crates/basal-module", + "crates/basal-launch", "crates/basal-rig", ] @@ -20,6 +21,7 @@ rust-version = "1.88" # Dependencies do not require sibling checkouts. basal-core = { path = "crates/basal-core" } basal-host = { path = "crates/basal-host" } +basal-launch = { path = "crates/basal-launch" } basal-proto = { path = "crates/basal-proto" } basal-testkit = { path = "crates/basal-testkit" } blake3 = "1.8" diff --git a/crates/basal-launch/Cargo.toml b/crates/basal-launch/Cargo.toml new file mode 100644 index 0000000..bee28aa --- /dev/null +++ b/crates/basal-launch/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "basal-launch" +version = "0.1.0" +description = "Windows process launcher, AppContainer profile and confinement setup." +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +serde = { workspace = true, optional = true } + +[features] +default = [] +serde = ["dep:serde"] + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Registry", + "Win32_System_Diagnostics_Debug", + "Win32_System_Environment", + "Win32_System_LibraryLoader", + "Win32_Storage_FileSystem", + "Win32_System_SystemServices", +] } diff --git a/crates/basal-launch/src/lib.rs b/crates/basal-launch/src/lib.rs new file mode 100644 index 0000000..722c48e --- /dev/null +++ b/crates/basal-launch/src/lib.rs @@ -0,0 +1,9 @@ +//! Process launching, AppContainer profile creation, and confinement configuration for Windows. +//! +//! Off Windows this crate contains no code. + +#[cfg(windows)] +pub mod windows; + +#[cfg(windows)] +pub use windows::*; diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs new file mode 100644 index 0000000..9c5de84 --- /dev/null +++ b/crates/basal-launch/src/windows/deviation.rs @@ -0,0 +1,159 @@ +//! Confinement launch deviation modes and controls. + +use std::fmt; +use std::str::FromStr; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Confinement deviation requested for a spawn. +/// +/// Covers full confinement, LPAC-only control, plain control, and one deviation +/// per worker refusal reason token and per parent pre-resume check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))] +pub enum Deviation { + /// Full production confinement recipe. + Full, + /// Weaker LPAC-only positive control: CreateProcessW, zero capabilities, + /// no restricted primary token, no mitigations, no job. + LpacOnly, + /// Weaker plain positive control: CreateProcessW without AppContainer or mitigations. + Plain, + + // Worker refusal reason token deviations + /// Retains or injects an impersonation thread token before first frame read. + ThreadTokenPresent, + /// Prevents lowering primary token integrity to Untrusted. + IntegrityLowerFailed, + /// Omits LPAC attributes (e.g. WIN://NOALLAPPPKG claim or wrong package SID). + NotLpac, + /// Injects a non-empty capability list into the token. + CapabilitiesPresent, + /// Primary token restricting SIDs are not exactly `{S-1-0-0}`. + RestrictingSidMismatch, + /// Retains an access group as enabled rather than deny-only. + GroupNotDenyOnly, + /// Retains privileges on the primary token instead of clearing them. + PrivilegesPresent, + /// Primary token integrity is not lowered to Untrusted. + IntegrityNotUntrusted, + /// Omits required mitigation policy flags or enables forbidden bits. + MitigationMismatch, + /// Leaks unpermitted handles or omits handle list restriction. + HandleNotAllowed, + + // Parent pre-resume check deviations + /// Intentionally mismatches job limits or flags readback. + JobLimitsMismatch, + /// Omits the job list attribute so the process is not in the owned job. + NotInOwnedJob, + /// Suspended primary token differs from expected birth token attributes. + BirthTokenMismatch, + /// Thread token handle is not closed before resume, or token level differs. + InitialTokenOpen, +} + +impl Deviation { + /// Returns the canonical kebab-case string name of this deviation. + pub const fn as_str(&self) -> &'static str { + match self { + Self::Full => "full", + Self::LpacOnly => "lpac-only", + Self::Plain => "plain", + Self::ThreadTokenPresent => "thread-token-present", + Self::IntegrityLowerFailed => "integrity-lower-failed", + Self::NotLpac => "not-lpac", + Self::CapabilitiesPresent => "capabilities-present", + Self::RestrictingSidMismatch => "restricting-sid-mismatch", + Self::GroupNotDenyOnly => "group-not-deny-only", + Self::PrivilegesPresent => "privileges-present", + Self::IntegrityNotUntrusted => "integrity-not-untrusted", + Self::MitigationMismatch => "mitigation-mismatch", + Self::HandleNotAllowed => "handle-not-allowed", + Self::JobLimitsMismatch => "job-limits-mismatch", + Self::NotInOwnedJob => "not-in-owned-job", + Self::BirthTokenMismatch => "birth-token-mismatch", + Self::InitialTokenOpen => "initial-token-open", + } + } + + /// If this deviation targets a worker startup check, returns the expected + /// exit 70 stderr reason token. + pub const fn worker_reason(&self) -> Option<&'static str> { + match self { + Self::ThreadTokenPresent => Some("thread-token-present"), + Self::IntegrityLowerFailed => Some("integrity-lower-failed"), + Self::NotLpac => Some("not-lpac"), + Self::CapabilitiesPresent => Some("capabilities-present"), + Self::RestrictingSidMismatch => Some("restricting-sid-mismatch"), + Self::GroupNotDenyOnly => Some("group-not-deny-only"), + Self::PrivilegesPresent => Some("privileges-present"), + Self::IntegrityNotUntrusted => Some("integrity-not-untrusted"), + Self::MitigationMismatch => Some("mitigation-mismatch"), + Self::HandleNotAllowed => Some("handle-not-allowed"), + _ => None, + } + } + + /// If this deviation targets a parent pre-resume check, returns the expected + /// parent pre-resume refusal reason. + pub const fn parent_reason(&self) -> Option<&'static str> { + match self { + Self::JobLimitsMismatch => Some("job-limits-mismatch"), + Self::NotInOwnedJob => Some("not-in-owned-job"), + Self::BirthTokenMismatch => Some("birth-token-mismatch"), + Self::InitialTokenOpen => Some("initial-token-open"), + _ => None, + } + } + + /// Returns true if this is one of the positive control modes (Full, LpacOnly, Plain). + pub const fn is_control(&self) -> bool { + matches!(self, Self::Full | Self::LpacOnly | Self::Plain) + } + + /// Returns true if this deviation targets a parent pre-resume check. + pub const fn is_parent_check(&self) -> bool { + self.parent_reason().is_some() + } + + /// Returns true if this deviation targets a worker startup check. + pub const fn is_worker_check(&self) -> bool { + self.worker_reason().is_some() + } +} + +impl fmt::Display for Deviation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for Deviation { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "full" => Ok(Self::Full), + "lpac-only" => Ok(Self::LpacOnly), + "plain" => Ok(Self::Plain), + "thread-token-present" => Ok(Self::ThreadTokenPresent), + "integrity-lower-failed" => Ok(Self::IntegrityLowerFailed), + "not-lpac" => Ok(Self::NotLpac), + "capabilities-present" => Ok(Self::CapabilitiesPresent), + "restricting-sid-mismatch" => Ok(Self::RestrictingSidMismatch), + "group-not-deny-only" => Ok(Self::GroupNotDenyOnly), + "privileges-present" => Ok(Self::PrivilegesPresent), + "integrity-not-untrusted" => Ok(Self::IntegrityNotUntrusted), + "mitigation-mismatch" => Ok(Self::MitigationMismatch), + "handle-not-allowed" => Ok(Self::HandleNotAllowed), + "job-limits-mismatch" => Ok(Self::JobLimitsMismatch), + "not-in-owned-job" => Ok(Self::NotInOwnedJob), + "birth-token-mismatch" => Ok(Self::BirthTokenMismatch), + "initial-token-open" => Ok(Self::InitialTokenOpen), + other => Err(format!("unknown deviation: {other}")), + } + } +} diff --git a/crates/basal-launch/src/windows/error.rs b/crates/basal-launch/src/windows/error.rs new file mode 100644 index 0000000..5e030a0 --- /dev/null +++ b/crates/basal-launch/src/windows/error.rs @@ -0,0 +1,108 @@ +//! Error types for Windows process launching and pre-resume verification. + +use std::fmt; + +/// Errors produced during profile creation, token construction, process launching, +/// or pre-resume attestation. +#[derive(Debug)] +pub enum LaunchError { + /// AppContainer profile could not be created or opened. + AppContainerProfileUnavailable(String), + /// Pre-resume job limits readback did not match the configured confinement or commit limit. + JobLimitsMismatch(String), + /// Pre-resume check found that the suspended child process is not in the owned job. + NotInOwnedJob(String), + /// Suspended primary token differed from the expected birth token configuration. + BirthTokenMismatch(String), + /// Suspended initial thread token could not be verified or closed before resume. + InitialTokenOpen(String), + /// A Win32 API call failed unexpectedly. + Win32 { + api: &'static str, + code: u32, + detail: String, + }, + /// An I/O error occurred during pipe creation or process manipulation. + Io(std::io::Error), + /// Generic launch error with human-readable description. + Other(String), +} + +impl LaunchError { + /// Returns the named refusal reason token if this error corresponds to a standard refusal. + pub fn reason_token(&self) -> Option<&'static str> { + match self { + Self::AppContainerProfileUnavailable(_) => Some("appcontainer-profile-unavailable"), + Self::JobLimitsMismatch(_) => Some("job-limits-mismatch"), + Self::NotInOwnedJob(_) => Some("not-in-owned-job"), + Self::BirthTokenMismatch(_) => Some("birth-token-mismatch"), + Self::InitialTokenOpen(_) => Some("initial-token-open"), + _ => None, + } + } +} + +impl fmt::Display for LaunchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AppContainerProfileUnavailable(d) => { + if d.is_empty() { + write!(f, "appcontainer-profile-unavailable") + } else { + write!(f, "appcontainer-profile-unavailable: {d}") + } + } + Self::JobLimitsMismatch(d) => { + if d.is_empty() { + write!(f, "job-limits-mismatch") + } else { + write!(f, "job-limits-mismatch: {d}") + } + } + Self::NotInOwnedJob(d) => { + if d.is_empty() { + write!(f, "not-in-owned-job") + } else { + write!(f, "not-in-owned-job: {d}") + } + } + Self::BirthTokenMismatch(d) => { + if d.is_empty() { + write!(f, "birth-token-mismatch") + } else { + write!(f, "birth-token-mismatch: {d}") + } + } + Self::InitialTokenOpen(d) => { + if d.is_empty() { + write!(f, "initial-token-open") + } else { + write!(f, "initial-token-open: {d}") + } + } + Self::Win32 { api, code, detail } => { + write!( + f, + "{api} failed with Win32 error {code} (0x{code:08x}): {detail}" + ) + } + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for LaunchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for LaunchError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} diff --git a/crates/basal-launch/src/windows/job.rs b/crates/basal-launch/src/windows/job.rs new file mode 100644 index 0000000..2f0ccd0 --- /dev/null +++ b/crates/basal-launch/src/windows/job.rs @@ -0,0 +1,135 @@ +//! Parent-owned job object setup, limit enforcement, and pre-resume verification. + +use crate::windows::deviation::Deviation; +use crate::windows::error::LaunchError; +use crate::windows::native::{Handle, JOB_OBJECT_UILIMIT_ALL, last_win32_error}; +use std::mem::{size_of, zeroed}; +use std::ptr::null_mut; +use windows_sys::Win32::{Foundation::*, System::JobObjects::*}; + +/// The expected job object limit flags: 0x2508. +/// - `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` (0x2000) +/// - `JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION` (0x400) +/// - `JOB_OBJECT_LIMIT_PROCESS_MEMORY` (0x100) +/// - `JOB_OBJECT_LIMIT_ACTIVE_PROCESS` (0x008) +pub const JOB_LIMIT_FLAGS: u32 = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION + | JOB_OBJECT_LIMIT_PROCESS_MEMORY + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + +/// Creates and configures the parent-owned job object with confinement limits. +pub fn create_confined_job(commit_limit: u64, deviation: Deviation) -> Result { + unsafe { + let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if job.is_null() { + return Err(last_win32_error("CreateJobObjectW")); + } + let job = Handle(job); + + // Job handle must be non-inheritable + SetHandleInformation(job.0, HANDLE_FLAG_INHERIT, 0); + + let flags = JOB_LIMIT_FLAGS; + let mut active_limit = 1; + let process_memory = commit_limit as usize; + + if deviation == Deviation::JobLimitsMismatch { + // Intentionally mismatch limits to trigger job-limits-mismatch refusal + active_limit = 2; + } + + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed(); + limits.BasicLimitInformation.LimitFlags = flags; + limits.BasicLimitInformation.ActiveProcessLimit = active_limit; + limits.ProcessMemoryLimit = process_memory; + + if SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) == 0 + { + return Err(last_win32_error("SetInformationJobObject(limits)")); + } + + let ui = JOBOBJECT_BASIC_UI_RESTRICTIONS { + UIRestrictionsClass: JOB_OBJECT_UILIMIT_ALL, // 0xff + }; + if SetInformationJobObject( + job.0, + JobObjectBasicUIRestrictions, + (&ui as *const JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + ) == 0 + { + return Err(last_win32_error("SetInformationJobObject(UI)")); + } + + Ok(job) + } +} + +/// Reads back the job limits through the owned handle and checks child membership. +/// +/// On mismatch, returns `LaunchError::JobLimitsMismatch` or `LaunchError::NotInOwnedJob`. +pub fn check_job_before_resume( + job: HANDLE, + process: HANDLE, + expected_commit_limit: u64, +) -> Result<(), LaunchError> { + unsafe { + let mut actual: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed(); + if QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut actual as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + null_mut(), + ) == 0 + { + return Err(LaunchError::JobLimitsMismatch( + "failed to query job limits through owned handle".to_string(), + )); + } + + let mut actual_ui: JOBOBJECT_BASIC_UI_RESTRICTIONS = zeroed(); + if QueryInformationJobObject( + job, + JobObjectBasicUIRestrictions, + (&mut actual_ui as *mut JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + null_mut(), + ) == 0 + { + return Err(LaunchError::JobLimitsMismatch( + "failed to query job UI restrictions".to_string(), + )); + } + + let flags = actual.BasicLimitInformation.LimitFlags; + let active = actual.BasicLimitInformation.ActiveProcessLimit; + let mem = actual.ProcessMemoryLimit; + let ui = actual_ui.UIRestrictionsClass; + + if flags != JOB_LIMIT_FLAGS + || active != 1 + || mem != (expected_commit_limit as usize) + || ui != JOB_OBJECT_UILIMIT_ALL + || (flags & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)) != 0 + { + return Err(LaunchError::JobLimitsMismatch(format!( + "flags: 0x{flags:08x} (expected 0x{JOB_LIMIT_FLAGS:08x}), active: {active}, mem: {mem}, ui: 0x{ui:02x}" + ))); + } + + let mut in_job = 0; + if IsProcessInJob(process, job, &mut in_job) == 0 || in_job == 0 { + return Err(LaunchError::NotInOwnedJob( + "child process is not a member of the owned job".to_string(), + )); + } + + Ok(()) + } +} diff --git a/crates/basal-launch/src/windows/launch.rs b/crates/basal-launch/src/windows/launch.rs new file mode 100644 index 0000000..e58c321 --- /dev/null +++ b/crates/basal-launch/src/windows/launch.rs @@ -0,0 +1,434 @@ +//! Launch options and the primary `launch` entry point for Windows processes. + +use crate::windows::deviation::Deviation; +use crate::windows::error::LaunchError; +use crate::windows::job::{check_job_before_resume, create_confined_job}; +use crate::windows::native::*; +use crate::windows::process::{OwnedProcess, WORKER_KILL_CODE}; +use crate::windows::profile::{AppContainerSid, PROFILE_NAME, create_or_open_profile}; +use crate::windows::token::{check_birth_token, check_initial_token_and_close, construct_tokens}; +use std::mem::{size_of, zeroed}; +use std::os::windows::io::FromRawHandle; +use std::path::PathBuf; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::{ + Foundation::*, Security::*, System::JobObjects::TerminateJobObject, System::Threading::*, +}; + +/// Default mitigation policy flags: +/// - ProhibitDynamicCode +/// - MicrosoftSignedOnly +/// - NoRemoteImages +/// - NoLowMandatoryLabelImages +/// - PreferSystem32Images +/// - DisallowWin32kSystemCalls +/// - StrictHandleChecks +/// - DisableExtensionPoints +pub const MITIGATION_POLICY_FLAGS: u64 = (1 << 24) | // StrictHandleChecks + (1 << 28) | // DisallowWin32kSystemCalls + (1 << 32) | // DisableExtensionPoints + (1 << 36) | // ProhibitDynamicCode + (1 << 44) | // MicrosoftSignedOnly + (1 << 52) | // NoRemoteImages + (1 << 56) | // NoLowMandatoryLabelImages + (1 << 60); // PreferSystem32Images + +/// Options configuring process launch under Windows confinement. +#[derive(Debug, Clone)] +pub struct LaunchOptions { + /// Path to the worker binary to execute. + pub binary: PathBuf, + /// Arguments to pass to the process. + pub args: Vec, + /// Explicit package SID. If None, derived from [`PROFILE_NAME`]. + pub package_sid: Option, + /// Commit limit in bytes for the job object. + pub commit_limit: u64, + /// Confinement deviation mode (defaults to [`Deviation::Full`]). + pub deviation: Deviation, + /// Optional environment overrides (defaults to retaining only `SystemRoot`). + pub environment: Option>, +} + +impl LaunchOptions { + /// Creates default launch options with full confinement for the given binary and commit limit. + pub fn new(binary: impl Into, commit_limit: u64) -> Self { + Self { + binary: binary.into(), + args: Vec::new(), + package_sid: None, + commit_limit, + deviation: Deviation::Full, + environment: None, + } + } + + /// Appends an argument. + pub fn arg(&mut self, arg: impl Into) -> &mut Self { + self.args.push(arg.into()); + self + } + + /// Appends multiple arguments. + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + for a in args { + self.args.push(a.into()); + } + self + } + + /// Sets the confinement deviation mode. + pub fn with_deviation(&mut self, deviation: Deviation) -> &mut Self { + self.deviation = deviation; + self + } + + /// Overrides the package SID. + pub fn with_package_sid(&mut self, sid: impl Into) -> &mut Self { + self.package_sid = Some(sid.into()); + self + } +} + +/// Launches a child worker process under Windows confinement according to [`LaunchOptions`]. +pub fn launch(options: &LaunchOptions) -> Result { + unsafe { + // Resolve package profile and SID + let package_sid = if options.deviation == Deviation::Plain { + None + } else if let Some(sid_str) = &options.package_sid { + Some(AppContainerSid::from_string_sid(sid_str)?) + } else { + let profile = create_or_open_profile(PROFILE_NAME)?; + Some(profile.sid().clone()) + }; + + // Construct command line + let mut cmd = format!("\"{}\"", options.binary.display()); + if let Some(sid) = &package_sid { + if !options.args.iter().any(|a| a.starts_with("--package-sid=")) { + cmd.push_str(&format!(" \"--package-sid={}\"", sid.as_str())); + } + } + for arg in &options.args { + cmd.push_str(&format!(" \"{arg}\"")); + } + let mut cmd_wide = wide(&cmd); + + // Create standard I/O pipes + let (child_stdin, parent_stdin) = create_pipe(true)?; + let (parent_stdout, child_stdout) = create_pipe(true)?; + let (parent_stderr, child_stderr) = create_pipe(true)?; + + SetHandleInformation(parent_stdin.0, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(parent_stdout.0, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(parent_stderr.0, HANDLE_FLAG_INHERIT, 0); + + // Prepare environment block: cleared except SystemRoot (or custom) + let env_block: Vec = if let Some(env_vars) = &options.environment { + let mut block = Vec::new(); + for (k, v) in env_vars { + block.extend(format!("{k}={v}\0").encode_utf16()); + } + block.push(0); + block + } else { + let sys_root = + std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + format!("SystemRoot={sys_root}\0\0") + .encode_utf16() + .collect() + }; + + // Handle plain control launch + if options.deviation == Deviation::Plain { + let mut si: STARTUPINFOW = zeroed(); + si.cb = size_of::() as u32; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = child_stdin.0; + si.hStdOutput = child_stdout.0; + si.hStdError = child_stderr.0; + + let mut pi: PROCESS_INFORMATION = zeroed(); + let ok = CreateProcessW( + wide(&options.binary.to_string_lossy()).as_ptr(), + cmd_wide.as_mut_ptr(), + null(), + null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + env_block.as_ptr().cast(), + null(), + &si, + &mut pi, + ); + + if ok == 0 { + return Err(last_win32_error("CreateProcessW(plain)")); + } + + let process = Handle(pi.hProcess); + let thread = Handle(pi.hThread); + + drop(child_stdin); + drop(child_stdout); + drop(child_stderr); + + let stdin_file = std::fs::File::from_raw_handle(parent_stdin.into_raw() as _); + let stdout_file = std::fs::File::from_raw_handle(parent_stdout.into_raw() as _); + let stderr_file = std::fs::File::from_raw_handle(parent_stderr.into_raw() as _); + + if ResumeThread(thread.0) == u32::MAX { + TerminateProcess(process.0, WORKER_KILL_CODE); + return Err(last_win32_error("ResumeThread(plain)")); + } + + return Ok(OwnedProcess::new( + process.into_raw(), + pi.dwProcessId, + Some(stdin_file), + Some(stdout_file), + Some(stderr_file), + null_mut(), + )); + } + + let package_sid = package_sid.unwrap(); + + // Handle LPAC-only control launch + if options.deviation == Deviation::LpacOnly { + let mut attrs = AttributeList::new(3)?; + let handles = [child_stdin.0, child_stdout.0, child_stderr.0]; + attrs.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; + + let mut dummy_cap: SID_AND_ATTRIBUTES = zeroed(); + let security = SECURITY_CAPABILITIES { + AppContainerSid: package_sid.as_psid(), + Capabilities: &mut dummy_cap as *mut _, + CapabilityCount: 0, + Reserved: 0, + }; + attrs.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + attrs.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, + )?; + + let mut siex: STARTUPINFOEXW = zeroed(); + siex.StartupInfo.cb = size_of::() as u32; + siex.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + siex.StartupInfo.hStdInput = child_stdin.0; + siex.StartupInfo.hStdOutput = child_stdout.0; + siex.StartupInfo.hStdError = child_stderr.0; + siex.lpAttributeList = attrs.ptr(); + + let mut pi: PROCESS_INFORMATION = zeroed(); + let ok = CreateProcessW( + wide(&options.binary.to_string_lossy()).as_ptr(), + cmd_wide.as_mut_ptr(), + null(), + null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_SUSPENDED + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT, + env_block.as_ptr().cast(), + null(), + &siex.StartupInfo, + &mut pi, + ); + + if ok == 0 { + return Err(last_win32_error("CreateProcessW(lpac-only)")); + } + + let process = Handle(pi.hProcess); + let thread = Handle(pi.hThread); + + drop(child_stdin); + drop(child_stdout); + drop(child_stderr); + + let stdin_file = std::fs::File::from_raw_handle(parent_stdin.into_raw() as _); + let stdout_file = std::fs::File::from_raw_handle(parent_stdout.into_raw() as _); + let stderr_file = std::fs::File::from_raw_handle(parent_stderr.into_raw() as _); + + if ResumeThread(thread.0) == u32::MAX { + TerminateProcess(process.0, WORKER_KILL_CODE); + return Err(last_win32_error("ResumeThread(lpac-only)")); + } + + return Ok(OwnedProcess::new( + process.into_raw(), + pi.dwProcessId, + Some(stdin_file), + Some(stdout_file), + Some(stderr_file), + null_mut(), + )); + } + + // Full confinement or deviation launch + let job = create_confined_job(options.commit_limit, options.deviation)?; + let mut tokens = construct_tokens(&package_sid, options.deviation)?; + + let mut attrs = AttributeList::new(6)?; + + // Handle list attribute + let planted_handle = if options.deviation == Deviation::HandleNotAllowed { + // Plant an extra inheritable handle (e.g. parent end of pipe) into the list + let (extra_r, _extra_w) = create_pipe(true)?; + Some(extra_r) + } else { + None + }; + + if let Some(extra) = &planted_handle { + let handles = [child_stdin.0, child_stdout.0, child_stderr.0, extra.0]; + attrs.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; + } else { + let handles = [child_stdin.0, child_stdout.0, child_stderr.0]; + attrs.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; + } + + // Security capabilities attribute + let mut dummy_cap: SID_AND_ATTRIBUTES = zeroed(); + let cap_count = if options.deviation == Deviation::CapabilitiesPresent { + 1 + } else { + 0 + }; + let security = SECURITY_CAPABILITIES { + AppContainerSid: package_sid.as_psid(), + Capabilities: &mut dummy_cap as *mut _, + CapabilityCount: cap_count, + Reserved: 0, + }; + attrs.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + + // All application packages opt-out policy + if options.deviation != Deviation::NotLpac { + attrs.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, + )?; + } + + // Job list attribute + if options.deviation != Deviation::NotInOwnedJob { + let job_handles = [job.0]; + attrs.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job_handles)?; + } + + // Mitigation policy + let mut mitigations = MITIGATION_POLICY_FLAGS; + if options.deviation == Deviation::MitigationMismatch { + // Omit ProhibitDynamicCode (bit 36) + mitigations &= !(1 << 36); + } + attrs.add(PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, &mitigations)?; + + // Child process policy: restrict child processes + attrs.add( + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, + &PROCESS_CREATION_CHILD_PROCESS_RESTRICTED, + )?; + + let mut siex: STARTUPINFOEXW = zeroed(); + siex.StartupInfo.cb = size_of::() as u32; + siex.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + siex.StartupInfo.hStdInput = child_stdin.0; + siex.StartupInfo.hStdOutput = child_stdout.0; + siex.StartupInfo.hStdError = child_stderr.0; + siex.lpAttributeList = attrs.ptr(); + + let mut pi: PROCESS_INFORMATION = zeroed(); + let ok = CreateProcessAsUserW( + tokens.primary.0, + wide(&options.binary.to_string_lossy()).as_ptr(), + cmd_wide.as_mut_ptr(), + null(), + null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_SUSPENDED + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT, + env_block.as_ptr().cast(), + null(), + &siex.StartupInfo, + &mut pi, + ); + + if ok == 0 { + return Err(last_win32_error("CreateProcessAsUserW")); + } + + let process = Handle(pi.hProcess); + let main_thread = Handle(pi.hThread); + + drop(child_stdin); + drop(child_stdout); + drop(child_stderr); + + let stdin_file = std::fs::File::from_raw_handle(parent_stdin.into_raw() as _); + let stdout_file = std::fs::File::from_raw_handle(parent_stdout.into_raw() as _); + let stderr_file = std::fs::File::from_raw_handle(parent_stderr.into_raw() as _); + + // Assign initial thread token to the suspended main thread + if let Some(initial) = &tokens.initial { + if SetThreadToken(&main_thread.0, initial.0) == 0 { + TerminateProcess(process.0, WORKER_KILL_CODE); + TerminateJobObject(job.0, WORKER_KILL_CODE); + return Err(last_win32_error("SetThreadToken(suspended main thread)")); + } + } + + // Pre-resume check 1: Job limits and membership + if let Err(e) = check_job_before_resume(job.0, process.0, options.commit_limit) { + TerminateProcess(process.0, WORKER_KILL_CODE); + TerminateJobObject(job.0, WORKER_KILL_CODE); + return Err(e); + } + + // Pre-resume check 2: Birth primary token + if let Err(e) = check_birth_token(process.0, &package_sid) { + TerminateProcess(process.0, WORKER_KILL_CODE); + TerminateJobObject(job.0, WORKER_KILL_CODE); + return Err(e); + } + + // Pre-resume check 3: Initial thread token and close + if let Err(e) = check_initial_token_and_close( + main_thread.0, + &mut tokens, + &package_sid, + options.deviation, + ) { + TerminateProcess(process.0, WORKER_KILL_CODE); + TerminateJobObject(job.0, WORKER_KILL_CODE); + return Err(e); + } + + // Resume thread + if ResumeThread(main_thread.0) == u32::MAX { + TerminateProcess(process.0, WORKER_KILL_CODE); + TerminateJobObject(job.0, WORKER_KILL_CODE); + return Err(last_win32_error("ResumeThread")); + } + + Ok(OwnedProcess::new( + process.into_raw(), + pi.dwProcessId, + Some(stdin_file), + Some(stdout_file), + Some(stderr_file), + job.into_raw(), + )) + } +} diff --git a/crates/basal-launch/src/windows/mod.rs b/crates/basal-launch/src/windows/mod.rs new file mode 100644 index 0000000..0386f43 --- /dev/null +++ b/crates/basal-launch/src/windows/mod.rs @@ -0,0 +1,121 @@ +//! Windows implementation of process launching, token construction, and confinement controls. + +pub mod deviation; +pub mod error; +pub mod job; +pub mod launch; +pub mod native; +pub mod process; +pub mod profile; +pub mod token; + +pub use deviation::Deviation; +pub use error::LaunchError; +pub use job::{JOB_LIMIT_FLAGS, check_job_before_resume, create_confined_job}; +pub use launch::{LaunchOptions, MITIGATION_POLICY_FLAGS, launch}; +pub use process::{KILL_CODE, OwnedProcess, WORKER_KILL_CODE, is_confinement_fault_code}; +pub use profile::{ + AppContainerProfile, AppContainerSid, PROFILE_NAME, create_or_open_profile, + grant_test_binary_directory, +}; +pub use token::{SpawnTokens, check_birth_token, check_initial_token_and_close, construct_tokens}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deviation_tokens_match_specification() { + assert_eq!(Deviation::Full.as_str(), "full"); + assert_eq!(Deviation::LpacOnly.as_str(), "lpac-only"); + assert_eq!(Deviation::Plain.as_str(), "plain"); + + assert_eq!( + Deviation::ThreadTokenPresent.worker_reason(), + Some("thread-token-present") + ); + assert_eq!( + Deviation::IntegrityLowerFailed.worker_reason(), + Some("integrity-lower-failed") + ); + assert_eq!(Deviation::NotLpac.worker_reason(), Some("not-lpac")); + assert_eq!( + Deviation::CapabilitiesPresent.worker_reason(), + Some("capabilities-present") + ); + assert_eq!( + Deviation::RestrictingSidMismatch.worker_reason(), + Some("restricting-sid-mismatch") + ); + assert_eq!( + Deviation::GroupNotDenyOnly.worker_reason(), + Some("group-not-deny-only") + ); + assert_eq!( + Deviation::PrivilegesPresent.worker_reason(), + Some("privileges-present") + ); + assert_eq!( + Deviation::IntegrityNotUntrusted.worker_reason(), + Some("integrity-not-untrusted") + ); + assert_eq!( + Deviation::MitigationMismatch.worker_reason(), + Some("mitigation-mismatch") + ); + assert_eq!( + Deviation::HandleNotAllowed.worker_reason(), + Some("handle-not-allowed") + ); + + assert_eq!( + Deviation::JobLimitsMismatch.parent_reason(), + Some("job-limits-mismatch") + ); + assert_eq!( + Deviation::NotInOwnedJob.parent_reason(), + Some("not-in-owned-job") + ); + assert_eq!( + Deviation::BirthTokenMismatch.parent_reason(), + Some("birth-token-mismatch") + ); + assert_eq!( + Deviation::InitialTokenOpen.parent_reason(), + Some("initial-token-open") + ); + } + + #[test] + fn fault_accounting_predicate_matches_specification() { + assert!(is_confinement_fault_code(0xC0000008)); + assert!(is_confinement_fault_code(0xC0000005)); + assert!(!is_confinement_fault_code(70)); + assert!(!is_confinement_fault_code(0)); + assert!(!is_confinement_fault_code(WORKER_KILL_CODE)); + } + + #[test] + fn launch_error_formatting_matches_refusal_tokens() { + assert_eq!( + LaunchError::AppContainerProfileUnavailable(String::new()).to_string(), + "appcontainer-profile-unavailable" + ); + assert_eq!( + LaunchError::JobLimitsMismatch(String::new()).to_string(), + "job-limits-mismatch" + ); + assert_eq!( + LaunchError::NotInOwnedJob(String::new()).to_string(), + "not-in-owned-job" + ); + assert_eq!( + LaunchError::BirthTokenMismatch(String::new()).to_string(), + "birth-token-mismatch" + ); + assert_eq!( + LaunchError::InitialTokenOpen(String::new()).to_string(), + "initial-token-open" + ); + } +} diff --git a/crates/basal-launch/src/windows/native.rs b/crates/basal-launch/src/windows/native.rs new file mode 100644 index 0000000..1992f9f --- /dev/null +++ b/crates/basal-launch/src/windows/native.rs @@ -0,0 +1,245 @@ +//! Native Win32 and NT bindings, structures, and memory-safe wrappers. + +use crate::windows::error::LaunchError; +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr::null_mut; +use windows_sys::Win32::{ + Foundation::*, Security::*, System::Pipes::CreatePipe, System::Threading::*, +}; + +pub type PCWSTR = *const u16; +pub type PWSTR = *mut u16; +pub type HRESULT = i32; + +pub const JOB_OBJECT_UILIMIT_ALL: u32 = 0x000000FF; +pub const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 1; +pub const PROCESS_CREATION_CHILD_PROCESS_RESTRICTED: u32 = 1; + +/// Encodes a UTF-8 string as a null-terminated UTF-16 wide string. +pub fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() +} + +/// Reads a null-terminated wide string pointer into a Rust String. +pub unsafe fn utf16_ptr(text: *const u16) -> String { + if text.is_null() { + return String::new(); + } + let mut len = 0; + while unsafe { *text.add(len) } != 0 { + len += 1; + } + String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, len) }) +} + +/// Constructs a `LaunchError::Win32` capturing the last OS error. +pub fn last_win32_error(api: &'static str) -> LaunchError { + let code = unsafe { GetLastError() }; + LaunchError::Win32 { + api, + code, + detail: std::io::Error::from_raw_os_error(code as i32).to_string(), + } +} + +/// RAII wrapper for a Win32 HANDLE that calls `CloseHandle` on drop. +pub struct Handle(pub HANDLE); + +impl Drop for Handle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { + CloseHandle(self.0); + } + self.0 = null_mut(); + } + } +} + +impl Handle { + pub fn new(h: HANDLE, api: &'static str) -> Result { + if h.is_null() || h == INVALID_HANDLE_VALUE { + Err(last_win32_error(api)) + } else { + Ok(Self(h)) + } + } + + pub fn into_raw(mut self) -> HANDLE { + let h = self.0; + self.0 = null_mut(); + h + } + + pub fn is_valid(&self) -> bool { + !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE + } +} + +/// RAII wrapper for a `PROC_THREAD_ATTRIBUTE_LIST`. +pub struct AttributeList { + buffer: Vec, +} + +impl AttributeList { + pub fn new(count: u32) -> Result { + unsafe { + let mut bytes = 0; + InitializeProcThreadAttributeList(null_mut(), count, 0, &mut bytes); + let mut this = Self { + buffer: vec![0; (bytes as usize).div_ceil(size_of::())], + }; + if InitializeProcThreadAttributeList(this.ptr(), count, 0, &mut bytes) == 0 { + return Err(last_win32_error("InitializeProcThreadAttributeList")); + } + Ok(this) + } + } + + pub fn ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.buffer.as_mut_ptr().cast() + } + + pub fn add(&mut self, key: u32, value: &T) -> Result<(), LaunchError> { + unsafe { + if UpdateProcThreadAttribute( + self.ptr(), + 0, + key as usize, + (value as *const T).cast(), + size_of::(), + null_mut(), + std::ptr::null(), + ) == 0 + { + Err(last_win32_error("UpdateProcThreadAttribute")) + } else { + Ok(()) + } + } + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + unsafe { + DeleteProcThreadAttributeList(self.ptr()); + } + } +} + +/// Creates an anonymous pipe pair: (read_handle, write_handle). +pub fn create_pipe(inheritable: bool) -> Result<(Handle, Handle), LaunchError> { + unsafe { + let sa = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: if inheritable { 1 } else { 0 }, + }; + let mut read = null_mut(); + let mut write = null_mut(); + if CreatePipe(&mut read, &mut write, &sa, 0) == 0 { + return Err(last_win32_error("CreatePipe")); + } + Ok((Handle(read), Handle(write))) + } +} + +/// Queries token information into a heap-allocated buffer. +pub unsafe fn token_buffer( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, +) -> Result, LaunchError> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last_win32_error("GetTokenInformation(size)")); + } + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + if GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes) == 0 { + return Err(last_win32_error("GetTokenInformation")); + } + Ok(data) + } +} + +/// Native Unicode string representation for NT APIs. +#[repr(C)] +pub struct UnicodeString { + pub length: u16, + pub maximum_length: u16, + pub buffer: *mut u16, +} + +impl UnicodeString { + pub fn as_slice(&self) -> &[u16] { + if self.buffer.is_null() || self.length == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(self.buffer, (self.length / 2) as usize) } + } + } + + pub fn to_string_lossy(&self) -> String { + String::from_utf16_lossy(self.as_slice()) + } +} + +/// Native object attributes structure. +#[repr(C)] +pub struct ObjectAttributes { + pub length: u32, + pub root: HANDLE, + pub name: *mut UnicodeString, + pub attributes: u32, + pub descriptor: *mut c_void, + pub qos: *mut c_void, +} + +/// Native security attribute entry inside `TokenSecurityAttributes`. +#[repr(C)] +pub struct SecurityAttribute { + pub name: UnicodeString, + pub kind: u16, + pub reserved: u16, + pub flags: u32, + pub count: u32, + pub values: *mut c_void, +} + +/// Native header for `TokenSecurityAttributes`. +#[repr(C)] +pub struct SecurityAttributes { + pub version: u16, + pub reserved: u16, + pub count: u32, + pub attributes: *mut SecurityAttribute, +} + +#[link(name = "ntdll")] +unsafe extern "system" { + pub fn NtQueryInformationToken( + token: HANDLE, + class: u32, + information: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + + pub fn NtCreateLowBoxToken( + out: *mut HANDLE, + existing: HANDLE, + access: u32, + attrs: *mut ObjectAttributes, + package: PSID, + capability_count: u32, + capabilities: *const SID_AND_ATTRIBUTES, + handle_count: u32, + handles: *const HANDLE, + ) -> i32; +} diff --git a/crates/basal-launch/src/windows/process.rs b/crates/basal-launch/src/windows/process.rs new file mode 100644 index 0000000..91e68c9 --- /dev/null +++ b/crates/basal-launch/src/windows/process.rs @@ -0,0 +1,157 @@ +//! Owned process wrapper providing lifecycle management, stdio pipes, and kill termination. + +use std::fs::File; +use std::io; +use std::ptr::null_mut; +use windows_sys::Win32::{ + Foundation::*, System::JobObjects::TerminateJobObject, System::Threading::*, +}; + +/// Named exit code passed when terminating a confined worker process and its job. +/// +/// Distinct from exit 70, STATUS_INVALID_HANDLE (0xc0000008), and STATUS_ACCESS_VIOLATION (0xc0000005). +pub const WORKER_KILL_CODE: u32 = 0xC000002B; + +/// Alias for `WORKER_KILL_CODE`. +pub const KILL_CODE: u32 = WORKER_KILL_CODE; + +/// Owned process wrapper holding the native process handle, PID, stdio pipes, and job object. +/// +/// Dropping the wrapper terminates the process and job. +pub struct OwnedProcess { + process: HANDLE, + pid: u32, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + job: HANDLE, + killed: bool, +} + +unsafe impl Send for OwnedProcess {} +unsafe impl Sync for OwnedProcess {} + +impl OwnedProcess { + /// Creates a new `OwnedProcess` wrapper from raw handles and optional stdio streams. + pub fn new( + process: HANDLE, + pid: u32, + stdin: Option, + stdout: Option, + stderr: Option, + job: HANDLE, + ) -> Self { + Self { + process, + pid, + stdin, + stdout, + stderr, + job, + killed: false, + } + } + + /// Returns the OS process ID. + pub fn id(&self) -> u32 { + self.pid + } + + /// Returns the raw process handle. + pub fn process_handle(&self) -> HANDLE { + self.process + } + + /// Returns the raw job object handle. + pub fn job_handle(&self) -> HANDLE { + self.job + } + + /// Terminates the job object and the process with [`WORKER_KILL_CODE`]. + pub fn kill(&mut self) -> io::Result<()> { + self.killed = true; + unsafe { + if !self.job.is_null() && self.job != INVALID_HANDLE_VALUE { + let _ = TerminateJobObject(self.job, WORKER_KILL_CODE); + } + if !self.process.is_null() && self.process != INVALID_HANDLE_VALUE { + let _ = TerminateProcess(self.process, WORKER_KILL_CODE); + } + } + Ok(()) + } + + /// Terminates the job object and process using a shared reference. + pub fn kill_shared(&self) -> io::Result<()> { + unsafe { + if !self.job.is_null() && self.job != INVALID_HANDLE_VALUE { + let _ = TerminateJobObject(self.job, WORKER_KILL_CODE); + } + if !self.process.is_null() && self.process != INVALID_HANDLE_VALUE { + let _ = TerminateProcess(self.process, WORKER_KILL_CODE); + } + } + Ok(()) + } + + /// Checks if the process has exited without blocking. + /// + /// Returns `Ok(Some(exit_code))` if the process has terminated, + /// `Ok(None)` if it is still running, or an `io::Error` on failure. + pub fn try_wait(&mut self) -> io::Result> { + unsafe { + let res = WaitForSingleObject(self.process, 0); + if res == WAIT_OBJECT_0 { + let mut code = 0u32; + if GetExitCodeProcess(self.process, &mut code) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(code)) + } else if res == WAIT_TIMEOUT { + Ok(None) + } else { + Err(io::Error::last_os_error()) + } + } + } + + /// Waits indefinitely for the process to exit and returns its exit code. + pub fn wait(&mut self) -> io::Result { + unsafe { + let res = WaitForSingleObject(self.process, INFINITE); + if res == WAIT_OBJECT_0 { + let mut code = 0u32; + if GetExitCodeProcess(self.process, &mut code) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(code) + } else { + Err(io::Error::last_os_error()) + } + } + } +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + let _ = self.kill(); + unsafe { + if !self.process.is_null() && self.process != INVALID_HANDLE_VALUE { + CloseHandle(self.process); + self.process = null_mut(); + } + if !self.job.is_null() && self.job != INVALID_HANDLE_VALUE { + CloseHandle(self.job); + self.job = null_mut(); + } + } + } +} + +/// Evaluates whether a process exit status indicates a confinement fault. +/// +/// Returns true for `0xc0000008` (invalid handle) and `0xc0000005` (access violation). +/// Returns false for exit 70, standard clean exit (0), and [`WORKER_KILL_CODE`]. +pub fn is_confinement_fault_code(code: u32) -> bool { + code == 0xC0000008 || code == 0xC0000005 +} diff --git a/crates/basal-launch/src/windows/profile.rs b/crates/basal-launch/src/windows/profile.rs new file mode 100644 index 0000000..6f548fa --- /dev/null +++ b/crates/basal-launch/src/windows/profile.rs @@ -0,0 +1,309 @@ +//! AppContainer profile management and test ACL granting. + +use crate::windows::error::LaunchError; +use crate::windows::native::{HRESULT, PCWSTR, utf16_ptr, wide}; +use std::path::Path; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::{ + Foundation::*, Security::Authorization::*, Security::*, Storage::FileSystem::*, + System::LibraryLoader::*, +}; + +/// Fixed AppContainer profile name used for all basal workers. +pub const PROFILE_NAME: &str = "cortexkit.basal.worker"; + +type CreateAppContainerProfileFn = unsafe extern "system" fn( + psz_app_container_name: PCWSTR, + psz_display_name: PCWSTR, + psz_description: PCWSTR, + p_capabilities: *const SID_AND_ATTRIBUTES, + dw_capability_count: u32, + pp_sid_mapping: *mut PSID, +) -> HRESULT; + +type DeriveAppContainerSidFromAppContainerNameFn = unsafe extern "system" fn( + psz_app_container_name: PCWSTR, + ppsid_app_container_sid: *mut PSID, +) -> HRESULT; + +struct UserEnvModule(HMODULE); + +impl Drop for UserEnvModule { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + FreeLibrary(self.0); + } + } + } +} + +impl UserEnvModule { + fn load() -> Result { + let h = unsafe { + LoadLibraryExW( + wide("userenv.dll").as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if h.is_null() { + Err(LaunchError::AppContainerProfileUnavailable( + "failed to load userenv.dll from System32".to_string(), + )) + } else { + Ok(Self(h)) + } + } + + unsafe fn symbol(&self, name: &[u8]) -> Result { + let f = unsafe { GetProcAddress(self.0, name.as_ptr()) }; + if let Some(f) = f { + Ok(unsafe { std::mem::transmute_copy(&f) }) + } else { + Err(LaunchError::AppContainerProfileUnavailable(format!( + "missing symbol in userenv.dll: {}", + String::from_utf8_lossy(name) + ))) + } + } +} + +/// An owned AppContainer SID. +#[derive(Clone, PartialEq, Eq)] +pub struct AppContainerSid { + raw: Vec, + string: String, +} + +impl AppContainerSid { + /// Creates an `AppContainerSid` from an existing Win32 `PSID`. + pub unsafe fn from_psid(psid: PSID) -> Result { + if psid.is_null() { + return Err(LaunchError::AppContainerProfileUnavailable( + "NULL PSID pointer".to_string(), + )); + } + let len = unsafe { GetLengthSid(psid) }; + if len == 0 { + return Err(LaunchError::AppContainerProfileUnavailable( + "invalid SID length".to_string(), + )); + } + let mut raw = vec![0u8; len as usize]; + if unsafe { CopySid(len, raw.as_mut_ptr().cast(), psid) } == 0 { + return Err(LaunchError::AppContainerProfileUnavailable( + "failed to copy SID".to_string(), + )); + } + let mut string_ptr = null_mut(); + if unsafe { ConvertSidToStringSidW(raw.as_mut_ptr().cast(), &mut string_ptr) } == 0 { + return Err(LaunchError::AppContainerProfileUnavailable( + "failed to convert SID to string".to_string(), + )); + } + let string = unsafe { utf16_ptr(string_ptr) }; + unsafe { LocalFree(string_ptr.cast()) }; + Ok(Self { raw, string }) + } + + /// Parses a string representation (e.g. `"S-1-15-2-..."`) into an `AppContainerSid`. + pub fn from_string_sid(s: &str) -> Result { + let mut psid = null_mut(); + let wide_str = wide(s); + if unsafe { ConvertStringSidToSidW(wide_str.as_ptr(), &mut psid) } == 0 { + return Err(LaunchError::AppContainerProfileUnavailable(format!( + "invalid string SID: {s}" + ))); + } + let res = unsafe { Self::from_psid(psid) }; + unsafe { LocalFree(psid.cast()) }; + res + } + + /// Returns a raw `PSID` pointer valid for the lifetime of this struct. + pub fn as_psid(&self) -> PSID { + self.raw.as_ptr() as PSID + } + + /// Returns the string representation of this SID. + pub fn as_str(&self) -> &str { + &self.string + } + + /// Checks whether this SID equals another `PSID`. + pub fn matches(&self, other: PSID) -> bool { + if other.is_null() { + return false; + } + unsafe { EqualSid(self.as_psid(), other) != 0 } + } +} + +impl std::fmt::Debug for AppContainerSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "AppContainerSid({})", self.string) + } +} + +impl std::fmt::Display for AppContainerSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.string) + } +} + +/// An opened or created AppContainer profile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppContainerProfile { + name: String, + sid: AppContainerSid, +} + +impl AppContainerProfile { + pub fn name(&self) -> &str { + &self.name + } + + pub fn sid(&self) -> &AppContainerSid { + &self.sid + } +} + +/// Creates or opens the named AppContainer profile with zero capabilities. +/// +/// If the profile already exists, derives the same package SID. +/// Failure produces `LaunchError::AppContainerProfileUnavailable`. +pub fn create_or_open_profile(name: &str) -> Result { + let module = UserEnvModule::load()?; + let create: CreateAppContainerProfileFn = + unsafe { module.symbol(b"CreateAppContainerProfile\0")? }; + let derive: DeriveAppContainerSidFromAppContainerNameFn = + unsafe { module.symbol(b"DeriveAppContainerSidFromAppContainerName\0")? }; + + let wide_name = wide(name); + let mut psid = null_mut(); + let hr = unsafe { + create( + wide_name.as_ptr(), + wide_name.as_ptr(), + wide("CortexKit basal worker").as_ptr(), + null(), + 0, + &mut psid, + ) + }; + + if hr >= 0 { + let sid = unsafe { AppContainerSid::from_psid(psid) }; + unsafe { FreeSid(psid) }; + let sid = sid?; + return Ok(AppContainerProfile { + name: name.to_string(), + sid, + }); + } + + // Check for ERROR_ALREADY_EXISTS (183 = 0xB7; HRESULT = 0x800700B7) + let is_already_exists = (hr as u32) == 0x800700B7 || (hr as u32 & 0xFFFF) == 183; + if is_already_exists { + let mut derived_sid = null_mut(); + let hr_derive = unsafe { derive(wide_name.as_ptr(), &mut derived_sid) }; + if hr_derive >= 0 { + let sid = unsafe { AppContainerSid::from_psid(derived_sid) }; + unsafe { FreeSid(derived_sid) }; + let sid = sid?; + return Ok(AppContainerProfile { + name: name.to_string(), + sid, + }); + } + } + + Err(LaunchError::AppContainerProfileUnavailable(format!( + "profile create/open failed for {name}: HRESULT 0x{hr:08x}" + ))) +} + +/// Grants the package SID `FILE_GENERIC_READ | FILE_GENERIC_EXECUTE` on the +/// directory of the worker binary about to run. +/// +/// Test harnesses call this function; production paths do not. +pub fn grant_test_binary_directory( + directory: impl AsRef, + package_sid: &AppContainerSid, +) -> Result<(), LaunchError> { + let path_str = directory + .as_ref() + .to_str() + .ok_or_else(|| LaunchError::Other("directory path is not valid UTF-8".to_string()))?; + let wide_path = wide(path_str); + + unsafe { + let mut old_dacl = null_mut(); + let mut descriptor = null_mut(); + let err = GetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old_dacl, + null_mut(), + &mut descriptor, + ); + if err != 0 { + return Err(LaunchError::Win32 { + api: "GetNamedSecurityInfoW", + code: err, + detail: format!("failed to read DACL for {path_str}"), + }); + } + + let ace = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + grfAccessMode: GRANT_ACCESS, + grfInheritance: SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: package_sid.as_psid() as *mut u16, + }, + }; + + let mut new_dacl = null_mut(); + let err = SetEntriesInAclW(1, &ace, old_dacl, &mut new_dacl); + if err != 0 { + LocalFree(descriptor.cast()); + return Err(LaunchError::Win32 { + api: "SetEntriesInAclW", + code: err, + detail: "failed to build updated DACL with package ACE".to_string(), + }); + } + + let err = SetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new_dacl, + null(), + ); + + LocalFree(new_dacl.cast()); + LocalFree(descriptor.cast()); + + if err != 0 { + return Err(LaunchError::Win32 { + api: "SetNamedSecurityInfoW", + code: err, + detail: format!("failed to apply updated DACL for {path_str}"), + }); + } + + Ok(()) + } +} diff --git a/crates/basal-launch/src/windows/token.rs b/crates/basal-launch/src/windows/token.rs new file mode 100644 index 0000000..6fb6f41 --- /dev/null +++ b/crates/basal-launch/src/windows/token.rs @@ -0,0 +1,541 @@ +//! Token construction, privilege stripping, lowboxing, and pre-resume token attestation. + +use crate::windows::deviation::Deviation; +use crate::windows::error::LaunchError; +use crate::windows::native::*; +use crate::windows::profile::AppContainerSid; +use std::mem::{size_of, zeroed}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::{Foundation::*, Security::*, System::Threading::*}; + +pub const SE_GROUP_INTEGRITY: u32 = 0x00000020; +pub const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x00000010; +pub const SE_PRIVILEGE_REMOVED: u32 = 0x00000004; +pub const SECURITY_MANDATORY_LOW_RID: u32 = 0x00001000; +pub const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x00002000; + +/// Constructed primary and initial impersonation tokens. +pub struct SpawnTokens { + pub primary: Handle, + pub initial: Option, +} + +unsafe fn token_dacl(token: HANDLE) -> Result, LaunchError> { + unsafe { + let mut bytes = 0; + GetKernelObjectSecurity(token, DACL_SECURITY_INFORMATION, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last_win32_error("GetKernelObjectSecurity(size)")); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + if GetKernelObjectSecurity( + token, + DACL_SECURITY_INFORMATION, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ) == 0 + { + return Err(last_win32_error("GetKernelObjectSecurity")); + } + Ok(data) + } +} + +pub unsafe fn set_integrity(token: HANDLE, level: WELL_KNOWN_SID_TYPE) -> Result<(), LaunchError> { + unsafe { + let mut sid = [0u32; 17]; + let mut bytes = (sid.len() * 4) as u32; + if CreateWellKnownSid(level, null_mut(), sid.as_mut_ptr().cast(), &mut bytes) == 0 { + return Err(last_win32_error("CreateWellKnownSid(integrity)")); + } + let mut label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: sid.as_mut_ptr().cast(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + if SetTokenInformation( + token, + TokenIntegrityLevel, + (&mut label as *mut TOKEN_MANDATORY_LABEL).cast(), + size_of::() as u32, + ) == 0 + { + return Err(last_win32_error("SetTokenInformation(integrity)")); + } + Ok(()) + } +} + +unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + let g = unsafe { &*data.as_ptr().cast::() }; + unsafe { std::slice::from_raw_parts(g.Groups.as_ptr(), g.GroupCount as usize) } +} + +/// Constructs the primary restricted token and initial thread impersonation token. +pub fn construct_tokens( + package_sid: &AppContainerSid, + deviation: Deviation, +) -> Result { + unsafe { + let mut parent_token = null_mut(); + if OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE, + &mut parent_token, + ) == 0 + { + return Err(last_win32_error("OpenProcessToken(current process)")); + } + let parent_token = Handle(parent_token); + + let data = token_buffer(parent_token.0, TokenGroups)?; + let mut disabled = Vec::new(); + for (i, g) in groups(&data).iter().enumerate() { + if g.Attributes & SE_GROUP_INTEGRITY == 0 { + // If GroupNotDenyOnly deviation is set, leave one group enabled + if deviation == Deviation::GroupNotDenyOnly && i == 0 { + continue; + } + disabled.push(SID_AND_ATTRIBUTES { + Sid: g.Sid, + Attributes: 0, + }); + } + } + + let mut null_sid = [0u32; 17]; + let mut bytes = (null_sid.len() * 4) as u32; + if CreateWellKnownSid( + WinNullSid, + null_mut(), + null_sid.as_mut_ptr().cast(), + &mut bytes, + ) == 0 + { + return Err(last_win32_error("CreateWellKnownSid(NULL)")); + } + + let null_restrict = [SID_AND_ATTRIBUTES { + Sid: null_sid.as_mut_ptr().cast(), + Attributes: 0, + }]; + + let restrict: &[SID_AND_ATTRIBUTES] = if deviation == Deviation::RestrictingSidMismatch { + &[] + } else { + &null_restrict + }; + + let mut lockdown = null_mut(); + if CreateRestrictedToken( + parent_token.0, + DISABLE_MAX_PRIVILEGE, + disabled.len() as u32, + disabled.as_ptr(), + 0, + null(), + restrict.len() as u32, + restrict.as_ptr(), + &mut lockdown, + ) == 0 + { + return Err(last_win32_error("CreateRestrictedToken(lockdown)")); + } + let lockdown = Handle(lockdown); + + // Strip any surviving privileges (such as SeChangeNotifyPrivilege) unless PrivilegesPresent + if deviation != Deviation::PrivilegesPresent { + let p = token_buffer(lockdown.0, TokenPrivileges)?; + let p = &*p.as_ptr().cast::(); + for entry in + std::slice::from_raw_parts(p.Privileges.as_ptr(), p.PrivilegeCount as usize) + { + let remove = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: entry.Luid, + Attributes: SE_PRIVILEGE_REMOVED, + }], + }; + AdjustTokenPrivileges(lockdown.0, 0, &remove, 0, null_mut(), null_mut()); + } + } + + // Set integrity level + if deviation == Deviation::IntegrityNotUntrusted { + set_integrity(lockdown.0, WinMediumLabelSid)?; + } else { + set_integrity(lockdown.0, WinLowLabelSid)?; + } + + // Now construct the initial thread token + let source_dacl = token_dacl(parent_token.0)?; + let loader_groups = token_buffer(parent_token.0, TokenGroups)?; + let loader_user = token_buffer(parent_token.0, TokenUser)?; + let mut same_access = groups(&loader_groups) + .iter() + .filter(|g| g.Attributes & SE_GROUP_INTEGRITY == 0) + .map(|g| SID_AND_ATTRIBUTES { + Sid: g.Sid, + Attributes: 0, + }) + .collect::>(); + same_access.push(SID_AND_ATTRIBUTES { + Sid: (*loader_user.as_ptr().cast::()).User.Sid, + Attributes: 0, + }); + + let mut loader = null_mut(); + if CreateRestrictedToken( + parent_token.0, + DISABLE_MAX_PRIVILEGE, + 0, + null(), + 0, + null(), + same_access.len() as u32, + same_access.as_ptr(), + &mut loader, + ) == 0 + { + return Err(last_win32_error("CreateRestrictedToken(loader)")); + } + let loader = Handle(loader); + + if SetKernelObjectSecurity( + loader.0, + DACL_SECURITY_INFORMATION, + source_dacl.as_ptr().cast_mut().cast(), + ) == 0 + { + return Err(last_win32_error( + "SetKernelObjectSecurity(filtered loader DACL)", + )); + } + + set_integrity(loader.0, WinLowLabelSid)?; + + // Lowbox the loader token + let mut attrs = ObjectAttributes { + length: size_of::() as u32, + root: null_mut(), + name: null_mut(), + attributes: 0, + descriptor: null_mut(), + qos: null_mut(), + }; + let mut lowbox = null_mut(); + let dummy_cap: SID_AND_ATTRIBUTES = zeroed(); + let caps_ptr = &dummy_cap as *const SID_AND_ATTRIBUTES; + let cap_count = if deviation == Deviation::CapabilitiesPresent { + 1 + } else { + 0 + }; + + let status = NtCreateLowBoxToken( + &mut lowbox, + loader.0, + TOKEN_ALL_ACCESS, + &mut attrs, + package_sid.as_psid(), + cap_count, + caps_ptr, + 0, + null(), + ); + if status != 0 { + return Err(LaunchError::Other(format!( + "NtCreateLowBoxToken(loader): 0x{status:08x}" + ))); + } + let lowbox = Handle(lowbox); + + let loader_dacl = token_dacl(lowbox.0)?; + let mut impersonation = null_mut(); + let imp_level = if deviation == Deviation::InitialTokenOpen { + SecurityIdentification // Level 1 instead of Level 2 + } else { + SecurityImpersonation // Level 2 + }; + + if DuplicateTokenEx( + lowbox.0, + TOKEN_ALL_ACCESS, + null(), + imp_level, + TokenImpersonation, + &mut impersonation, + ) == 0 + { + return Err(last_win32_error("DuplicateTokenEx(loader)")); + } + let initial = Handle(impersonation); + + if SetKernelObjectSecurity( + initial.0, + DACL_SECURITY_INFORMATION, + loader_dacl.as_ptr().cast_mut().cast(), + ) == 0 + { + return Err(last_win32_error("SetKernelObjectSecurity(initial DACL)")); + } + + if SetHandleInformation(initial.0, HANDLE_FLAG_INHERIT, 0) == 0 { + return Err(last_win32_error("SetHandleInformation(initial token)")); + } + + Ok(SpawnTokens { + primary: lockdown, + initial: Some(initial), + }) + } +} + +/// Checks the suspended child process's primary token at birth. +/// +/// On mismatch, returns `LaunchError::BirthTokenMismatch`. +pub fn check_birth_token( + process: HANDLE, + package_sid: &AppContainerSid, +) -> Result<(), LaunchError> { + unsafe { + let mut token = null_mut(); + if OpenProcessToken(process, TOKEN_QUERY, &mut token) == 0 { + return Err(LaunchError::BirthTokenMismatch( + "failed to open suspended process token".to_string(), + )); + } + let token = Handle(token); + + // 1. Token type must be TokenPrimary (1) + let token_type_buf = token_buffer(token.0, TokenType) + .map_err(|e| LaunchError::BirthTokenMismatch(format!("query TokenType failed: {e}")))?; + let kind = *token_type_buf.as_ptr().cast::(); + if kind != TokenPrimary as u32 { + return Err(LaunchError::BirthTokenMismatch(format!( + "expected TokenPrimary (1), got {kind}" + ))); + } + + // 2. Low integrity S-1-16-4096 + let integrity_buf = token_buffer(token.0, TokenIntegrityLevel).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenIntegrityLevel failed: {e}")) + })?; + let label = &*integrity_buf.as_ptr().cast::(); + let sub_auth_count = *GetSidSubAuthorityCount(label.Label.Sid); + if sub_auth_count == 0 { + return Err(LaunchError::BirthTokenMismatch( + "empty integrity SID".to_string(), + )); + } + let last_sub_auth = *GetSidSubAuthority(label.Label.Sid, (sub_auth_count - 1) as u32); + if last_sub_auth != SECURITY_MANDATORY_LOW_RID { + return Err(LaunchError::BirthTokenMismatch(format!( + "expected Low integrity (0x{:04x}), got 0x{last_sub_auth:04x}", + SECURITY_MANDATORY_LOW_RID + ))); + } + + // 3. AppContainer SID equals package SID + let app_buf = token_buffer(token.0, TokenAppContainerSid).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenAppContainerSid failed: {e}")) + })?; + let app_sid = + (*app_buf.as_ptr().cast::()).TokenAppContainer; + if app_sid.is_null() || !package_sid.matches(app_sid) { + return Err(LaunchError::BirthTokenMismatch( + "AppContainer SID does not match expected package SID".to_string(), + )); + } + + // 4. Zero capabilities + let cap_buf = token_buffer(token.0, TokenCapabilities).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenCapabilities failed: {e}")) + })?; + let cap_count = (*cap_buf.as_ptr().cast::()).GroupCount; + if cap_count != 0 { + return Err(LaunchError::BirthTokenMismatch(format!( + "expected 0 capabilities, found {cap_count}" + ))); + } + + // 5. The WIN://NOALLAPPPKG claim + let mut bytes = 0; + let status = NtQueryInformationToken(token.0, 44, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(LaunchError::BirthTokenMismatch(format!( + "NtQueryInformationToken(TokenSecurityAttributes size) failed: 0x{status:08x}" + ))); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let status = + NtQueryInformationToken(token.0, 44, data.as_mut_ptr().cast(), bytes, &mut bytes); + if status != 0 { + return Err(LaunchError::BirthTokenMismatch(format!( + "NtQueryInformationToken(TokenSecurityAttributes) failed: 0x{status:08x}" + ))); + } + let header = &*data.as_ptr().cast::(); + let entries = if header.count == 0 { + &[][..] + } else { + std::slice::from_raw_parts(header.attributes, header.count as usize) + }; + let mut has_noallapppkg = false; + for entry in entries { + if entry.name.to_string_lossy() == "WIN://NOALLAPPPKG" { + if entry.kind == 2 && entry.count >= 1 { + let vals = std::slice::from_raw_parts( + entry.values.cast::(), + entry.count as usize, + ); + if vals.first().copied() == Some(1) { + has_noallapppkg = true; + } + } + } + } + if !has_noallapppkg { + return Err(LaunchError::BirthTokenMismatch( + "missing WIN://NOALLAPPPKG claim with value [1]".to_string(), + )); + } + + // 6. Restricting SIDs exactly {S-1-0-0} + let mut null_sid = [0u32; 17]; + let mut bytes = (null_sid.len() * 4) as u32; + CreateWellKnownSid( + WinNullSid, + null_mut(), + null_sid.as_mut_ptr().cast(), + &mut bytes, + ); + + let restrict_buf = token_buffer(token.0, TokenRestrictedSids).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenRestrictedSids failed: {e}")) + })?; + let restrict_groups = groups(&restrict_buf); + if restrict_groups.len() != 1 + || EqualSid(restrict_groups[0].Sid, null_sid.as_mut_ptr().cast()) == 0 + { + return Err(LaunchError::BirthTokenMismatch( + "restricting SIDs are not exactly {S-1-0-0}".to_string(), + )); + } + + // 7. Every group deny-only except SE_GROUP_INTEGRITY + let group_buf = token_buffer(token.0, TokenGroups).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenGroups failed: {e}")) + })?; + for g in groups(&group_buf) { + if g.Attributes & SE_GROUP_INTEGRITY == 0 { + if g.Attributes & SE_GROUP_USE_FOR_DENY_ONLY == 0 { + return Err(LaunchError::BirthTokenMismatch( + "found access group that is not deny-only".to_string(), + )); + } + } + } + + // 8. Zero privileges + let priv_buf = token_buffer(token.0, TokenPrivileges).map_err(|e| { + LaunchError::BirthTokenMismatch(format!("query TokenPrivileges failed: {e}")) + })?; + let priv_count = (*priv_buf.as_ptr().cast::()).PrivilegeCount; + if priv_count != 0 { + return Err(LaunchError::BirthTokenMismatch(format!( + "expected 0 privileges, found {priv_count}" + ))); + } + + Ok(()) + } +} + +/// Verifies the suspended main thread token and closes the parent's initial handle before resume. +/// +/// On failure, returns `LaunchError::InitialTokenOpen`. +pub fn check_initial_token_and_close( + main_thread: HANDLE, + tokens: &mut SpawnTokens, + package_sid: &AppContainerSid, + deviation: Deviation, +) -> Result<(), LaunchError> { + unsafe { + // Read back thread token from suspended main thread + let mut thread_token = null_mut(); + if OpenThreadToken(main_thread, TOKEN_QUERY, 1, &mut thread_token) == 0 { + return Err(LaunchError::InitialTokenOpen( + "failed to open thread token on suspended thread".to_string(), + )); + } + let thread_token = Handle(thread_token); + + // Impersonation level must be SecurityImpersonation (2) + let imp_buf = token_buffer(thread_token.0, TokenImpersonationLevel).map_err(|e| { + LaunchError::InitialTokenOpen(format!("query TokenImpersonationLevel failed: {e}")) + })?; + let imp_level = *imp_buf.as_ptr().cast::(); + if imp_level != SecurityImpersonation as u32 { + return Err(LaunchError::InitialTokenOpen(format!( + "expected SecurityImpersonation (2), got {imp_level}" + ))); + } + + // Integrity must be Low + let integrity_buf = token_buffer(thread_token.0, TokenIntegrityLevel).map_err(|e| { + LaunchError::InitialTokenOpen(format!("query TokenIntegrityLevel failed: {e}")) + })?; + let label = &*integrity_buf.as_ptr().cast::(); + let sub_auth_count = *GetSidSubAuthorityCount(label.Label.Sid); + if sub_auth_count == 0 { + return Err(LaunchError::InitialTokenOpen( + "empty integrity SID".to_string(), + )); + } + let last_sub_auth = *GetSidSubAuthority(label.Label.Sid, (sub_auth_count - 1) as u32); + if last_sub_auth != SECURITY_MANDATORY_LOW_RID { + return Err(LaunchError::InitialTokenOpen(format!( + "expected Low integrity, got 0x{last_sub_auth:04x}" + ))); + } + + // Package SID must match + let app_buf = token_buffer(thread_token.0, TokenAppContainerSid).map_err(|e| { + LaunchError::InitialTokenOpen(format!("query TokenAppContainerSid failed: {e}")) + })?; + let app_sid = + (*app_buf.as_ptr().cast::()).TokenAppContainer; + if app_sid.is_null() || !package_sid.matches(app_sid) { + return Err(LaunchError::InitialTokenOpen( + "initial thread token AppContainer SID does not match package SID".to_string(), + )); + } + + // Check handle close before resume + if deviation == Deviation::InitialTokenOpen { + // Intentionally leak or leave the initial handle open to trigger refusal + return Err(LaunchError::InitialTokenOpen( + "initial token handle remained open before resume".to_string(), + )); + } + + if let Some(initial_handle) = tokens.initial.take() { + let raw = initial_handle.into_raw(); + if CloseHandle(raw) == 0 { + return Err(LaunchError::InitialTokenOpen( + "failed to close initial token handle before resume".to_string(), + )); + } + } else { + return Err(LaunchError::InitialTokenOpen( + "initial token handle was not held".to_string(), + )); + } + + Ok(()) + } +} From 4a9394e34c64da1bbdc55f89ea994f3f32db07a3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:46:26 +0200 Subject: [PATCH 2/3] mason: gate deviations behind feature and add Windows launch integration tests --- .github/workflows/ci.yml | 6 + crates/basal-launch/Cargo.toml | 6 + crates/basal-launch/src/bin/test-helper.rs | 10 + crates/basal-launch/src/windows/deviation.rs | 79 ++++++- crates/basal-launch/src/windows/job.rs | 4 + crates/basal-launch/src/windows/launch.rs | 95 +++++--- crates/basal-launch/src/windows/mod.rs | 13 ++ crates/basal-launch/src/windows/process.rs | 9 + crates/basal-launch/src/windows/token.rs | 55 +++-- crates/basal-launch/tests/windows_launch.rs | 217 +++++++++++++++++++ 10 files changed, 443 insertions(+), 51 deletions(-) create mode 100644 crates/basal-launch/src/bin/test-helper.rs create mode 100644 crates/basal-launch/tests/windows_launch.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25e81..e82fd3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,6 +406,11 @@ jobs: continue-on-error: true timeout-minutes: 40 run: cargo test --workspace --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-no-fail-fast.log" + - name: Test basal-launch with deviations + id: test-basal-launch + continue-on-error: true + timeout-minutes: 20 + run: cargo test -p basal-launch --features deviations 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" - name: Summarize the baseline if: always() run: | @@ -414,6 +419,7 @@ jobs: echo echo "- cargo check --workspace --all-targets --locked: ${{ steps.check.outcome }}" echo "- cargo test --workspace --locked: ${{ steps.test.outcome }}" + echo "- cargo test -p basal-launch --features deviations: ${{ steps.test-basal-launch.outcome }}" } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() diff --git a/crates/basal-launch/Cargo.toml b/crates/basal-launch/Cargo.toml index bee28aa..49455f4 100644 --- a/crates/basal-launch/Cargo.toml +++ b/crates/basal-launch/Cargo.toml @@ -7,12 +7,18 @@ license.workspace = true publish.workspace = true rust-version.workspace = true +[[bin]] +name = "basal-launch-test-helper" +path = "src/bin/test-helper.rs" +test = false + [dependencies] serde = { workspace = true, optional = true } [features] default = [] serde = ["dep:serde"] +deviations = [] [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ diff --git a/crates/basal-launch/src/bin/test-helper.rs b/crates/basal-launch/src/bin/test-helper.rs new file mode 100644 index 0000000..2d2a353 --- /dev/null +++ b/crates/basal-launch/src/bin/test-helper.rs @@ -0,0 +1,10 @@ +#![cfg_attr(windows, windows_subsystem = "windows")] + +fn main() { + #[cfg(windows)] + { + use std::io::Read; + let mut buf = [0u8; 1]; + let _ = std::io::stdin().read(&mut buf); + } +} diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs index 9c5de84..fb5f08a 100644 --- a/crates/basal-launch/src/windows/deviation.rs +++ b/crates/basal-launch/src/windows/deviation.rs @@ -8,50 +8,69 @@ use serde::{Deserialize, Serialize}; /// Confinement deviation requested for a spawn. /// -/// Covers full confinement, LPAC-only control, plain control, and one deviation -/// per worker refusal reason token and per parent pre-resume check. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Under production builds (without the `deviations` feature), only [`Deviation::Full`] +/// is compiled. Non-`Full` deviations are test and development controls gated behind +/// the `deviations` feature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))] pub enum Deviation { /// Full production confinement recipe. + #[default] Full, + /// Weaker LPAC-only positive control: CreateProcessW, zero capabilities, /// no restricted primary token, no mitigations, no job. + #[cfg(feature = "deviations")] LpacOnly, /// Weaker plain positive control: CreateProcessW without AppContainer or mitigations. + #[cfg(feature = "deviations")] Plain, // Worker refusal reason token deviations /// Retains or injects an impersonation thread token before first frame read. + #[cfg(feature = "deviations")] ThreadTokenPresent, /// Prevents lowering primary token integrity to Untrusted. + #[cfg(feature = "deviations")] IntegrityLowerFailed, /// Omits LPAC attributes (e.g. WIN://NOALLAPPPKG claim or wrong package SID). + #[cfg(feature = "deviations")] NotLpac, /// Injects a non-empty capability list into the token. + #[cfg(feature = "deviations")] CapabilitiesPresent, /// Primary token restricting SIDs are not exactly `{S-1-0-0}`. + #[cfg(feature = "deviations")] RestrictingSidMismatch, /// Retains an access group as enabled rather than deny-only. + #[cfg(feature = "deviations")] GroupNotDenyOnly, /// Retains privileges on the primary token instead of clearing them. + #[cfg(feature = "deviations")] PrivilegesPresent, /// Primary token integrity is not lowered to Untrusted. + #[cfg(feature = "deviations")] IntegrityNotUntrusted, /// Omits required mitigation policy flags or enables forbidden bits. + #[cfg(feature = "deviations")] MitigationMismatch, /// Leaks unpermitted handles or omits handle list restriction. + #[cfg(feature = "deviations")] HandleNotAllowed, // Parent pre-resume check deviations /// Intentionally mismatches job limits or flags readback. + #[cfg(feature = "deviations")] JobLimitsMismatch, /// Omits the job list attribute so the process is not in the owned job. + #[cfg(feature = "deviations")] NotInOwnedJob, /// Suspended primary token differs from expected birth token attributes. + #[cfg(feature = "deviations")] BirthTokenMismatch, /// Thread token handle is not closed before resume, or token level differs. + #[cfg(feature = "deviations")] InitialTokenOpen, } @@ -60,21 +79,37 @@ impl Deviation { pub const fn as_str(&self) -> &'static str { match self { Self::Full => "full", + #[cfg(feature = "deviations")] Self::LpacOnly => "lpac-only", + #[cfg(feature = "deviations")] Self::Plain => "plain", + #[cfg(feature = "deviations")] Self::ThreadTokenPresent => "thread-token-present", + #[cfg(feature = "deviations")] Self::IntegrityLowerFailed => "integrity-lower-failed", + #[cfg(feature = "deviations")] Self::NotLpac => "not-lpac", + #[cfg(feature = "deviations")] Self::CapabilitiesPresent => "capabilities-present", + #[cfg(feature = "deviations")] Self::RestrictingSidMismatch => "restricting-sid-mismatch", + #[cfg(feature = "deviations")] Self::GroupNotDenyOnly => "group-not-deny-only", + #[cfg(feature = "deviations")] Self::PrivilegesPresent => "privileges-present", + #[cfg(feature = "deviations")] Self::IntegrityNotUntrusted => "integrity-not-untrusted", + #[cfg(feature = "deviations")] Self::MitigationMismatch => "mitigation-mismatch", + #[cfg(feature = "deviations")] Self::HandleNotAllowed => "handle-not-allowed", + #[cfg(feature = "deviations")] Self::JobLimitsMismatch => "job-limits-mismatch", + #[cfg(feature = "deviations")] Self::NotInOwnedJob => "not-in-owned-job", + #[cfg(feature = "deviations")] Self::BirthTokenMismatch => "birth-token-mismatch", + #[cfg(feature = "deviations")] Self::InitialTokenOpen => "initial-token-open", } } @@ -83,15 +118,25 @@ impl Deviation { /// exit 70 stderr reason token. pub const fn worker_reason(&self) -> Option<&'static str> { match self { + #[cfg(feature = "deviations")] Self::ThreadTokenPresent => Some("thread-token-present"), + #[cfg(feature = "deviations")] Self::IntegrityLowerFailed => Some("integrity-lower-failed"), + #[cfg(feature = "deviations")] Self::NotLpac => Some("not-lpac"), + #[cfg(feature = "deviations")] Self::CapabilitiesPresent => Some("capabilities-present"), + #[cfg(feature = "deviations")] Self::RestrictingSidMismatch => Some("restricting-sid-mismatch"), + #[cfg(feature = "deviations")] Self::GroupNotDenyOnly => Some("group-not-deny-only"), + #[cfg(feature = "deviations")] Self::PrivilegesPresent => Some("privileges-present"), + #[cfg(feature = "deviations")] Self::IntegrityNotUntrusted => Some("integrity-not-untrusted"), + #[cfg(feature = "deviations")] Self::MitigationMismatch => Some("mitigation-mismatch"), + #[cfg(feature = "deviations")] Self::HandleNotAllowed => Some("handle-not-allowed"), _ => None, } @@ -101,9 +146,13 @@ impl Deviation { /// parent pre-resume refusal reason. pub const fn parent_reason(&self) -> Option<&'static str> { match self { + #[cfg(feature = "deviations")] Self::JobLimitsMismatch => Some("job-limits-mismatch"), + #[cfg(feature = "deviations")] Self::NotInOwnedJob => Some("not-in-owned-job"), + #[cfg(feature = "deviations")] Self::BirthTokenMismatch => Some("birth-token-mismatch"), + #[cfg(feature = "deviations")] Self::InitialTokenOpen => Some("initial-token-open"), _ => None, } @@ -111,7 +160,13 @@ impl Deviation { /// Returns true if this is one of the positive control modes (Full, LpacOnly, Plain). pub const fn is_control(&self) -> bool { - matches!(self, Self::Full | Self::LpacOnly | Self::Plain) + match self { + Self::Full => true, + #[cfg(feature = "deviations")] + Self::LpacOnly | Self::Plain => true, + #[cfg(feature = "deviations")] + _ => false, + } } /// Returns true if this deviation targets a parent pre-resume check. @@ -137,21 +192,37 @@ impl FromStr for Deviation { fn from_str(s: &str) -> Result { match s { "full" => Ok(Self::Full), + #[cfg(feature = "deviations")] "lpac-only" => Ok(Self::LpacOnly), + #[cfg(feature = "deviations")] "plain" => Ok(Self::Plain), + #[cfg(feature = "deviations")] "thread-token-present" => Ok(Self::ThreadTokenPresent), + #[cfg(feature = "deviations")] "integrity-lower-failed" => Ok(Self::IntegrityLowerFailed), + #[cfg(feature = "deviations")] "not-lpac" => Ok(Self::NotLpac), + #[cfg(feature = "deviations")] "capabilities-present" => Ok(Self::CapabilitiesPresent), + #[cfg(feature = "deviations")] "restricting-sid-mismatch" => Ok(Self::RestrictingSidMismatch), + #[cfg(feature = "deviations")] "group-not-deny-only" => Ok(Self::GroupNotDenyOnly), + #[cfg(feature = "deviations")] "privileges-present" => Ok(Self::PrivilegesPresent), + #[cfg(feature = "deviations")] "integrity-not-untrusted" => Ok(Self::IntegrityNotUntrusted), + #[cfg(feature = "deviations")] "mitigation-mismatch" => Ok(Self::MitigationMismatch), + #[cfg(feature = "deviations")] "handle-not-allowed" => Ok(Self::HandleNotAllowed), + #[cfg(feature = "deviations")] "job-limits-mismatch" => Ok(Self::JobLimitsMismatch), + #[cfg(feature = "deviations")] "not-in-owned-job" => Ok(Self::NotInOwnedJob), + #[cfg(feature = "deviations")] "birth-token-mismatch" => Ok(Self::BirthTokenMismatch), + #[cfg(feature = "deviations")] "initial-token-open" => Ok(Self::InitialTokenOpen), other => Err(format!("unknown deviation: {other}")), } diff --git a/crates/basal-launch/src/windows/job.rs b/crates/basal-launch/src/windows/job.rs index 2f0ccd0..ca43d69 100644 --- a/crates/basal-launch/src/windows/job.rs +++ b/crates/basal-launch/src/windows/job.rs @@ -30,14 +30,18 @@ pub fn create_confined_job(commit_limit: u64, deviation: Deviation) -> Result, /// Commit limit in bytes for the job object. pub commit_limit: u64, - /// Confinement deviation mode (defaults to [`Deviation::Full`]). + /// Confinement deviation mode (available only with the `deviations` feature). + #[cfg(feature = "deviations")] pub deviation: Deviation, /// Optional environment overrides (defaults to retaining only `SystemRoot`). pub environment: Option>, @@ -58,6 +62,7 @@ impl LaunchOptions { args: Vec::new(), package_sid: None, commit_limit, + #[cfg(feature = "deviations")] deviation: Deviation::Full, environment: None, } @@ -81,7 +86,8 @@ impl LaunchOptions { self } - /// Sets the confinement deviation mode. + /// Sets the confinement deviation mode. Available only with the `deviations` feature. + #[cfg(feature = "deviations")] pub fn with_deviation(&mut self, deviation: Deviation) -> &mut Self { self.deviation = deviation; self @@ -97,8 +103,20 @@ impl LaunchOptions { /// Launches a child worker process under Windows confinement according to [`LaunchOptions`]. pub fn launch(options: &LaunchOptions) -> Result { unsafe { + #[cfg(feature = "deviations")] + let deviation = options.deviation; + #[cfg(not(feature = "deviations"))] + let deviation = Deviation::Full; + // Resolve package profile and SID - let package_sid = if options.deviation == Deviation::Plain { + #[allow(unused_mut)] + let mut need_package_sid = true; + #[cfg(feature = "deviations")] + if deviation == Deviation::Plain { + need_package_sid = false; + } + + let package_sid = if !need_package_sid { None } else if let Some(sid_str) = &options.package_sid { Some(AppContainerSid::from_string_sid(sid_str)?) @@ -145,7 +163,8 @@ pub fn launch(options: &LaunchOptions) -> Result { }; // Handle plain control launch - if options.deviation == Deviation::Plain { + #[cfg(feature = "deviations")] + if deviation == Deviation::Plain { let mut si: STARTUPINFOW = zeroed(); si.cb = size_of::() as u32; si.dwFlags = STARTF_USESTDHANDLES; @@ -193,14 +212,15 @@ pub fn launch(options: &LaunchOptions) -> Result { Some(stdin_file), Some(stdout_file), Some(stderr_file), - null_mut(), + std::ptr::null_mut(), )); } let package_sid = package_sid.unwrap(); // Handle LPAC-only control launch - if options.deviation == Deviation::LpacOnly { + #[cfg(feature = "deviations")] + if deviation == Deviation::LpacOnly { let mut attrs = AttributeList::new(3)?; let handles = [child_stdin.0, child_stdout.0, child_stderr.0]; attrs.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; @@ -269,24 +289,24 @@ pub fn launch(options: &LaunchOptions) -> Result { Some(stdin_file), Some(stdout_file), Some(stderr_file), - null_mut(), + std::ptr::null_mut(), )); } // Full confinement or deviation launch - let job = create_confined_job(options.commit_limit, options.deviation)?; - let mut tokens = construct_tokens(&package_sid, options.deviation)?; + let job = create_confined_job(options.commit_limit, deviation)?; + let mut tokens = construct_tokens(&package_sid, deviation)?; let mut attrs = AttributeList::new(6)?; // Handle list attribute - let planted_handle = if options.deviation == Deviation::HandleNotAllowed { - // Plant an extra inheritable handle (e.g. parent end of pipe) into the list + #[allow(unused_mut)] + let mut planted_handle: Option = None; + #[cfg(feature = "deviations")] + if deviation == Deviation::HandleNotAllowed { let (extra_r, _extra_w) = create_pipe(true)?; - Some(extra_r) - } else { - None - }; + planted_handle = Some(extra_r); + } if let Some(extra) = &planted_handle { let handles = [child_stdin.0, child_stdout.0, child_stderr.0, extra.0]; @@ -298,11 +318,13 @@ pub fn launch(options: &LaunchOptions) -> Result { // Security capabilities attribute let mut dummy_cap: SID_AND_ATTRIBUTES = zeroed(); - let cap_count = if options.deviation == Deviation::CapabilitiesPresent { - 1 - } else { - 0 - }; + #[allow(unused_mut)] + let mut cap_count = 0; + #[cfg(feature = "deviations")] + if deviation == Deviation::CapabilitiesPresent { + cap_count = 1; + } + let security = SECURITY_CAPABILITIES { AppContainerSid: package_sid.as_psid(), Capabilities: &mut dummy_cap as *mut _, @@ -312,7 +334,14 @@ pub fn launch(options: &LaunchOptions) -> Result { attrs.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; // All application packages opt-out policy - if options.deviation != Deviation::NotLpac { + #[allow(unused_mut)] + let mut add_opt_out = true; + #[cfg(feature = "deviations")] + if deviation == Deviation::NotLpac { + add_opt_out = false; + } + + if add_opt_out { attrs.add( PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, &PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, @@ -320,14 +349,23 @@ pub fn launch(options: &LaunchOptions) -> Result { } // Job list attribute - if options.deviation != Deviation::NotInOwnedJob { + #[allow(unused_mut)] + let mut add_job = true; + #[cfg(feature = "deviations")] + if deviation == Deviation::NotInOwnedJob { + add_job = false; + } + + if add_job { let job_handles = [job.0]; attrs.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job_handles)?; } // Mitigation policy + #[allow(unused_mut)] let mut mitigations = MITIGATION_POLICY_FLAGS; - if options.deviation == Deviation::MitigationMismatch { + #[cfg(feature = "deviations")] + if deviation == Deviation::MitigationMismatch { // Omit ProhibitDynamicCode (bit 36) mitigations &= !(1 << 36); } @@ -404,12 +442,9 @@ pub fn launch(options: &LaunchOptions) -> Result { } // Pre-resume check 3: Initial thread token and close - if let Err(e) = check_initial_token_and_close( - main_thread.0, - &mut tokens, - &package_sid, - options.deviation, - ) { + if let Err(e) = + check_initial_token_and_close(main_thread.0, &mut tokens, &package_sid, deviation) + { TerminateProcess(process.0, WORKER_KILL_CODE); TerminateJobObject(job.0, WORKER_KILL_CODE); return Err(e); diff --git a/crates/basal-launch/src/windows/mod.rs b/crates/basal-launch/src/windows/mod.rs index 0386f43..1a942e3 100644 --- a/crates/basal-launch/src/windows/mod.rs +++ b/crates/basal-launch/src/windows/mod.rs @@ -25,6 +25,7 @@ mod tests { use super::*; #[test] + #[cfg(feature = "deviations")] fn deviation_tokens_match_specification() { assert_eq!(Deviation::Full.as_str(), "full"); assert_eq!(Deviation::LpacOnly.as_str(), "lpac-only"); @@ -86,6 +87,18 @@ mod tests { ); } + #[test] + #[cfg(not(feature = "deviations"))] + fn deviations_absent_in_production() { + let d = Deviation::Full; + match d { + Deviation::Full => (), + } + assert_eq!(d.as_str(), "full"); + assert_eq!(d.worker_reason(), None); + assert_eq!(d.parent_reason(), None); + } + #[test] fn fault_accounting_predicate_matches_specification() { assert!(is_confinement_fault_code(0xC0000008)); diff --git a/crates/basal-launch/src/windows/process.rs b/crates/basal-launch/src/windows/process.rs index 91e68c9..222fa5a 100644 --- a/crates/basal-launch/src/windows/process.rs +++ b/crates/basal-launch/src/windows/process.rs @@ -31,6 +31,15 @@ pub struct OwnedProcess { unsafe impl Send for OwnedProcess {} unsafe impl Sync for OwnedProcess {} +impl std::fmt::Debug for OwnedProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OwnedProcess") + .field("pid", &self.pid) + .field("killed", &self.killed) + .finish() + } +} + impl OwnedProcess { /// Creates a new `OwnedProcess` wrapper from raw handles and optional stdio streams. pub fn new( diff --git a/crates/basal-launch/src/windows/token.rs b/crates/basal-launch/src/windows/token.rs index 6fb6f41..1e2e20b 100644 --- a/crates/basal-launch/src/windows/token.rs +++ b/crates/basal-launch/src/windows/token.rs @@ -79,6 +79,7 @@ pub fn construct_tokens( deviation: Deviation, ) -> Result { unsafe { + let _ = deviation; let mut parent_token = null_mut(); if OpenProcessToken( GetCurrentProcess(), @@ -94,10 +95,11 @@ pub fn construct_tokens( let mut disabled = Vec::new(); for (i, g) in groups(&data).iter().enumerate() { if g.Attributes & SE_GROUP_INTEGRITY == 0 { - // If GroupNotDenyOnly deviation is set, leave one group enabled + #[cfg(feature = "deviations")] if deviation == Deviation::GroupNotDenyOnly && i == 0 { continue; } + let _ = i; disabled.push(SID_AND_ATTRIBUTES { Sid: g.Sid, Attributes: 0, @@ -122,11 +124,12 @@ pub fn construct_tokens( Attributes: 0, }]; - let restrict: &[SID_AND_ATTRIBUTES] = if deviation == Deviation::RestrictingSidMismatch { - &[] - } else { - &null_restrict - }; + #[allow(unused_mut)] + let mut restrict: &[SID_AND_ATTRIBUTES] = &null_restrict; + #[cfg(feature = "deviations")] + if deviation == Deviation::RestrictingSidMismatch { + restrict = &[]; + } let mut lockdown = null_mut(); if CreateRestrictedToken( @@ -146,7 +149,14 @@ pub fn construct_tokens( let lockdown = Handle(lockdown); // Strip any surviving privileges (such as SeChangeNotifyPrivilege) unless PrivilegesPresent - if deviation != Deviation::PrivilegesPresent { + #[allow(unused_mut)] + let mut strip_privileges = true; + #[cfg(feature = "deviations")] + if deviation == Deviation::PrivilegesPresent { + strip_privileges = false; + } + + if strip_privileges { let p = token_buffer(lockdown.0, TokenPrivileges)?; let p = &*p.as_ptr().cast::(); for entry in @@ -164,7 +174,14 @@ pub fn construct_tokens( } // Set integrity level + #[allow(unused_mut)] + let mut medium_integrity = false; + #[cfg(feature = "deviations")] if deviation == Deviation::IntegrityNotUntrusted { + medium_integrity = true; + } + + if medium_integrity { set_integrity(lockdown.0, WinMediumLabelSid)?; } else { set_integrity(lockdown.0, WinLowLabelSid)?; @@ -229,11 +246,12 @@ pub fn construct_tokens( let mut lowbox = null_mut(); let dummy_cap: SID_AND_ATTRIBUTES = zeroed(); let caps_ptr = &dummy_cap as *const SID_AND_ATTRIBUTES; - let cap_count = if deviation == Deviation::CapabilitiesPresent { - 1 - } else { - 0 - }; + #[allow(unused_mut)] + let mut cap_count = 0; + #[cfg(feature = "deviations")] + if deviation == Deviation::CapabilitiesPresent { + cap_count = 1; + } let status = NtCreateLowBoxToken( &mut lowbox, @@ -255,11 +273,12 @@ pub fn construct_tokens( let loader_dacl = token_dacl(lowbox.0)?; let mut impersonation = null_mut(); - let imp_level = if deviation == Deviation::InitialTokenOpen { - SecurityIdentification // Level 1 instead of Level 2 - } else { - SecurityImpersonation // Level 2 - }; + #[allow(unused_mut)] + let mut imp_level = SecurityImpersonation; // Level 2 + #[cfg(feature = "deviations")] + if deviation == Deviation::InitialTokenOpen { + imp_level = SecurityIdentification; // Level 1 instead of Level 2 + } if DuplicateTokenEx( lowbox.0, @@ -465,6 +484,7 @@ pub fn check_initial_token_and_close( deviation: Deviation, ) -> Result<(), LaunchError> { unsafe { + let _ = deviation; // Read back thread token from suspended main thread let mut thread_token = null_mut(); if OpenThreadToken(main_thread, TOKEN_QUERY, 1, &mut thread_token) == 0 { @@ -516,6 +536,7 @@ pub fn check_initial_token_and_close( } // Check handle close before resume + #[cfg(feature = "deviations")] if deviation == Deviation::InitialTokenOpen { // Intentionally leak or leave the initial handle open to trigger refusal return Err(LaunchError::InitialTokenOpen( diff --git a/crates/basal-launch/tests/windows_launch.rs b/crates/basal-launch/tests/windows_launch.rs new file mode 100644 index 0000000..5085eca --- /dev/null +++ b/crates/basal-launch/tests/windows_launch.rs @@ -0,0 +1,217 @@ +#![cfg(windows)] + +#[test] +fn test_deviations_feature_contract() { + #[cfg(not(feature = "deviations"))] + { + let d = basal_launch::Deviation::Full; + match d { + basal_launch::Deviation::Full => (), + } + assert_eq!(d.as_str(), "full"); + } + + #[cfg(feature = "deviations")] + { + let all = [ + basal_launch::Deviation::Full, + basal_launch::Deviation::LpacOnly, + basal_launch::Deviation::Plain, + basal_launch::Deviation::ThreadTokenPresent, + basal_launch::Deviation::IntegrityLowerFailed, + basal_launch::Deviation::NotLpac, + basal_launch::Deviation::CapabilitiesPresent, + basal_launch::Deviation::RestrictingSidMismatch, + basal_launch::Deviation::GroupNotDenyOnly, + basal_launch::Deviation::PrivilegesPresent, + basal_launch::Deviation::IntegrityNotUntrusted, + basal_launch::Deviation::MitigationMismatch, + basal_launch::Deviation::HandleNotAllowed, + basal_launch::Deviation::JobLimitsMismatch, + basal_launch::Deviation::NotInOwnedJob, + basal_launch::Deviation::BirthTokenMismatch, + basal_launch::Deviation::InitialTokenOpen, + ]; + assert_eq!(all.len(), 17); + for d in all { + assert!(!d.as_str().is_empty()); + } + } +} + +#[cfg(all(windows, feature = "deviations"))] +mod windows_tests { + use basal_launch::*; + use std::path::PathBuf; + use windows_sys::Win32::{ + Foundation::*, Security::*, System::JobObjects::*, System::Threading::*, + }; + + fn find_test_helper() -> PathBuf { + let mut path = std::env::current_exe().expect("current_exe"); + path.pop(); // remove test binary name + if path.file_name().and_then(|s| s.to_str()) == Some("deps") { + path.pop(); // remove deps + } + let helper = path.join("basal-launch-test-helper.exe"); + assert!( + helper.exists(), + "test helper binary not found at {}", + helper.display() + ); + helper + } + + #[test] + fn test_full_confinement_spawn_attestation_and_kill() { + let helper = find_test_helper(); + let profile = create_or_open_profile(PROFILE_NAME).expect("create_or_open_profile"); + + // Grant test binary directory access to the AppContainer package SID + grant_test_binary_directory(helper.parent().unwrap(), profile.sid()) + .expect("grant_test_binary_directory"); + + const COMMIT_LIMIT: u64 = 512 * 1024 * 1024; + let mut options = LaunchOptions::new(&helper, COMMIT_LIMIT); + options.with_deviation(Deviation::Full); + + let mut child = launch(&options).expect("launch full confinement"); + assert!(child.id() > 0, "child pid must be non-zero"); + + unsafe { + // Parent side verification: check process is in owned job + let mut in_job = 0; + let ok = IsProcessInJob(child.process_handle(), child.job_handle(), &mut in_job); + assert_ne!(ok, 0, "IsProcessInJob API must succeed"); + assert_ne!(in_job, 0, "child must be in the owned job"); + + // Parent side verification: query primary token + let mut token = std::ptr::null_mut(); + let ok = OpenProcessToken(child.process_handle(), TOKEN_QUERY, &mut token); + assert_ne!(ok, 0, "OpenProcessToken must succeed from parent"); + + // 1. Token type + let mut token_type = 0u32; + let mut bytes = 0; + let ok = GetTokenInformation( + token, + TokenType, + (&mut token_type as *mut u32).cast(), + 4, + &mut bytes, + ); + assert_ne!(ok, 0, "GetTokenInformation(TokenType)"); + assert_eq!(token_type, TokenPrimary as u32); + + // 2. Low integrity + let mut label_buf = [0usize; 64]; + let ok = GetTokenInformation( + token, + TokenIntegrityLevel, + label_buf.as_mut_ptr().cast(), + (label_buf.len() * std::mem::size_of::()) as u32, + &mut bytes, + ); + assert_ne!(ok, 0, "GetTokenInformation(TokenIntegrityLevel)"); + let label = &*label_buf.as_ptr().cast::(); + let sub_count = *GetSidSubAuthorityCount(label.Label.Sid); + assert!(sub_count > 0); + let last_sub = *GetSidSubAuthority(label.Label.Sid, (sub_count - 1) as u32); + assert_eq!(last_sub, 0x1000, "integrity must be Low (0x1000)"); + + // 3. AppContainer SID matches + let mut app_buf = [0usize; 64]; + let ok = GetTokenInformation( + token, + TokenAppContainerSid, + app_buf.as_mut_ptr().cast(), + (app_buf.len() * std::mem::size_of::()) as u32, + &mut bytes, + ); + assert_ne!(ok, 0, "GetTokenInformation(TokenAppContainerSid)"); + let app_sid = + (*app_buf.as_ptr().cast::()).TokenAppContainer; + assert!(!app_sid.is_null()); + assert!(profile.sid().matches(app_sid)); + + // 4. Zero capabilities + let mut cap_buf = [0usize; 64]; + let ok = GetTokenInformation( + token, + TokenCapabilities, + cap_buf.as_mut_ptr().cast(), + (cap_buf.len() * std::mem::size_of::()) as u32, + &mut bytes, + ); + assert_ne!(ok, 0, "GetTokenInformation(TokenCapabilities)"); + let cap_count = (*cap_buf.as_ptr().cast::()).GroupCount; + assert_eq!(cap_count, 0, "capabilities must be zero"); + + CloseHandle(token); + } + + // Kill child and verify exit code + child.kill().expect("child.kill"); + let exit_code = child.wait().expect("child.wait"); + assert_eq!( + exit_code, WORKER_KILL_CODE, + "exit code must match WORKER_KILL_CODE" + ); + } + + #[test] + fn test_parent_check_job_limits_mismatch_refused() { + let helper = find_test_helper(); + let mut options = LaunchOptions::new(&helper, 512 * 1024 * 1024); + options.with_deviation(Deviation::JobLimitsMismatch); + + let err = launch(&options).expect_err("JobLimitsMismatch must be refused"); + assert_eq!( + err.reason_token(), + Some("job-limits-mismatch"), + "expected job-limits-mismatch, got: {err}" + ); + } + + #[test] + fn test_parent_check_not_in_owned_job_refused() { + let helper = find_test_helper(); + let mut options = LaunchOptions::new(&helper, 512 * 1024 * 1024); + options.with_deviation(Deviation::NotInOwnedJob); + + let err = launch(&options).expect_err("NotInOwnedJob must be refused"); + assert_eq!( + err.reason_token(), + Some("not-in-owned-job"), + "expected not-in-owned-job, got: {err}" + ); + } + + #[test] + fn test_parent_check_birth_token_mismatch_refused() { + let helper = find_test_helper(); + let mut options = LaunchOptions::new(&helper, 512 * 1024 * 1024); + options.with_deviation(Deviation::BirthTokenMismatch); + + let err = launch(&options).expect_err("BirthTokenMismatch must be refused"); + assert_eq!( + err.reason_token(), + Some("birth-token-mismatch"), + "expected birth-token-mismatch, got: {err}" + ); + } + + #[test] + fn test_parent_check_initial_token_open_refused() { + let helper = find_test_helper(); + let mut options = LaunchOptions::new(&helper, 512 * 1024 * 1024); + options.with_deviation(Deviation::InitialTokenOpen); + + let err = launch(&options).expect_err("InitialTokenOpen must be refused"); + assert_eq!( + err.reason_token(), + Some("initial-token-open"), + "expected initial-token-open, got: {err}" + ); + } +} From 109994cf17a7bd39ef3d3a57ec8eef942307d124 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:10:19 +0200 Subject: [PATCH 3/3] mason: fix token access rights and concurrent profile creation --- .github/workflows/ci.yml | 2 +- crates/basal-launch/src/windows/profile.rs | 11 +++- crates/basal-launch/src/windows/token.rs | 59 +++++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e82fd3f..e8bcceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -410,7 +410,7 @@ jobs: id: test-basal-launch continue-on-error: true timeout-minutes: 20 - run: cargo test -p basal-launch --features deviations 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" + run: cargo test -p basal-launch --features deviations --locked 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" - name: Summarize the baseline if: always() run: | diff --git a/crates/basal-launch/src/windows/profile.rs b/crates/basal-launch/src/windows/profile.rs index 6f548fa..ecc950d 100644 --- a/crates/basal-launch/src/windows/profile.rs +++ b/crates/basal-launch/src/windows/profile.rs @@ -169,11 +169,15 @@ impl AppContainerProfile { } } +static PROFILE_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Creates or opens the named AppContainer profile with zero capabilities. /// /// If the profile already exists, derives the same package SID. +/// Thread-safe and idempotent under concurrent calls. /// Failure produces `LaunchError::AppContainerProfileUnavailable`. pub fn create_or_open_profile(name: &str) -> Result { + let _guard = PROFILE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let module = UserEnvModule::load()?; let create: CreateAppContainerProfileFn = unsafe { module.symbol(b"CreateAppContainerProfile\0")? }; @@ -204,8 +208,11 @@ pub fn create_or_open_profile(name: &str) -> Result= 0 { diff --git a/crates/basal-launch/src/windows/token.rs b/crates/basal-launch/src/windows/token.rs index 1e2e20b..09e4b74 100644 --- a/crates/basal-launch/src/windows/token.rs +++ b/crates/basal-launch/src/windows/token.rs @@ -49,7 +49,7 @@ pub unsafe fn set_integrity(token: HANDLE, level: WELL_KNOWN_SID_TYPE) -> Result if CreateWellKnownSid(level, null_mut(), sid.as_mut_ptr().cast(), &mut bytes) == 0 { return Err(last_win32_error("CreateWellKnownSid(integrity)")); } - let mut label = TOKEN_MANDATORY_LABEL { + let label = TOKEN_MANDATORY_LABEL { Label: SID_AND_ATTRIBUTES { Sid: sid.as_mut_ptr().cast(), Attributes: SE_GROUP_INTEGRITY, @@ -58,7 +58,7 @@ pub unsafe fn set_integrity(token: HANDLE, level: WELL_KNOWN_SID_TYPE) -> Result if SetTokenInformation( token, TokenIntegrityLevel, - (&mut label as *mut TOKEN_MANDATORY_LABEL).cast(), + (&label as *const TOKEN_MANDATORY_LABEL).cast(), size_of::() as u32, ) == 0 { @@ -81,13 +81,17 @@ pub fn construct_tokens( unsafe { let _ = deviation; let mut parent_token = null_mut(); - if OpenProcessToken( - GetCurrentProcess(), - TOKEN_QUERY | TOKEN_DUPLICATE, - &mut parent_token, - ) == 0 - { - return Err(last_win32_error("OpenProcessToken(current process)")); + // Open parent token with TOKEN_ALL_ACCESS. + // If TOKEN_ALL_ACCESS fails, fall back to the exact required rights including TOKEN_ADJUST_DEFAULT. + if OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &mut parent_token) == 0 { + let fallback = TOKEN_QUERY + | TOKEN_DUPLICATE + | TOKEN_ADJUST_DEFAULT + | TOKEN_ADJUST_PRIVILEGES + | TOKEN_ASSIGN_PRIMARY; + if OpenProcessToken(GetCurrentProcess(), fallback, &mut parent_token) == 0 { + return Err(last_win32_error("OpenProcessToken(current process)")); + } } let parent_token = Handle(parent_token); @@ -181,10 +185,28 @@ pub fn construct_tokens( medium_integrity = true; } - if medium_integrity { - set_integrity(lockdown.0, WinMediumLabelSid)?; + let current = token_buffer(lockdown.0, TokenIntegrityLevel)?; + let current_label = &*current.as_ptr().cast::(); + let sub_count = *GetSidSubAuthorityCount(current_label.Label.Sid); + let current_sub = if sub_count > 0 { + *GetSidSubAuthority(current_label.Label.Sid, (sub_count - 1) as u32) + } else { + 0 + }; + let target_sub = if medium_integrity { + SECURITY_MANDATORY_MEDIUM_RID } else { - set_integrity(lockdown.0, WinLowLabelSid)?; + SECURITY_MANDATORY_LOW_RID + }; + if current_sub != target_sub { + set_integrity( + lockdown.0, + if medium_integrity { + WinMediumLabelSid + } else { + WinLowLabelSid + }, + )?; } // Now construct the initial thread token @@ -232,7 +254,18 @@ pub fn construct_tokens( )); } - set_integrity(loader.0, WinLowLabelSid)?; + // Lower integrity to Low before lowboxing + let level = token_buffer(loader.0, TokenIntegrityLevel)?; + let level_label = &*level.as_ptr().cast::(); + let sub_count = *GetSidSubAuthorityCount(level_label.Label.Sid); + let current_sub = if sub_count > 0 { + *GetSidSubAuthority(level_label.Label.Sid, (sub_count - 1) as u32) + } else { + 0 + }; + if current_sub != SECURITY_MANDATORY_LOW_RID { + set_integrity(loader.0, WinLowLabelSid)?; + } // Lowbox the loader token let mut attrs = ObjectAttributes {