diff --git a/docs/process-container/UIPolicy_Schema.md b/docs/process-container/UIPolicy_Schema.md index dc7f16e0a6..69a4a43960 100644 --- a/docs/process-container/UIPolicy_Schema.md +++ b/docs/process-container/UIPolicy_Schema.md @@ -97,6 +97,8 @@ The `"ui"` section is a sibling of `"processContainer"`, `"filesystem"`, `"netwo | `"write"` | `UILIMIT_READCLIPBOARD` | ❌ Blocked | ✅ Allowed | Process can copy but not paste in | | `"none"` | `UILIMIT_READCLIPBOARD` + `UILIMIT_WRITECLIPBOARD` | ❌ Blocked | ❌ Blocked | Complete clipboard isolation | +> ✅ means MXC does not set the corresponding UI limit. It does not explicitly enable clipboard access — other parts of the process-container security environment may still deny it. + --- ### `isolation` @@ -165,6 +167,8 @@ The `"ui"` section is a sibling of `"processContainer"`, `"filesystem"`, `"netwo | `"display"` | `UILIMIT_SYSTEMPARAMETERS` | ❌ Blocked | ✅ Allowed | Process can change display but not UI params | | `"none"` | `UILIMIT_SYSTEMPARAMETERS` + `UILIMIT_DISPLAYSETTINGS` | ❌ Blocked | ❌ Blocked | No system settings changes | +> ✅ means MXC does not set the corresponding UI limit. It does not explicitly enable the setting change — other parts of the process-container security environment may still deny it. + --- ### `ime` @@ -262,7 +266,7 @@ All defaults apply — maximum lockdown. ### Example 3: Selective permissions — GUI with clipboard access -Process has full GUI access and can interact with other windows on the desktop. Full clipboard access is granted, but all other capabilities remain locked down. +Process keeps GUI access and can interact with other windows on the desktop. MXC sets no clipboard limit, but all other capabilities remain locked down. ```json "ui": { @@ -284,7 +288,7 @@ This section is an implementation reference for runner developers. The JSON fiel | `clipboard` | `"none"` | `JOB_OBJECT_UILIMIT_READCLIPBOARD` + `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` | | `clipboard` | `"read"` | `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` | | `clipboard` | `"write"` | `JOB_OBJECT_UILIMIT_READCLIPBOARD` | -| `clipboard` | `"all"` | *(no flags)* | +| `clipboard` | `"all"` | *(no flags; does not explicitly enable clipboard access)* | | `isolation` | `"container"` | `JOB_OBJECT_UILIMIT_HANDLES` + `JOB_OBJECT_UILIMIT_GLOBALATOMS` | | `isolation` | `"handles"` | `JOB_OBJECT_UILIMIT_HANDLES` | | `isolation` | `"atoms"` | `JOB_OBJECT_UILIMIT_GLOBALATOMS` | @@ -294,7 +298,7 @@ This section is an implementation reference for runner developers. The JSON fiel | `systemSettings` | `"none"` | `JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS` + `JOB_OBJECT_UILIMIT_DISPLAYSETTINGS` | | `systemSettings` | `"parameters"` | `JOB_OBJECT_UILIMIT_DISPLAYSETTINGS` | | `systemSettings` | `"display"` | `JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS` | -| `systemSettings` | `"all"` | *(no flags)* | +| `systemSettings` | `"all"` | *(no flags; does not explicitly enable the setting changes)* | | `ime` | `false` | `JOB_OBJECT_UILIMIT_IME` | | `injection` | `false` | `JOB_OBJECT_UILIMIT_INJECTION` | diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 8c7587a73e..b31e836546 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -175,6 +175,10 @@ would reject; `wxc-exec --probe` reports what a host can enforce. | `injection` — INJECTION (`0x200`, ≥ 26100) | ❌ | ✅ | ✅ | ✅ | | `disable` — `disallowWin32kSystemCalls` mitigation | ✅ | ✅ | ✅ | ✅ | +✅ here means the host can set the flag, not that the permissive value grants +the operation. Omitting a limit never enables anything; see +[UI policy schema](UIPolicy_Schema.md). + The single UI differentiator for 23H2 is **`injection`** (`JOB_OBJECT_UILIMIT_INJECTION`), which the kernel accepts only on build 26100 and later (`MIN_BUILD_FOR_INJECTION_LIMIT`) and is therefore unavailable on diff --git a/src/testing/wxc_ui_probe/src/main.rs b/src/testing/wxc_ui_probe/src/main.rs index cea5aa379e..efbfd1e398 100644 --- a/src/testing/wxc_ui_probe/src/main.rs +++ b/src/testing/wxc_ui_probe/src/main.rs @@ -148,6 +148,8 @@ struct WndClassExW { } const CF_UNICODETEXT: u32 = 13; +/// Text the read probe puts on the clipboard under test before reading it back. +const SEED_CLIPBOARD_TEXT: &str = "MXC_UI_PROBE_READ_SEED"; const GMEM_MOVEABLE: u32 = 0x0002; const SPI_SETMOUSESPEED: u32 = 0x0071; const SPIF_SENDCHANGE: u32 = 0x0002; @@ -368,10 +370,157 @@ fn probe_globalatoms(args: &ProbeArgs) { } } +/// Puts `CF_UNICODETEXT` on whatever clipboard the *calling* process sees and +/// returns the owner window, which the caller must keep alive until it has read +/// the data back and then destroy. +/// +/// The clipboard is per-window-station, so a seed placed by the harness is not +/// necessarily on the one a contained process reads. Seeding from inside is the +/// only way to guarantee the format is present on the clipboard under test. +/// +/// The owner outlives the seed deliberately: this process pumps no messages, so +/// destroying it early leaves `WM_RENDERALLFORMATS` unhandled and the data can +/// go unreadable while the format list still advertises it. +/// +/// This travels the write path, so `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` blocks +/// it; callers report that rather than treating it as fatal. +fn seed_unicode_clipboard(user32: Hmodule) -> Result { + type OpenClipboardFn = unsafe extern "system" fn(Hwnd) -> Bool; + type CloseClipboardFn = unsafe extern "system" fn() -> Bool; + type EmptyClipboardFn = unsafe extern "system" fn() -> Bool; + type SetClipboardDataFn = unsafe extern "system" fn(u32, Hglobal) -> Hglobal; + #[allow(clippy::type_complexity)] + type CreateWindowExWFn = unsafe extern "system" fn( + u32, + *const u16, + *const u16, + u32, + i32, + i32, + i32, + i32, + Hwnd, + *mut c_void, + Hmodule, + *mut c_void, + ) -> Hwnd; + type DestroyWindowFn = unsafe extern "system" fn(Hwnd) -> Bool; + + let resolve = |name: &str| get_proc(user32, name).ok_or(format!("{name} not resolvable")); + let open = + unsafe { std::mem::transmute::(resolve("OpenClipboard")?) }; + let empty = + unsafe { std::mem::transmute::(resolve("EmptyClipboard")?) }; + let set_data = + unsafe { std::mem::transmute::(resolve("SetClipboardData")?) }; + let create_window = + unsafe { std::mem::transmute::(resolve("CreateWindowExW")?) }; + let destroy_window = + unsafe { std::mem::transmute::(resolve("DestroyWindow")?) }; + let close = + unsafe { std::mem::transmute::(resolve("CloseClipboard")?) }; + + // SetClipboardData requires an owner window the calling thread owns. + let class_name = to_wide("STATIC"); + let window_name = to_wide("MxcUiClipboardSeed"); + let owner = unsafe { + create_window( + 0, + class_name.as_ptr(), + window_name.as_ptr(), + 0, + 0, + 0, + 0, + 0, + -3isize as Hwnd, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if owner.is_null() { + return Err(format!("CreateWindowExW failed gle={}", unsafe { + GetLastError() + })); + } + + // Closes the clipboard and tears the owner down; only the failure paths + // take it, since a successful seed must outlive this call. + let fail = |reason: String| -> Result { + unsafe { + let _ = close(); + let _ = destroy_window(owner); + } + Err(reason) + }; + + unsafe { + SetLastError(0); + } + if unsafe { open(owner) } == 0 { + let error = unsafe { GetLastError() }; + unsafe { + let _ = destroy_window(owner); + } + return Err(format!("OpenClipboard failed gle={error}")); + } + + let text = to_wide(SEED_CLIPBOARD_TEXT); + let byte_count = text.len() * std::mem::size_of::(); + let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_count) }; + if memory.is_null() { + return fail(format!("GlobalAlloc failed gle={}", unsafe { + GetLastError() + })); + } + let buffer = unsafe { GlobalLock(memory) }; + if buffer.is_null() { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return fail(format!("GlobalLock failed gle={error}")); + } + unsafe { + std::ptr::copy_nonoverlapping(text.as_ptr() as *const u8, buffer as *mut u8, byte_count); + let _ = GlobalUnlock(memory); + } + + unsafe { + SetLastError(0); + } + if unsafe { empty() } == 0 { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return fail(format!("EmptyClipboard failed gle={error}")); + } + + unsafe { + SetLastError(0); + } + if unsafe { set_data(CF_UNICODETEXT, memory) }.is_null() { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return fail(format!("SetClipboardData failed gle={error}")); + } + unsafe { + let _ = close(); + } + Ok(owner) +} + fn probe_readclipboard(user32: Hmodule) { type OpenClipboardFn = unsafe extern "system" fn(Hwnd) -> Bool; type CloseClipboardFn = unsafe extern "system" fn() -> Bool; type GetClipboardDataFn = unsafe extern "system" fn(u32) -> Hglobal; + type IsClipboardFormatAvailableFn = unsafe extern "system" fn(u32) -> Bool; + type CountClipboardFormatsFn = unsafe extern "system" fn() -> i32; + type DestroyWindowFn = unsafe extern "system" fn(Hwnd) -> Bool; let open = match get_proc(user32, "OpenClipboard") { Some(p) => unsafe { std::mem::transmute::(p) }, @@ -381,8 +530,14 @@ fn probe_readclipboard(user32: Hmodule) { return; } }; - let close = get_proc(user32, "CloseClipboard") - .map(|p| unsafe { std::mem::transmute::(p) }); + let close = match get_proc(user32, "CloseClipboard") { + Some(p) => unsafe { std::mem::transmute::(p) }, + None => { + emit_diag("READCLIPBOARD", "CloseClipboard not resolvable"); + emit_fail("READCLIPBOARD"); + return; + } + }; let get_data = match get_proc(user32, "GetClipboardData") { Some(p) => unsafe { std::mem::transmute::(p) }, None => { @@ -391,6 +546,22 @@ fn probe_readclipboard(user32: Hmodule) { return; } }; + let is_available = get_proc(user32, "IsClipboardFormatAvailable") + .map(|p| unsafe { std::mem::transmute::(p) }); + let count_formats = get_proc(user32, "CountClipboardFormats") + .map(|p| unsafe { std::mem::transmute::(p) }); + let destroy_window = get_proc(user32, "DestroyWindow") + .map(|p| unsafe { std::mem::transmute::(p) }); + + // The seed's owner window stays alive until the read has completed. + let seed = seed_unicode_clipboard(user32); + let release_seed = || { + if let (Ok(owner), Some(destroy)) = (&seed, destroy_window) { + unsafe { + let _ = destroy(*owner); + } + } + }; unsafe { SetLastError(0); @@ -398,6 +569,7 @@ fn probe_readclipboard(user32: Hmodule) { let opened = unsafe { open(std::ptr::null_mut()) }; let open_error = unsafe { GetLastError() }; if opened == 0 { + release_seed(); emit_diag( "READCLIPBOARD", &format!("OpenClipboard failed gle={open_error}"), @@ -405,30 +577,98 @@ fn probe_readclipboard(user32: Hmodule) { emit_inconclusive("READCLIPBOARD"); return; } + // Sampled before the fetch so "is the format even here" is answered + // independently of why the fetch failed. + let advertised = is_available.map(|f| unsafe { f(CF_UNICODETEXT) } != 0); + let format_count = count_formats.map(|f| unsafe { f() }); unsafe { SetLastError(0); } let data = unsafe { get_data(CF_UNICODETEXT) }; let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); + // The handle belongs to the clipboard and is only valid until CloseClipboard. + let readback = if data.is_null() { + None + } else { + let locked = unsafe { GlobalLock(data) }; + if locked.is_null() { + Some(Err(format!("GlobalLock failed gle={}", unsafe { + GetLastError() + }))) + } else { + let mut units: Vec = Vec::new(); + unsafe { + let mut cursor = locked as *const u16; + while *cursor != 0 && units.len() < 4096 { + units.push(*cursor); + cursor = cursor.add(1); + } + let _ = GlobalUnlock(data); + } + Some(Ok(String::from_utf16_lossy(&units))) } + }; + unsafe { + let _ = close(); } - if !data.is_null() { - emit_diag("READCLIPBOARD", "GetClipboardData returned clipboard text"); + release_seed(); + if let Some(readback) = readback { + // Never echo clipboard contents: on a shared window station they can + // be the host user's. + match readback { + Ok(text) if text == SEED_CLIPBOARD_TEXT => { + emit_diag("READCLIPBOARD", "GetClipboardData returned the seeded text") + } + Ok(text) => emit_diag( + "READCLIPBOARD", + &format!( + "GetClipboardData returned {} chars that are not the seed", + text.chars().count() + ), + ), + Err(reason) => emit_diag( + "READCLIPBOARD", + &format!("GetClipboardData returned a handle but {reason}"), + ), + } emit_fail("READCLIPBOARD"); - } else if error == ERROR_ACCESS_DENIED { + return; + } + if error == ERROR_ACCESS_DENIED { emit_pass("READCLIPBOARD"); + return; + } + + let seed_state = match &seed { + Ok(_) => "seeded in-process".to_string(), + Err(reason) => format!("in-process seed failed: {reason}"), + }; + let counted = match format_count { + Some(n) => n.to_string(), + None => "?".to_string(), + }; + if advertised == Some(true) { + emit_diag( + "READCLIPBOARD", + &format!( + "CF_UNICODETEXT is advertised but the data handle is not readable (gle={error}); \ + formats={counted}; {seed_state}" + ), + ); } else { emit_diag( "READCLIPBOARD", &format!( - "GetClipboardData returned NULL with gle={error}; seed Unicode text before probing" + "no CF_UNICODETEXT on the clipboard this process sees (gle={error}); \ + formats={counted}; {seed_state}" ), ); - emit_inconclusive("READCLIPBOARD"); } + // JOB_OBJECT_UILIMIT_READCLIPBOARD denies with ERROR_ACCESS_DENIED, which + // the clipboard=none cases exercise as a standing control. Any other error + // means the limit did not refuse this call, even where nothing readable was + // present to complete the round trip. + emit_fail("READCLIPBOARD"); } fn probe_writeclipboard(user32: Hmodule) { @@ -477,8 +717,14 @@ fn probe_writeclipboard(user32: Hmodule) { return; } }; - let close = get_proc(user32, "CloseClipboard") - .map(|p| unsafe { std::mem::transmute::(p) }); + let close = match get_proc(user32, "CloseClipboard") { + Some(p) => unsafe { std::mem::transmute::(p) }, + None => { + emit_diag("WRITECLIPBOARD", "CloseClipboard not resolvable"); + emit_fail("WRITECLIPBOARD"); + return; + } + }; let create_window = match get_proc(user32, "CreateWindowExW") { Some(p) => unsafe { std::mem::transmute::(p) }, None => { @@ -545,12 +791,8 @@ fn probe_writeclipboard(user32: Hmodule) { let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_count) }; if memory.is_null() { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = destroy_window(owner); } emit_diag("WRITECLIPBOARD", &format!("GlobalAlloc failed gle={error}")); @@ -560,12 +802,8 @@ fn probe_writeclipboard(user32: Hmodule) { let buffer = unsafe { GlobalLock(memory) }; if buffer.is_null() { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = GlobalFree(memory); let _ = destroy_window(owner); } @@ -581,12 +819,8 @@ fn probe_writeclipboard(user32: Hmodule) { let emptied = unsafe { empty() }; if emptied == 0 { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = GlobalFree(memory); let _ = destroy_window(owner); } @@ -607,12 +841,8 @@ fn probe_writeclipboard(user32: Hmodule) { } let set_ok = unsafe { set_data(CF_UNICODETEXT, memory) }; let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = destroy_window(owner); } if !set_ok.is_null() { diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 5967c70f5d..497eacca2f 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -357,6 +357,19 @@ function Get-HostCapabilities { $p.probes.uiCapabilities.PSObject.Properties['canBlockInputInjection']) { $canInject = [bool]$p.probes.uiCapabilities.canBlockInputInjection } + # Guarded capture resolves plm.exe next to the loaded module, so the release + # sidecar's trust result does not answer for the binary these tests run. + $pd = Invoke-Probe -Wxc $WxcDebug -Phase 'P0' -Name 'host-capabilities (capture providers)' + $nativeCapture = $false + $guardedCapture = $false + if ($pd -and $pd.PSObject.Properties['probes']) { + if ($pd.probes.PSObject.Properties['nativeCaptureAvailable']) { + $nativeCapture = [bool]$pd.probes.nativeCaptureAvailable + } + if ($pd.probes.PSObject.Properties['guardedCaptureAvailable']) { + $guardedCapture = [bool]$pd.probes.guardedCaptureAvailable + } + } return [pscustomobject]@{ BaselineTier = $tier BaseContainerUsable = ($tier -eq 'base-container') @@ -380,6 +393,12 @@ function Get-HostCapabilities { # EnumeratePathsUnsupported). So it is available only where the host # both selects base-container and advertises the capability. SupportsEnumeratePaths = (($tier -eq 'base-container') -and $enumBit) + # Native capture is reachable only on base-container; every AppContainer + # tier needs the guarded WPR fallback. With neither, a captureDenials + # request fails before the sandbox exists (docs/schema.md). + NativeCaptureAvailable = $nativeCapture + GuardedCaptureAvailable = $guardedCapture + CaptureDenialsUsable = ((($tier -eq 'base-container') -and $nativeCapture) -or $guardedCapture) } } @@ -1228,10 +1247,11 @@ function Initialize-WpcContext { } if ($Fresh) { - Write-Host ("Host capabilities: expectedTier={0} baseContainerUsable={1} apiPresent={2} bfscfgPresent={3} bfsCompiledIn={4} supportsDeniedPaths={5} supportsEnumeratePaths={6}" -f ` + Write-Host ("Host capabilities: expectedTier={0} baseContainerUsable={1} apiPresent={2} bfscfgPresent={3} bfsCompiledIn={4} supportsDeniedPaths={5} supportsEnumeratePaths={6} captureDenialsUsable={7} (native={8} guarded={9})" -f ` $Script:Caps.BaselineTier, $Script:Caps.BaseContainerUsable, $Script:Caps.BaseContainerApiPresent, ` $Script:Caps.BfscfgPresent, $Script:Caps.BfsCompiledIn, $Script:Caps.SupportsDeniedPaths, ` - $Script:Caps.SupportsEnumeratePaths) -ForegroundColor Cyan + $Script:Caps.SupportsEnumeratePaths, $Script:Caps.CaptureDenialsUsable, ` + $Script:Caps.NativeCaptureAvailable, $Script:Caps.GuardedCaptureAvailable) -ForegroundColor Cyan } } diff --git a/tests/scripts/lib/WinProcessContainer.Native.ps1 b/tests/scripts/lib/WinProcessContainer.Native.ps1 index 6829abdffb..6f1ffd2e11 100644 --- a/tests/scripts/lib/WinProcessContainer.Native.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Native.ps1 @@ -105,6 +105,9 @@ namespace Mxc { } function Set-UiProbeClipboardSeed { + # The probe seeds the clipboard it can actually see. This host-side seed + # additionally covers the one case the probe cannot seed for itself: a + # shared clipboard with JOB_OBJECT_UILIMIT_WRITECLIPBOARD set. $token = "MXC_UI_PROBE_READ_$([guid]::NewGuid().ToString('N'))" Set-Clipboard -Value $token } diff --git a/tests/scripts/run_processcontainer_capture_denials_test.ps1 b/tests/scripts/run_processcontainer_capture_denials_test.ps1 index dfea025dde..60195d9b7e 100644 --- a/tests/scripts/run_processcontainer_capture_denials_test.ps1 +++ b/tests/scripts/run_processcontainer_capture_denials_test.ps1 @@ -153,6 +153,11 @@ function Phase-CaptureDenialsModes { # warning". The warning is the user-visible half of that contract, and a # silent relaxation of deny-by-default is exactly the regression worth # catching. Asserted only when the run got far enough to emit it. + if (-not $Script:Caps.CaptureDenialsUsable) { + Record-Result -Phase 'P14b' -Name 'captureDenials mode=allow emits a security warning' -Status 'skip' ` + -Detail 'no capture provider on this host, so the request fails before a capture session exists to warn about' + return + } $cfg = New-Config -Name 'cd-allow-warning' -CommandLine $cmd -ReadWrite @($rw) ` -CaptureDenialsMode 'allow' $log = Join-Path $ScratchRoot 'logs\cd-allow-warning.log' @@ -177,6 +182,17 @@ function Phase-CaptureDenialsModes { function Phase-CaptureDenialsStampedPath { Section 'Phase 14c: captureDenials per-run output path' + if (-not $Script:Caps.CaptureDenialsUsable) { + foreach ($name in @( + 'the actual denials output path is reported on stderr', + 'the reported path is not the verbatim requested path (a per-run id is stamped in)', + 'two runs of the same config report different output paths')) { + Record-Result -Phase 'P14c' -Name $name -Status 'skip' ` + -Detail 'no capture provider on this host, so the request fails before an output path is resolved' + } + return + } + $rw = Join-Path $ScratchRoot 'rw' $requested = Join-Path $ScratchRoot 'rw\stamped.json' $cmd = 'cmd /c echo MXC-CD-RAN' diff --git a/tests/scripts/run_processcontainer_privilege_test.ps1 b/tests/scripts/run_processcontainer_privilege_test.ps1 index 920bcea410..c397f834ed 100644 --- a/tests/scripts/run_processcontainer_privilege_test.ps1 +++ b/tests/scripts/run_processcontainer_privilege_test.ps1 @@ -69,24 +69,37 @@ function Phase-LeastPrivilege { # Documented in docs/schema.md: "Native PSEC/V2 capture cannot combine # with leastPrivilege or network.proxy. Hosts without that complete native - # set retain an eligible legacy containment tier and use guarded WPR." + # set retain an eligible legacy containment tier and use guarded WPR. If + # guarded-WPR prerequisites are unavailable, the request fails before MXC + # creates the sandbox." # - # So the combination is a tier constraint, not a rejection: the run should - # still be accepted and fall back. Asserting a rejection here would encode - # the opposite of the documented behavior. + # So the combination is never a validation error; which outcome applies + # depends on the host, and both arms are asserted positively so neither + # passes on a run that simply fell over. $cfg = New-Config -Name 'priv-lpac-capture' -CommandLine $Script:PrivCmd -ReadWrite @($rw) ` -LeastPrivilege $true -CaptureDenialsMode 'block' $log = Join-Path $ScratchRoot 'logs\priv-lpac-capture.log' $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 60 $logText = Read-Log $log - $rejected = Test-WasRejected -Run $r -Log $logText # Test-WasRejected is false for backend_error, runner_unavailable and any # unexplained launch failure, so negating it alone proves no fallback ran. + $rejected = Test-WasRejected -Run $r -Log $logText $ran = [bool]("$($r.Stdout)" -match $Script:PrivMarker) - Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials falls back rather than being rejected' ` - -Pass ((-not $rejected) -and $ran) ` - -Detail ("exit=$($r.ExitCode); rejectedAtValidation=$rejected; workloadRan=$ran; " + - 'documented as a tier constraint (guarded WPR fallback), not a validation error') + if ($Script:Caps.GuardedCaptureAvailable) { + Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials falls back to guarded WPR rather than being rejected' ` + -Pass ((-not $rejected) -and $ran) ` + -Detail ("exit=$($r.ExitCode); rejectedAtValidation=$rejected; workloadRan=$ran; " + + 'guarded WPR is available on this host, so the documented tier constraint applies') + } else { + # Matching the dispatcher's own wording distinguishes the documented + # fail-closed from any other launch failure, which looks identical. + $all = "$logText`n$($r.Stderr)" + $m = [regex]::Match($all, '(?is)captureDenials.{0,160}?(is unavailable|does not support denial capture)') + Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials fails before sandbox creation when guarded WPR is unavailable' ` + -Pass ($m.Success -and -not $ran) ` + -Detail ("exit=$($r.ExitCode); workloadRan=$ran; " + + "reason=$(if ($m.Success) { $m.Value -replace '\s+', ' ' } else { '' })") + } } diff --git a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 index b9385aab23..0d0323eb6b 100644 --- a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 @@ -74,7 +74,7 @@ function Phase-UiMitigationMatrix { $matrixA = @{} foreach ($line in ($rA.Stdout -split "`r?`n")) { - if ($line -match '^(?READCLIPBOARD|WRITECLIPBOARD|SYSTEMPARAMETERS|DISPLAYSETTINGS|DESKTOP|EXITWINDOWS|HANDLES|INJECTION|WIN32K)=(?PASS|FAIL)\s*$') { + if ($line -match '^(?READCLIPBOARD|WRITECLIPBOARD|SYSTEMPARAMETERS|DISPLAYSETTINGS|DESKTOP|EXITWINDOWS|HANDLES|INJECTION|WIN32K)=(?PASS|FAIL|INCONCLUSIVE)\s*$') { $matrixA[$matches['k']] = $matches['v'] } } @@ -89,6 +89,17 @@ function Phase-UiMitigationMatrix { $got = if ($matrixA.ContainsKey($tag)) { $matrixA[$tag] } else { '' } $gotV = Format-Verdict $got 'blocked' 'allowed' $fullV = Format-VerdictSummary $summaryA 'blocked' 'allowed' + # INCONCLUSIVE means the probe never reached the operation the limit + # governs -- the surrounding security environment refused a + # precondition -- so the verdict says nothing about the UILIMIT bit. + # A missing tag still fails: the probe was asked for it and produced + # nothing at all. + if ($got -eq 'INCONCLUSIVE') { + $diag = if ($rA.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + Record-Result -Phase 'P4b' -Name "scenarioA: $tag" -Status 'skip' ` + -Detail "expected=blocked; the probe could not exercise $tag on this host; diag=$diag; full=$fullV" + continue + } Record-Result -Phase 'P4b' -Name "scenarioA: $tag" -Pass ($got -eq 'PASS') -Detail "expected=blocked; got=$gotV; full=$fullV" } diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index f49049c8eb..c8318a446c 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -12,7 +12,13 @@ # the thing you keep": clipboard=read allows reading and blocks writing. # # Allow-direction coverage matters: a flag wired to the wrong bit, or set -# unconditionally, still passes every blocked-direction assertion in 4b. +# unconditionally, still passes every blocked-direction assertion in 4b. It is +# only decidable where the host can actually perform the operation: a permissive +# knob omits the matching UILIMIT bit, it does not grant the capability, and the +# rest of the process-container security environment may still deny it +# (docs/process-container/UIPolicy_Schema.md). Measure-UiGrantable establishes +# which capabilities are reachable at all, and the cases below skip the allow +# direction for the rest. # # SAFETY: EXITWINDOWS is never probed where it is expected to be allowed — # that would be a real logoff of the operator's session. Those cases record an @@ -38,17 +44,17 @@ Set-StrictMode -Version Latest Initialize-WpcContext @PSBoundParameters +# Probe tag -> whether a fully permissive UI policy could exercise it on this +# host. Tags Measure-UiGrantable does not cover are absent and stay strict. +$Script:UiGrantable = @{} -# Assert the documented floor: with every UI knob permissive, a contained -# process gets every UI capability. -# -# UIPolicy_Schema.md:95 maps clipboard="all" to no UILIMIT flags and "Allowed" -# for both directions; os-version-support.md:185 marks clipboard, systemSettings -# and desktopSystemControl supported on every build. No doc says containment -# removes them. So anything still denied here is a real defect, not a quirk of -# the test host, and it is reported as one -- it also explains at a glance why -# the individual allow cases below went red. -function Measure-UiReachable { + +# Measure which UI capabilities a contained process can exercise when every UI +# knob is permissive. This is a host measurement, not an assertion: a permissive +# value only omits the matching UILIMIT bit, so a capability the host denies +# anyway is undecidable in the allow direction. Reachable capabilities keep both +# directions strict. +function Measure-UiGrantable { $tags = @('READCLIPBOARD', 'WRITECLIPBOARD', 'SYSTEMPARAMETERS', 'DISPLAYSETTINGS', 'DESKTOP') $cfg = New-Config -Name 'ui-policy-baseline' -CommandLine "`"$UiProbeDebug`" $($tags -join ' ')" ` -ReadWrite @((Join-Path $ScratchRoot 'rw')) -Env (Get-ProbeEnvWithDestructive) ` @@ -58,21 +64,32 @@ function Measure-UiReachable { Set-UiProbeClipboardSeed $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log - $verdicts = @([regex]::Matches($r.Stdout, '(?m)^[A-Z][A-Z0-9_]*=(?:PASS|FAIL|INCONCLUSIVE)\s*$')) - if ($r.ExitCode -ne 0 -or $verdicts.Count -eq 0) { - Record-Result -Phase 'P4e' -Name 'baseline: a fully permissive UI policy grants every UI capability' -Pass $false ` - -Detail "the baseline case did not run (exit=$($r.ExitCode); verdicts=$($verdicts.Count))" + $verdicts = @{} + foreach ($line in ($r.Stdout -split "`r?`n")) { + if ($line -match '^(?[A-Z][A-Z0-9_]*)=(?PASS|FAIL|INCONCLUSIVE)\s*$') { + $verdicts[$matches['k']] = $matches['v'] + } + } + $absent = @($tags | Where-Object { -not $verdicts.ContainsKey($_) }) + if ($r.ExitCode -ne 0 -or $absent.Count) { + # Leaving the map empty keeps every case below strict rather than + # silently dropping allow-direction coverage. + Record-Result -Phase 'P4e' -Name 'baseline: the fully permissive UI policy case reports every tag' -Pass $false ` + -Detail "exit=$($r.ExitCode); missing=$(if ($absent.Count) { $absent -join ', ' } else { '' })" return } # FAIL from the probe means the operation succeeded, i.e. the capability is - # granted -- which is what a permissive policy must produce. - $denied = @($tags | Where-Object { $r.Stdout -notmatch "(?m)^$_=FAIL\s*$" }) + # reachable with no UILIMIT bit in the way. + foreach ($tag in $tags) { + $Script:UiGrantable[$tag] = ($verdicts[$tag] -eq 'FAIL') + } - $shown = if ($denied.Count) { $denied -join ', ' } else { '' } - Record-Result -Phase 'P4e' -Name 'baseline: a fully permissive UI policy grants every UI capability' ` - -Pass ($denied.Count -eq 0) ` - -Detail "still denied: $shown (UIPolicy_Schema.md:95 and os-version-support.md:185 document these as allowed)" + $reachable = @($tags | Where-Object { $Script:UiGrantable[$_] }) + $denied = @($tags | Where-Object { -not $Script:UiGrantable[$_] }) + Record-Result -Phase 'P4e' -Name 'baseline: UI capabilities reachable under a fully permissive policy' -Status 'skip' ` + -Detail ("reachable: $(if ($reachable.Count) { $reachable -join ', ' } else { '' }); " + + "denied by this host regardless of policy: $(if ($denied.Count) { $denied -join ', ' } else { '' })") } @@ -130,6 +147,9 @@ function Invoke-UiPolicyCase { $got = if ($matrix.ContainsKey($tag)) { $matrix[$tag] } else { '' } $gotV = Format-Verdict $got 'blocked' 'allowed' $name = "$($Case.Label) -> $tag=$want" + # Measured tags only; anything Measure-UiGrantable does not cover (such + # as HANDLES, which has its own negative control) stays strict. + $hostDenies = ($Script:UiGrantable.ContainsKey($tag) -and -not $Script:UiGrantable[$tag]) # INJECTION is the one tag whose verdict is not a clean two-state. # Mirror Phase 4b: an unsupported build or a lost foreground race is a @@ -152,7 +172,7 @@ function Invoke-UiPolicyCase { } if ($got -eq 'INCONCLUSIVE') { - $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diag = if ($r.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } # ui.disable=true engages the Win32k mitigation, which stops user32 # from loading. Losing the GUI subsystem outright is strictly # stronger than the individual limit, so it satisfies "blocked". @@ -161,6 +181,11 @@ function Invoke-UiPolicyCase { -Detail "expected=blocked; GUI subsystem unavailable (Win32k mitigation); diag=$diag" continue } + if ($hostDenies) { + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "expected=$want; this host denies $tag even under a fully permissive UI policy, so the probe cannot exercise the UILIMIT mapping; diag=$diag" + continue + } # Nothing documents the probe being unable to run here, so this is a # finding rather than a skip. Record-Result -Phase $Phase -Name $name -Pass $false ` @@ -168,14 +193,40 @@ function Invoke-UiPolicyCase { continue } - # A contained process still gets the documented capability when the knob - # is permissive (UIPolicy_Schema.md:95, os-version-support.md:185). - # Measure-UiReachable reports the contradiction once; the per-knob cases - # below stay red so a real regression is never hidden behind a skip. + # A permissive knob omits the UILIMIT bit; it does not grant the + # operation. Where the host denies the capability regardless of policy, + # the allow direction carries no information about the mapping. The + # block direction stays strict: a succeeded operation is a real leak. + if ($hostDenies -and $want -eq 'allowed') { + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "this host denies $tag even under a fully permissive UI policy; got=$gotV; full=$summaryV" + continue + } $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } + $diag = if ($r.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diagPart = if ($diag) { "; diag=$diag" } else { '' } + + # Where the policy also permits writing, the probe can seed the + # clipboard it reads, so "the limit did not refuse" is too weak: it + # still holds when the data comes back unreadable. Demand the round + # trip, which is the only assertion that covers the read path itself. + if ($got -eq $expectToken -and $tag -eq 'READCLIPBOARD' -and $Case['RequireClipboardRoundTrip']) { + if ($diag -match 'in-process seed failed') { + # The WRITECLIPBOARD assertion owns a refused seed; with nothing + # on the clipboard there is no round trip to require. + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "expected=$want; the probe could not seed the clipboard, so the read path was not exercised$diagPart; full=$summaryV" + continue + } + if ($diag -notmatch 'returned the seeded text') { + Record-Result -Phase $Phase -Name $name -Pass $false ` + -Detail "expected=$want with a readable round trip; the limit did not refuse the call but the seeded text did not come back$diagPart; full=$summaryV" + continue + } + } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` - -Detail "expected=$want; got=$gotV; full=$summaryV" + -Detail "expected=$want; got=$gotV$diagPart; full=$summaryV" } } @@ -187,7 +238,7 @@ function Invoke-UiPolicyCase { function Phase-UiPolicyMatrix { Section 'Phase 4e: UI policy resolution matrix (every documented value)' - Measure-UiReachable + Measure-UiGrantable # The HANDLES probe needs a USER handle owned by a process OUTSIDE the # job. Created once and shared by every case; harmless for the rest. @@ -201,6 +252,7 @@ function Phase-UiPolicyMatrix { # "the named thing is the thing you keep": read => reading survives. $cases = @( @{ Name = 'clip-all'; Label = 'ui.clipboard=all'; Clipboard = 'all' + RequireClipboardRoundTrip = $true Expect = @{ READCLIPBOARD = 'allowed'; WRITECLIPBOARD = 'allowed' } } @{ Name = 'clip-read'; Label = 'ui.clipboard=read'; Clipboard = 'read' Expect = @{ READCLIPBOARD = 'allowed'; WRITECLIPBOARD = 'blocked' } } @@ -244,8 +296,10 @@ function Phase-UiPolicyMatrix { Expect = @{ SYSTEMPARAMETERS = 'blocked'; DISPLAYSETTINGS = 'blocked' } } # --- processContainer.ui.desktopSystemControl ----------------- - # true unblocks BOTH desktop switching and logoff. Only DESKTOP is - # probed: CreateDesktopW is reversible, ExitWindowsEx is not. + # true omits the DESKTOP and EXITWINDOWS limits for both desktop + # switching and logoff; it does not enable either operation. Only + # DESKTOP is probed: CreateDesktopW is reversible, ExitWindowsEx + # is not. @{ Name = 'deskctl-on'; Label = 'pcUi.desktopSystemControl=true'; BpUiDesktopControl = $true Expect = @{ DESKTOP = 'allowed' } } @{ Name = 'deskctl-off'; Label = 'pcUi.desktopSystemControl=false'; BpUiDesktopControl = $false