fix(sandbox): grant sandbox accounts NUL access

This commit is contained in:
coso
2026-08-26 17:47:20 +08:00
parent 315c1cd799
commit 5eebd985f8
3 changed files with 216 additions and 0 deletions
@@ -389,6 +389,7 @@ Renderer typed gateway
- 2026-08-26:继续 C-3,删除已由真机证据判定无效的 `CreateProcessWithTokenW` fallback、suspended/resume 与后置 Job 分配,改为 `CreateProcessWithLogonW -> sandbox-account runner -> current token 派生 restricted token -> CreateProcessAsUserW + STARTUPINFOEX` 唯一 current 链;有界双 named-pipe framing 承接 spawn/stdin/close/resize/terminate/output/exit/error,pipe ACL 只允许目标 account SID且连接后校验 runner PID,pipe 与 ConPTY 均通过 job-list attribute 原子入 Job,宿主 reaper 持有控制 pipe 与 ACL lease 到 runner 退出。`windows-sandbox-runner.exe` 已进入 dev/Quality 成组构建、Electron Windows resource、release manifest `windowsSandboxRunnerSha256` 和 packaged verifier;Everyone-write case 的 `icacls` 输出改为进程内捕获,避免污染 evidence parser 的稳定 test 行。本地 `cargo fmt --check`、Prettier、`git diff --check`、packaging/sidecar/evidence 定向 Vitest `66/66`、Rust related 反向依赖链、`tool-runtime 373/373`、`app-server-daemon 38/38`、contracts `299 checks`、scripts/release/docs/legacy governance 全部通过,legacy 扫描仍为 2063 files、0/0/0。当前主机未安装 Windows target,因此这些结果不替代 MSVC 编译和 schema v3 七项真机矩阵;计划继续保持 `active`、`44/46 (95.7%)` 与 `Planned/enforced=false`,下一刀是推送该窄提交并监控 Windows Quality evidence。
- 2026-08-26:Windows Quality run `32950744293`(SHA `3ca895af0`)已取得 schema v3 artifact `9600137436`,但在 setup binary 的 MSVC 编译阶段失败,七项尚未执行。失败收敛为单一 Windows-only import:`windows_runner_child.rs` 从 `Win32::Foundation` 导入 `EqualSid`,而 `windows-sys 0.52` 的 owner 是 `Win32::Security`;同时报告本轮 `windows.rs` 的两个 unused import。已按实际模块归属修正并准备最小重跑;该 run 不改变 `44/46 (95.7%)` 或 `Planned/enforced=false`。
- 2026-08-26:Windows Quality run `32951556136`(SHA `dfc05e1e1`)已通过 setup 并完成 MSVC 编译,schema v3 artifact `9600453484` 首次执行完整七项矩阵:Everyone-write audit 通过,其余六项在 runner transport 建立阶段共同失败,错误均为 `SetNamedPipeHandleState(runner pipe): ERROR_ACCESS_DENIED`。根因是 Lime 为保留 15 秒有界连接而以 `PIPE_NOWAIT` 创建 pipe,连接后切换 `PIPE_WAIT`;output server pipe 使用 `PIPE_ACCESS_INBOUND` 时只有 `GENERIC_READ`,不满足 Windows 对 `SetNamedPipeHandleState` 的 `GENERIC_WRITE` 或 `GENERIC_READ + FILE_WRITE_ATTRIBUTES` 合同。已将 output server handle 收敛为 duplex access,runner 仍只请求 write access,account-only DACL、随机 pipe name 与 client PID 校验不变;该 run 为 `1/7` 失败证据,继续保持 `44/46 (95.7%)` 与 `Planned/enforced=false`,待下一轮真机矩阵验证。
- 2026-08-26:Windows Quality run `32952578959`(SHA `315c1cd79`)确认 runner transport 已越过 pipe state 切换,setup/MSVC 编译继续通过;schema v3 artifact 的七项矩阵仍为 `1/7`,六个 restricted child 均已 spawn 但在产生输出前以 code `1` 退出。对照 Codex current command runner 后确认 Lime 漏掉 restricted PowerShell/stdio 初始化所需的 NUL device ACL;Codex 对 capability SID 授权,而 Lime capability 是每回合随机 UUID,直接照搬会在全局设备 DACL 永久堆积短生命周期 ACE。当前修复改由 elevated setup 一次性向两个稳定 sandbox account SID 授予同等 NUL read/write/execute 权限,并在 readiness 对 effective rights 做 read-back;account SID 仍只作为 restricting identity,不进入 `TokenDefaultDacl`,workspace capability 边界不放宽。该 run 不改变 `44/46 (95.7%)` 或 `Planned/enforced=false`,待真机重跑验证此根因假设。
## 7. 阻塞与避让
@@ -13,6 +13,7 @@ mod accounts;
#[cfg(windows)]
use accounts::{
ensure_local_account, ensure_local_group, ensure_local_group_member,
ensure_sandbox_accounts_null_device_access, validate_sandbox_accounts_null_device_access,
validate_windows_sandbox_group_membership,
};
#[cfg(windows)]
@@ -143,6 +144,7 @@ pub fn run_windows_sandbox_setup(
WINDOWS_SANDBOX_OFFLINE_USERNAME,
)?;
ensure_local_group_member(WINDOWS_SANDBOX_USERS_GROUP, WINDOWS_SANDBOX_ONLINE_USERNAME)?;
ensure_sandbox_accounts_null_device_access()?;
let offline_blob = dpapi_protect(offline_password.as_bytes())?;
let online_blob = dpapi_protect(online_password.as_bytes())?;
@@ -490,6 +492,7 @@ fn validate_protected_password(label: &str, value: &str) -> Result<(), String> {
fn validate_windows_runtime_artifacts(artifacts: &ValidatedSetupArtifacts) -> Result<(), String> {
validate_windows_sandbox_group_membership(WINDOWS_SANDBOX_OFFLINE_USERNAME)?;
validate_windows_sandbox_group_membership(WINDOWS_SANDBOX_ONLINE_USERNAME)?;
validate_sandbox_accounts_null_device_access()?;
validate_windows_sandbox_user("offline", &artifacts.users.offline)?;
validate_windows_sandbox_user("online", &artifacts.users.online)?;
crate::windows_firewall::verify_offline_rules(WINDOWS_SANDBOX_OFFLINE_USERNAME)
@@ -4,6 +4,218 @@ use super::{
};
use std::io;
const READ_CONTROL: u32 = 0x0002_0000;
const WRITE_DAC: u32 = 0x0004_0000;
pub(super) fn ensure_sandbox_accounts_null_device_access() -> io::Result<()> {
use windows_sys::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HLOCAL};
use windows_sys::Win32::Security::Authorization::{
SetEntriesInAclW, SetSecurityInfo, EXPLICIT_ACCESS_W, SET_ACCESS, SE_KERNEL_OBJECT,
TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
};
let mut account_sids = [
resolve_windows_account_sid(WINDOWS_SANDBOX_OFFLINE_USERNAME).map_err(io::Error::other)?,
resolve_windows_account_sid(WINDOWS_SANDBOX_ONLINE_USERNAME).map_err(io::Error::other)?,
];
let entries = account_sids
.iter_mut()
.map(|sid| EXPLICIT_ACCESS_W {
grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE,
grfAccessMode: SET_ACCESS,
grfInheritance: 0,
Trustee: TRUSTEE_W {
pMultipleTrustee: std::ptr::null_mut(),
MultipleTrusteeOperation: 0,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_UNKNOWN,
ptstrName: sid.as_mut_ptr() as *mut u16,
},
})
.collect::<Vec<_>>();
with_null_device_dacl(READ_CONTROL | WRITE_DAC, |handle, dacl| {
if dacl.is_null() {
return Err(io::Error::other(
"Windows sandbox NUL device has no editable DACL",
));
}
let mut updated_dacl = std::ptr::null_mut();
let update = unsafe {
SetEntriesInAclW(
entries.len() as u32,
entries.as_ptr(),
dacl,
&mut updated_dacl,
)
};
if update != ERROR_SUCCESS {
return Err(win32_result_error(
"SetEntriesInAclW(Windows sandbox NUL)",
update,
));
}
if updated_dacl.is_null() {
return Err(io::Error::other(
"SetEntriesInAclW(Windows sandbox NUL) returned a null DACL",
));
}
let applied = unsafe {
SetSecurityInfo(
handle,
SE_KERNEL_OBJECT,
windows_sys::Win32::Security::DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
updated_dacl,
std::ptr::null_mut(),
)
};
unsafe {
LocalFree(updated_dacl as HLOCAL);
}
if applied == ERROR_SUCCESS {
Ok(())
} else {
Err(win32_result_error(
"SetSecurityInfo(Windows sandbox NUL)",
applied,
))
}
})
}
pub(super) fn validate_sandbox_accounts_null_device_access() -> Result<(), String> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::Security::Authorization::{
GetEffectiveRightsFromAclW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
};
let required = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE;
let account_sids = [
(
WINDOWS_SANDBOX_OFFLINE_USERNAME,
resolve_windows_account_sid(WINDOWS_SANDBOX_OFFLINE_USERNAME)?,
),
(
WINDOWS_SANDBOX_ONLINE_USERNAME,
resolve_windows_account_sid(WINDOWS_SANDBOX_ONLINE_USERNAME)?,
),
];
with_null_device_dacl(READ_CONTROL, |_handle, dacl| {
if dacl.is_null() {
return Err(io::Error::other(
"Windows sandbox NUL device has no readable DACL",
));
}
for (account, sid) in &account_sids {
let trustee = TRUSTEE_W {
pMultipleTrustee: std::ptr::null_mut(),
MultipleTrusteeOperation: 0,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_UNKNOWN,
ptstrName: sid.as_ptr() as *mut u16,
};
let mut effective = 0;
let result = unsafe { GetEffectiveRightsFromAclW(dacl, &trustee, &mut effective) };
if result != ERROR_SUCCESS {
return Err(win32_result_error(
"GetEffectiveRightsFromAclW(Windows sandbox NUL)",
result,
));
}
if effective & required != required {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("Windows sandbox account {account} lacks required NUL device access"),
));
}
}
Ok(())
})
.map_err(|error| error.to_string())
}
fn with_null_device_dacl<T>(
desired_access: u32,
operation: impl FnOnce(
windows_sys::Win32::Foundation::HANDLE,
*mut windows_sys::Win32::Security::ACL,
) -> io::Result<T>,
) -> io::Result<T> {
use windows_sys::Win32::Foundation::{
CloseHandle, LocalFree, ERROR_SUCCESS, HLOCAL, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_KERNEL_OBJECT};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
let path = to_wide(r"\\.\NUL");
let handle = unsafe {
CreateFileW(
path.as_ptr(),
desired_access,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
0,
)
};
if handle == 0 || handle == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
let mut descriptor = std::ptr::null_mut();
let mut dacl = std::ptr::null_mut();
let security = unsafe {
GetSecurityInfo(
handle,
SE_KERNEL_OBJECT,
windows_sys::Win32::Security::DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut descriptor,
)
};
if security != ERROR_SUCCESS {
unsafe {
CloseHandle(handle);
}
return Err(win32_result_error(
"GetSecurityInfo(Windows sandbox NUL)",
security,
));
}
let result = operation(handle, dacl);
unsafe {
if !descriptor.is_null() {
LocalFree(descriptor as HLOCAL);
}
CloseHandle(handle);
}
result
}
fn win32_result_error(context: &str, code: u32) -> io::Error {
io::Error::new(
io::ErrorKind::Other,
format!(
"{context} failed: {}",
io::Error::from_raw_os_error(code as i32)
),
)
}
pub(super) fn ensure_local_account(username: &str, password: &str) -> io::Result<()> {
use windows_sys::Win32::NetworkManagement::NetManagement::{
NERR_UserExists, NetUserAdd, NetUserSetInfo, UF_DONT_EXPIRE_PASSWD, UF_SCRIPT, USER_INFO_1,