feat(sandbox): 按句柄授予包访问权限并验证容器隔离
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user