feat(sandbox): 按句柄授予包访问权限并验证容器隔离

This commit is contained in:
2026-09-08 22:40:07 +08:00
parent dca0c7bff4
commit 627e4552d5
2 changed files with 236 additions and 2 deletions
+226 -2
View File
@@ -54,6 +54,100 @@ impl Profile {
pub fn sid(&self) -> PSID {
self.sid
}
/// Grant this instance read/execute access to one Host-owned package object.
/// The caller must open it without following reparse points and retain the
/// verified package handles for the entire launch. No recursive inheritance
/// is used: every directory and file must be checked and granted separately.
/// This adds an ACE; it does not sanitize pre-existing permissions.
pub fn grant_package_read_execute(&self, object: &std::fs::File) -> Result<()> {
use std::os::windows::{fs::MetadataExt, io::AsRawHandle};
use windows_sys::Win32::{
Foundation::LocalFree,
Security::{Authorization::*, DACL_SECURITY_INFORMATION},
Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
FILE_ATTRIBUTE_REPARSE_POINT, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ,
},
};
struct LocalAllocation(*mut core::ffi::c_void);
impl Drop for LocalAllocation {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
LocalFree(self.0);
}
}
}
}
let metadata = object
.metadata()
.map_err(|_| HostError::new("EXTENSION_CONTAINER_ACL_FAILED"))?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| !(metadata.is_file() || metadata.is_dir())
{
return Err(HostError::new("EXTENSION_CONTAINER_ACL_OBJECT_INVALID"));
}
if metadata.is_file() {
let mut info = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(object.as_raw_handle(), &mut info) } == 0
|| info.nNumberOfLinks != 1
{
return Err(HostError::new("EXTENSION_CONTAINER_ACL_OBJECT_INVALID"));
}
}
let mut old_acl = std::ptr::null_mut();
let mut descriptor = std::ptr::null_mut();
let status = unsafe {
GetSecurityInfo(
object.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut old_acl,
std::ptr::null_mut(),
&mut descriptor,
)
};
let _descriptor = LocalAllocation(descriptor);
// A null DACL grants everyone full access, so fail closed rather than
// silently treating it as a suitably isolated package object.
if status != 0 || old_acl.is_null() {
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
}
let entry = EXPLICIT_ACCESS_W {
grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE,
grfAccessMode: GRANT_ACCESS,
grfInheritance: 0,
Trustee: TRUSTEE_W {
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_UNKNOWN,
ptstrName: self.sid.cast(),
..Default::default()
},
};
let mut acl = std::ptr::null_mut();
let status = unsafe { SetEntriesInAclW(1, &entry, old_acl, &mut acl) };
let _acl = LocalAllocation(acl.cast());
if status != 0 || acl.is_null() {
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
}
let status = unsafe {
SetSecurityInfo(
object.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
acl,
std::ptr::null(),
)
};
if status != 0 {
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
}
Ok(())
}
pub fn folder(&self) -> Result<std::path::PathBuf> {
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::{
@@ -73,6 +167,11 @@ impl Profile {
LocalFree(string.cast());
}
if status < 0 || folder.is_null() {
if !folder.is_null() {
unsafe {
CoTaskMemFree(folder.cast());
}
}
return Err(HostError::new("EXTENSION_CONTAINER_FOLDER_FAILED"));
}
let mut length = 0;
@@ -198,6 +297,13 @@ mod tests {
#[test]
fn real_suspended_process_has_appcontainer_token_before_job_resume() {
let profile = Profile::create().unwrap();
checked_process(&profile, None);
profile.remove().unwrap();
}
// Only tests use cmd.exe, with fixed commands and controlled temporary paths.
// A production extension launcher must use a verified entry, never a shell.
fn checked_process(profile: &Profile, command: Option<&str>) -> Option<u32> {
let mut attributes = Attributes::new();
let caps = SECURITY_CAPABILITIES {
AppContainerSid: profile.sid(),
@@ -239,12 +345,24 @@ mod tests {
)
.encode_utf16()
.collect();
let mut command_line: Vec<u16> = command
.map(|command| {
format!("cmd.exe /d /c {command}")
.encode_utf16()
.chain(Some(0))
.collect()
})
.unwrap_or_default();
let mut info = PROCESS_INFORMATION::default();
assert_ne!(
unsafe {
CreateProcessW(
executable.as_ptr(),
std::ptr::null_mut(),
if command_line.is_empty() {
std::ptr::null_mut()
} else {
command_line.as_mut_ptr()
},
std::ptr::null(),
std::ptr::null(),
0,
@@ -349,12 +467,118 @@ mod tests {
);
assert_eq!(unsafe { *capabilities.as_ptr().cast::<u32>() }, 0);
// No command interpreter instruction was resumed by this identity test.
let exit = command.map(|_| {
assert_ne!(
unsafe { ResumeThread(process._thread.as_raw_handle()) },
u32::MAX
);
assert_eq!(
unsafe { WaitForSingleObject(process.process.as_raw_handle(), 10_000) },
0
);
let mut exit = 0;
assert_ne!(
unsafe { GetExitCodeProcess(process.process.as_raw_handle(), &mut exit) },
0
);
exit
});
job.terminate().unwrap();
drop(token);
drop(process);
drop(job);
drop(attributes);
exit
}
#[test]
fn real_container_can_read_only_explicitly_granted_package_objects() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::*;
let profile = Profile::create().unwrap();
let directory = tempfile::tempdir().unwrap();
let payload = directory.path().join("payload.txt");
let hidden = directory.path().join("ungranted.txt");
std::fs::write(&payload, b"verified package content").unwrap();
std::fs::write(&hidden, b"private sibling").unwrap();
let open = |path: &std::path::Path| {
std::fs::OpenOptions::new()
.access_mode(READ_CONTROL | WRITE_DAC)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.unwrap()
};
let root_handle = open(directory.path());
let file_handle = open(&payload);
let read = format!("set /p value=<\"{}\"", payload.display());
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
profile.grant_package_read_execute(&root_handle).unwrap();
// The directory ACE does not propagate to existing children.
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
profile.grant_package_read_execute(&file_handle).unwrap();
assert_eq!(checked_process(&profile, Some(&read)), Some(0));
let other = Profile::create().unwrap();
assert_ne!(checked_process(&other, Some(&read)), Some(0));
other.remove().unwrap();
let created = directory.path().join("new.txt");
assert_ne!(
checked_process(
&profile,
Some(&format!("echo changed>\"{}\"", created.display()))
),
Some(0)
);
assert!(!created.exists());
assert_ne!(
checked_process(
&profile,
Some(&format!("set /p value=<\"{}\"", hidden.display()))
),
Some(0)
);
assert_ne!(
checked_process(
&profile,
Some(&format!("echo changed>\"{}\"", payload.display()))
),
Some(0)
);
assert_eq!(
std::fs::read(&payload).unwrap(),
b"verified package content"
);
drop(file_handle);
drop(root_handle);
profile.remove().unwrap();
}
#[test]
fn package_grant_rejects_hardlinks_and_handles_without_acl_write_access() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::*;
let profile = Profile::create().unwrap();
let directory = tempfile::tempdir().unwrap();
let payload = directory.path().join("payload.txt");
let alias = directory.path().join("alias.txt");
std::fs::write(&payload, b"unchanged").unwrap();
let read_only = std::fs::File::open(&payload).unwrap();
assert!(profile.grant_package_read_execute(&read_only).is_err());
drop(read_only);
std::fs::hard_link(&payload, &alias).unwrap();
let handle = std::fs::OpenOptions::new()
.access_mode(READ_CONTROL | WRITE_DAC)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&payload)
.unwrap();
assert_eq!(
profile
.grant_package_read_execute(&handle)
.unwrap_err()
.code,
"EXTENSION_CONTAINER_ACL_OBJECT_INVALID"
);
assert_eq!(std::fs::read(&alias).unwrap(), b"unchanged");
drop(handle);
profile.remove().unwrap();
}
}