Skip to content

Commit d78430c

Browse files
authored
test(mxc): verify ProcessContainer token isolation (#3430)
Adds real-MXC regression coverage for ProcessContainer token isolation, including an unsandboxed SCM positive control, and documents the AppContainer authorization model.
1 parent 4f06e23 commit d78430c

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

‎crates/openshell-driver-mxc/tests/wxc_exec_real.rs‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,97 @@ use std::path::{Path, PathBuf};
4040
use std::process::Command;
4141
use std::time::Duration;
4242

43+
mod token_probe {
44+
#![allow(unsafe_code)]
45+
46+
use std::ffi::c_void;
47+
use std::ptr;
48+
49+
#[link(name = "kernel32")]
50+
unsafe extern "system" {
51+
fn GetCurrentProcess() -> isize;
52+
fn CloseHandle(handle: isize) -> i32;
53+
fn GetLastError() -> u32;
54+
}
55+
56+
#[link(name = "advapi32")]
57+
unsafe extern "system" {
58+
fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32;
59+
fn GetTokenInformation(
60+
token: isize,
61+
class: i32,
62+
info: *mut c_void,
63+
len: u32,
64+
return_len: *mut u32,
65+
) -> i32;
66+
fn OpenSCManagerW(machine: *const u16, database: *const u16, access: u32) -> isize;
67+
fn CloseServiceHandle(handle: isize) -> i32;
68+
}
69+
70+
fn token_u32(token: isize, class: i32) -> Option<u32> {
71+
let mut value = 0u32;
72+
let mut returned = 0u32;
73+
let ok = unsafe {
74+
GetTokenInformation(
75+
token,
76+
class,
77+
(&raw mut value).cast(),
78+
u32::try_from(size_of::<u32>()).expect("u32 size fits Win32 length"),
79+
&raw mut returned,
80+
)
81+
};
82+
(ok != 0).then_some(value)
83+
}
84+
85+
fn has_appcontainer_sid(token: isize) -> bool {
86+
let mut buf = [0u8; 256];
87+
let mut returned = 0u32;
88+
let ok = unsafe {
89+
GetTokenInformation(
90+
token,
91+
31, // TokenAppContainerSid
92+
buf.as_mut_ptr().cast(),
93+
u32::try_from(buf.len()).expect("token buffer length fits u32"),
94+
&raw mut returned,
95+
)
96+
};
97+
ok != 0 && !unsafe { ptr::read_unaligned(buf.as_ptr().cast::<*const c_void>()) }.is_null()
98+
}
99+
100+
pub fn service_manager_create_access() -> (bool, u32) {
101+
let manager = unsafe {
102+
OpenSCManagerW(
103+
ptr::null(),
104+
ptr::null(),
105+
0x0002, // SC_MANAGER_CREATE_SERVICE
106+
)
107+
};
108+
if manager == 0 {
109+
return (false, unsafe { GetLastError() });
110+
}
111+
unsafe { CloseServiceHandle(manager) };
112+
(true, 0)
113+
}
114+
115+
pub fn snapshot() -> serde_json::Value {
116+
let mut token = 0isize;
117+
let opened = unsafe { OpenProcessToken(GetCurrentProcess(), 0x0008, &raw mut token) };
118+
assert_ne!(opened, 0, "OpenProcessToken failed");
119+
120+
let (can_create_service, create_service_error) = service_manager_create_access();
121+
let snapshot = serde_json::json!({
122+
"is_appcontainer": token_u32(token, 29), // TokenIsAppContainer
123+
"has_appcontainer_sid": has_appcontainer_sid(token),
124+
"can_create_service": can_create_service,
125+
"create_service_error": create_service_error,
126+
});
127+
unsafe { CloseHandle(token) };
128+
snapshot
129+
}
130+
}
131+
132+
const TOKEN_PROBE_MARKER: &str = "OPENSHELL_MXC_TOKEN_PROBE=";
133+
43134
// ── Path resolution ──────────────────────────────────────────────────────────
44135

45136
/// Resolve the path to `wxc-exec.exe`.
@@ -477,6 +568,17 @@ fn dryrun_accepts_split_policy_output() {
477568
// These skip on this box (processcontainer velocity keys not enabled;
478569
// isolation_session backend absent). They PASS where backends are live.
479570

571+
/// Entrypoint used by `pc_oneshot_token_is_appcontainer_without_admin_access`.
572+
/// The parent test relaunches this integration-test binary inside MXC so the
573+
/// probe observes the workload token rather than the host test runner's token.
574+
#[test]
575+
fn child_token_probe_entry() {
576+
if std::env::var("OPENSHELL_MXC_CHILD_TOKEN_PROBE").as_deref() != Ok("1") {
577+
return;
578+
}
579+
println!("{TOKEN_PROBE_MARKER}{}", token_probe::snapshot());
580+
}
581+
480582
/// Probe the processcontainer backend.
481583
///
482584
/// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns
@@ -801,6 +903,101 @@ fn pc_oneshot_in_policy_write_succeeds() {
801903
);
802904
}
803905

906+
/// Verify the default `ProcessContainer` token and an administrator-gated
907+
/// access attempt. `whoami /all` is not sufficient for this assertion: the
908+
/// package SID is exposed through `TokenAppContainerSid`, and `AppContainer`
909+
/// access is the intersection of the user/group and package/capability grants.
910+
#[test]
911+
#[ignore = "requires real wxc-exec"]
912+
fn pc_oneshot_token_is_appcontainer_without_admin_access() {
913+
let Some(wxc) = wxc_path() else {
914+
eprintln!("SKIP: wxc-exec not found");
915+
return;
916+
};
917+
let (host_can_create_service, host_error) = token_probe::service_manager_create_access();
918+
if !host_can_create_service {
919+
eprintln!(
920+
"SKIP: host test runner lacks SC_MANAGER_CREATE_SERVICE (Win32 error {host_error}); sandboxed denial would not prove isolation"
921+
);
922+
return;
923+
}
924+
if let Err(reason) = probe_processcontainer(&wxc) {
925+
eprintln!("SKIP: processcontainer not live: {reason}");
926+
return;
927+
}
928+
929+
let (tempdir, temp_path) = temp_fixture();
930+
let test_exe = std::env::current_exe().expect("resolve integration-test executable");
931+
let test_exe_parent = test_exe
932+
.parent()
933+
.expect("integration-test executable has a parent")
934+
.to_string_lossy()
935+
.into_owned();
936+
let command_line = format!(
937+
"\"{}\" --exact child_token_probe_entry --nocapture",
938+
test_exe.display()
939+
);
940+
let mut child_env = vec!["OPENSHELL_MXC_CHILD_TOKEN_PROBE=1".to_string()];
941+
child_env.extend(
942+
["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]
943+
.into_iter()
944+
.filter_map(|key| {
945+
std::env::var(key)
946+
.ok()
947+
.map(|value| format!("{key}={value}"))
948+
}),
949+
);
950+
let config = serde_json::json!({
951+
"version": "0.8.0-alpha",
952+
"containerId": "pc-token-identity",
953+
"containment": "processcontainer",
954+
"process": {
955+
"commandLine": command_line,
956+
"cwd": temp_path,
957+
"env": child_env,
958+
"timeout": 30_000,
959+
},
960+
"filesystem": {
961+
"readwritePaths": [temp_path],
962+
"readonlyPaths": [test_exe_parent],
963+
},
964+
"processContainer": {
965+
"leastPrivilege": false,
966+
},
967+
"ui": {
968+
"disable": false,
969+
"clipboard": "none",
970+
"injection": false,
971+
},
972+
});
973+
let json = serde_json::to_string(&config).unwrap();
974+
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
975+
let out = Command::new(&wxc)
976+
.arg("--config-base64")
977+
.arg(&b64)
978+
.output()
979+
.expect("wxc-exec token probe spawn");
980+
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
981+
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
982+
assert!(
983+
out.status.success(),
984+
"token probe should exit successfully\nstdout={stdout}\nstderr={stderr}"
985+
);
986+
let snapshot: serde_json::Value = stdout
987+
.lines()
988+
.find_map(|line| line.trim().strip_prefix(TOKEN_PROBE_MARKER))
989+
.map_or_else(
990+
|| panic!("token probe marker missing\nstdout={stdout}\nstderr={stderr}"),
991+
|value| serde_json::from_str(value).expect("parse token probe JSON"),
992+
);
993+
994+
assert_eq!(snapshot["is_appcontainer"], 1);
995+
assert_eq!(snapshot["has_appcontainer_sid"], true);
996+
assert_eq!(snapshot["can_create_service"], false);
997+
assert_eq!(snapshot["create_service_error"], 5); // ERROR_ACCESS_DENIED
998+
drop(tempdir);
999+
}
1000+
8041001
/// Run an HTTPS request through the real driver and `ProcessContainer`. The
8051002
/// workload explicitly reads the injected bundle before curl uses it, proving
8061003
/// that the driver's internal TLS share is reachable from the `AppContainer`.

‎docs/reference/gateway-config.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,17 @@ debug = false
949949
etw_audit = false
950950
```
951951

952+
The default `pc_least_privilege = false` still runs the workload with an
953+
AppContainer token. Set it to `true` to request the stricter Less Privileged
954+
AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching
955+
account's user SID and group SIDs. Seeing the same username or
956+
`BUILTIN\\Administrators` in `whoami /all` therefore does not mean the sandbox
957+
can exercise administrator access: Windows
958+
requires both the user/group and AppContainer package/capability sides of an
959+
access check to grant the requested operation. The MXC real-binary integration
960+
suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a
961+
non-mutating administrator-gated Service Control Manager open.
962+
952963
Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `<cwd>/.openshell-proxy/<sandbox-id>/`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback.
953964

954965
MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry.

0 commit comments

Comments
 (0)