diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index afd0433c98..4572ad94df 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -82,7 +82,7 @@ launch policy, then launches the target with layered Windows controls: the tree on close; - handle inheritance disabled; - AppContainer ACEs for only the compiled read/write roots, with a persisted recovery ledger; -- recursive reparse-point rejection before ACL mutation; +- reparse-point root rejection and validated nested NTFS-junction non-traversal during ACL grants; - a closed, sorted environment from the normalized command; - bounded local named-pipe framing protected to SYSTEM and the current user. @@ -252,7 +252,8 @@ designed but explicitly deferred as later gates, tracked by Phase 4 in Enforced (merged in #2961 unless tagged with a follow-up PR): - default-deny filesystem with distinct read/write roots compiled from the exact profile (§6.1); -- recursive reparse-point rejection and multi-hard-link rejection before ACL mutation (§5, §6.1); +- reparse-point root rejection, validated nested NTFS-junction non-traversal, and multi-hard-link rejection + before ACL mutation (§5, §6.1); - a fresh request-derived AppContainer SID, per-launch ACL grants in a versioned recovery ledger, and stale-ledger reconciliation at startup (§6.1, §7.1); - an AppContainer token with no network capabilities (§6.2); @@ -264,8 +265,9 @@ Enforced (merged in #2961 unless tagged with a follow-up PR): the first launch, terminates and drains the AppContainer Job, and releases the launch ledger/ACEs; - a packaged 64-launch repeated-wave concurrency soak with disjoint launch identities, followed by process and ACL-ledger residue assertions; -- a packaged malicious-child matrix covering recursive junction and multi-hard-link admission, - outside-file access, TCP connection denial, host named-pipe access, ambient environment, +- a packaged malicious-child matrix covering junction-root admission rejection, nested-junction + non-traversal, multi-hard-link admission, outside-file access, TCP connection denial, host + named-pipe access, ambient environment, host HKCU values, parent-token access, descendant AppContainer/Job inheritance, and quarantined identity non-reuse; - per-launch private-desktop **placement** (§6.3) **(#3174)**: each production launch and the readiness probe @@ -374,7 +376,7 @@ sequenceDiagram M-->>H: native path + one-shot manifest H->>B: --broker-local manifest B->>B: delete manifest; bind PID, nonce, and launch digest - B->>B: recover ledger; reject reparse trees; grant SID ACEs + B->>B: recover ledger; reject reparse roots; skip validated nested NTFS junctions; grant SID ACEs B->>J: create kill-on-close Job B->>C: create AppContainer process with atomic Job attribute C-->>B: bounded exit result @@ -386,17 +388,19 @@ sequenceDiagram The first implementation needs no elevated setup. Windows creates a request-derived Maka AppContainer profile, and the packaged native binary grants its unique SID only the roots admitted -for the current launch. Before mutation it recursively rejects `FILE_ATTRIBUTE_REPARSE_POINT`, persists a -versioned ledger with `create_new` and `sync_all`, and reconciles every stale ledger before accepting -a new request. A global kernel mutex covers only ledger/ACL mutation; each launch holds a separate -request-specific kernel lease through child settlement, so recovery skips live ledgers while disjoint -launches execute concurrently. Normal settlement removes the SID ACE and then deletes the ledger. +for the current launch. Before mutation it rejects `FILE_ATTRIBUTE_REPARSE_POINT` roots and does not +traverse validated nested NTFS junctions during recursive grants, persists a versioned ledger with `create_new` +and `sync_all`, and reconciles every stale ledger before accepting a new request. A global kernel +mutex covers only ledger/ACL mutation; each launch holds a separate request-specific kernel lease +through child settlement, so recovery skips live ledgers while disjoint launches execute concurrently. +Normal settlement removes the SID ACE and then deletes the ledger. The ledger filename is a SHA-256 of the request identity, so request-controlled path characters cannot escape its directory. `icacls.exe` is resolved from absolute `%SystemRoot%\System32`, invoked without a shell, and uses `/L` so link objects are operated on rather than followed. The Windows CI -smoke proves normal cleanup, stale-ledger recovery, and rejection of a junction in an admitted tree. -Crash/power-loss and concurrent replacement hardening remain release evidence, not assumptions. +smoke proves normal cleanup, stale-ledger recovery, rejection of a junction root, and +non-traversal of a nested junction in an admitted tree. Crash/power-loss and concurrent replacement +hardening remain release evidence, not assumptions. ### 7.2 Broker and protocol @@ -487,7 +491,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Category | Packaged evidence | | --- | --- | -| Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | +| Filesystem aliases | outside denial, junction-root admission refusal, nested-junction non-traversal, and multi-hard-link admission refusal | | Network channels | TCP connect denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | | Descendants | child creation is denied fail-closed, or a created descendant retains the AppContainer token and kill-on-close Job | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index e525a8cebc..8dcbe98c5f 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -71,7 +71,7 @@ policy 的 SHA-256,再叠加以下 Windows 控制: - 通过 `PROC_THREAD_ATTRIBUTE_JOB_LIST` 在创建时原子附加、close 时杀整棵树的 Job Object; - 禁止 handle inheritance; - 只给编译后的 read/write root 添加 AppContainer ACE,并使用持久 recovery ledger; -- ACL 修改前递归拒绝 reparse point; +- 拒绝 reparse point root,并在递归 ACL 授权时不遍历经过验证的嵌套 NTFS junction; - 从规范化 command 构造封闭、排序后的环境; - 只允许 SYSTEM 和当前用户的本地命名管道,以及有长度上限的 frame。 @@ -169,7 +169,7 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 **已强制(未标注者由 #2961 合并强制):** - 默认拒绝文件系统,读/写 grant 分离(§6.1); -- ACL 修改前拒绝 reparse point 与多硬链接对象(§5/§6.1); +- ACL 修改前拒绝 reparse point root 与多硬链接对象,且递归授权时不遍历经过验证的嵌套 NTFS junction(§5/§6.1); - 每次启动使用 request-derived 独立 AppContainer SID + 版本化 ledger + startup reconcile(§6.1/§7.1); - 不授予网络 capability 的 AppContainer token(§6.2); - 创建时原子附加、close 时杀整棵树的 kill-on-close Job(§6.3); @@ -179,8 +179,8 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 中断首次启动、终止并 drain AppContainer Job,并释放本次 ledger/ACE; - 打包路径执行 64 次、按波次重复的并发 soak,每次使用互不相同的启动 identity,最后断言无进程与 ACL-ledger 残留; -- 打包恶意 child 矩阵覆盖递归 junction 与多硬链接准入、outside 文件、TCP connection 拒绝、宿主 named - pipe、ambient 环境、宿主 HKCU、父进程 token、descendant 的 AppContainer/Job 继承,以及 quarantine +- 打包恶意 child 矩阵覆盖 junction root 准入拒绝、嵌套 junction 不遍历、多硬链接准入、outside 文件、TCP + connection 拒绝、宿主 named pipe、ambient 环境、宿主 HKCU、父进程 token、descendant 的 AppContainer/Job 继承,以及 quarantine identity 不复用; - 按启动的 private desktop **放置(placement)**(§6.3)**(#3174)**:每次生产启动与 readiness probe 均在当前 window station 上创建 alternate desktop,其 DACL 仅授予发起用户、Local System 与该次启动的 AppContainer SID(且只给该 SID 最小非交互权限;并以前置 deny ACE 从 AppContainer 子进程有效携带的发起用户 SID 上剥离 `DESKTOP_SWITCHDESKTOP`/`DESKTOP_HOOKCONTROL`/journal 录制回放),并以 `STARTUPINFOW.lpDesktop` 指向它启动子进程,建不出或授不了即 fail closed。桌面钉在 Low integrity(`S:(ML;;NW;;;LW)`)使授予权限对 Low-IL 子进程通过 MIC,且 heap 经 `CreateDesktopExW` 按启动限额(512 KiB)使受支持并发不会耗尽系统 desktop heap。由于 `lpDesktop` 只选择*初始*桌面,这把 worker 放置到交互 `Default` 桌面之外并对私有桌面做 DACL 保护;这是 placement 加 DACL 保护、**不是**防逃逸边界——没有结构性机制阻止进程内代码 `OpenDesktopW("Default")` + `SetThreadDesktop` 重新挂回,clipboard 也归 window station、仍为共用(no-Win32k mitigation、独立 window station 与 token 边界见下方暂缓门禁); - 生产 identity readiness probe(§6.4)**(#3161)**:`--readiness-probe` 真正建立 AppContainer identity/token、kill-on-close Job 与 private desktop 并在该桌面上启动抛弃式受限子进程(`cmd.exe /d /c exit 0`,以 `/d` 关闭 AutoRun 使宿主 shell 定制不能扭曲结果),使可用性在宿主无法创建边界时 fail closed,而非仅凭打包二进制存在;成功时输出机器可读 attestation(精确 SID 匹配、特定 Job membership、settlement、private-desktop placement),发布冒烟逐字段断言,使该 gate 不会静默退化为空洞的 exit-0 检查; @@ -226,7 +226,7 @@ sequenceDiagram M-->>H: native path + one-shot manifest H->>B: --broker-local manifest B->>B: delete manifest; bind PID, nonce, launch digest - B->>B: recover ledger; reject reparse tree; grant SID ACE + B->>B: recover ledger; reject reparse point root; skip validated nested NTFS junction; grant SID ACE B->>J: create kill-on-close Job B->>C: create AppContainer process with atomic Job attribute C-->>B: bounded exit result @@ -237,14 +237,14 @@ sequenceDiagram ### 7.1 Setup 与持久状态 首个实现不需要 elevated setup。Windows 为每次 launch 创建 request-derived Maka AppContainer profile,打包 -native binary 只给当前 launch 允许的 root 授予其独立 SID。修改前递归拒绝 `FILE_ATTRIBUTE_REPARSE_POINT`,用 `create_new` 和 +native binary 只给当前 launch 允许的 root 授予其独立 SID。拒绝 `FILE_ATTRIBUTE_REPARSE_POINT` root,递归授权时不遍历经过验证的嵌套 NTFS junction;用 `create_new` 和 `sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 删除 ledger。全局 kernel mutex 只覆盖 ledger/ACL 修改;每个 launch 在 child settlement 完成前持有独立的 request-specific kernel lease,因此 recovery 会跳过仍在使用的 ledger,同时不同 launch 仍可并发执行。 ledger 文件名使用 request identity 的 SHA-256,请求控制的路径字符无法逃出目录。`icacls.exe` 从绝对 `%SystemRoot%\System32` 解析,不经过 shell,并使用 `/L` 操作 link object 而非跟随目标。Windows CI smoke -证明正常清理、遗留 ledger recovery 和允许目录内 junction 拒绝。crash/power-loss 与并发替换加固仍是发布 +证明正常清理、遗留 ledger recovery、junction root 拒绝,以及允许目录中嵌套 junction 不遍历。crash/power-loss 与并发替换加固仍是发布 证据,不能当作已满足的假设。 ### 7.2 Broker 与协议 @@ -330,7 +330,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 类别 | 打包证据 | | --- | --- | -| 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | +| 文件别名 | outside 拒绝、junction root 准入拒绝、嵌套 junction 不遍历,以及多硬链接准入拒绝 | | 网络通道 | 无网络 capability 时拒绝 TCP connect | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | | descendant | child 创建被 fail-closed 拒绝,或已创建 descendant 仍持有 AppContainer token 与 kill-on-close Job | diff --git a/experiments/windows-sandbox/README.md b/experiments/windows-sandbox/README.md index c7c02736cb..8c79932406 100644 --- a/experiments/windows-sandbox/README.md +++ b/experiments/windows-sandbox/README.md @@ -80,10 +80,12 @@ non-zero, fail-closed outcome. `launcher --appcontainer ` is the isolated-identity candidate. It creates a fresh request-derived AppContainer identity, combines its token with the same atomic Job attribute, and supplies no network capabilities. Before -launch, the broker persists an ACL recovery ledger, rejects reparse points, and -grants that per-launch SID only the requested roots. A short-lived global mutex -serializes ACL mutation, while a request-specific kernel lease distinguishes -live ledgers from abandoned ones without serializing child execution. The smoke -proves allowed read/write access, denial of a user-readable sibling file and -live loopback endpoint, stale-ledger recovery, concurrent launches, junction -rejection, and removal of the temporary AppContainer ACE after exit. +launch, the broker persists an ACL recovery ledger, rejects reparse-point +roots, skips validated nested NTFS junctions during recursive grants, and grants that +per-launch SID only the requested roots. A short-lived global mutex serializes +ACL mutation, while a request-specific kernel lease distinguishes live ledgers +from abandoned ones without serializing child execution. The smoke proves +allowed read/write access, denial of a user-readable sibling file and live +loopback endpoint, stale-ledger recovery, concurrent launches, junction-root +rejection, nested-junction non-traversal, and removal of the temporary +AppContainer ACE after exit. diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index ebf67f351f..ba4404ba6b 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -205,15 +205,21 @@ try { throw "Packaged adversarial probe failed: exit=$exitCode missing=$($missingEvidence -join ', ') output=$rendered" } - # Recursive roots fail admission when any entry redirects to another tree. $junctionRoot = Join-Path $workRoot 'junction-root' New-Item -ItemType Directory -Path $junctionRoot | Out-Null New-Item -ItemType Junction -Path (Join-Path $junctionRoot 'escape') -Target $outsideRoot | Out-Null - $junctionRequest = Write-LaunchRequest -Name "phase4-junction-$PID" ` + $junctionRequestId = "phase4-junction-$PID" + $junctionSid = Get-AppContainerSid $junctionRequestId + $junctionRequest = Write-LaunchRequest -Name $junctionRequestId ` -Arguments @('--self-probe') -ReadRoots @($junctionRoot) -WriteRoots @() ` -ExactReadRoots @() -ExactWriteRoots @() - Invoke-ExpectedAdmissionFailure -RequestPath $junctionRequest -Pattern 'reparse point' ` - -Description 'Junction alias admission' + $junctionResult = Invoke-Launcher @('--appcontainer', $junctionRequest) + if ($junctionResult.ExitCode -ne 0) { + throw "Nested junction admission failed: $($junctionResult.Output -join "`n")" + } + if ((Get-AclText $outsideRoot) -match [regex]::Escape($junctionSid)) { + throw 'Nested junction target received an AppContainer ACL grant' + } # Recursive roots also reject a file whose content is reachable through a # second hard-link name outside the admitted tree. diff --git a/experiments/windows-sandbox/appcontainer-smoke.ps1 b/experiments/windows-sandbox/appcontainer-smoke.ps1 index 26f7e5d1b0..a31cf134cf 100644 --- a/experiments/windows-sandbox/appcontainer-smoke.ps1 +++ b/experiments/windows-sandbox/appcontainer-smoke.ps1 @@ -33,6 +33,7 @@ if (-not (Test-Path -LiteralPath $launcher)) { $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } $requestPath = Join-Path $tempRoot "maka-windows-appcontainer-$PID.json" +$reparseRequestPath = Join-Path $tempRoot "maka-windows-appcontainer-reparse-$PID.json" $secretPath = Join-Path $tempRoot "maka-windows-appcontainer-secret-$PID.txt" $allowedReadPath = Join-Path $tempRoot "maka-windows-appcontainer-allowed-read-$PID.txt" $allowedWriteRoot = Join-Path $tempRoot "maka-windows-appcontainer-allowed-write-$PID" @@ -113,7 +114,21 @@ try { $junction = Join-Path $allowedWriteRoot 'junction' New-Item -ItemType Junction -Path $junction -Target $staleRoot | Out-Null - $reparseOutput = & $launcher --appcontainer $requestPath 2>&1 + $reparseRequest = @{ + version = 1 + requestId = "appcontainer-reparse-root-$PID" + executable = $launcher + arguments = @('--self-probe') + cwd = Split-Path -Parent $launcher + readRoots = @($junction) + writeRoots = @() + exactReadRoots = @() + exactWriteRoots = @() + network = 'restricted' + environment = @{} + } + $reparseRequest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $reparseRequestPath -Encoding utf8 + $reparseOutput = & $launcher --appcontainer $reparseRequestPath 2>&1 if ($LASTEXITCODE -eq 0 -or ($reparseOutput -join "`n") -notmatch 'reparse point') { throw "AppContainer accepted a reparse-point root: $($reparseOutput -join "`n")" } @@ -122,6 +137,7 @@ try { } finally { $listener.Stop() Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $reparseRequestPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $secretPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $allowedReadPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $allowedWriteRoot -Recurse -Force -ErrorAction SilentlyContinue diff --git a/experiments/windows-sandbox/launcher/Cargo.toml b/experiments/windows-sandbox/launcher/Cargo.toml index 57953a789e..d507adaaab 100644 --- a/experiments/windows-sandbox/launcher/Cargo.toml +++ b/experiments/windows-sandbox/launcher/Cargo.toml @@ -45,8 +45,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_IO", + "Win32_System_Ioctl", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_StationsAndDesktops", + "Win32_System_SystemServices", "Win32_System_Threading", ] } diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index 607ced68a7..39728d979d 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -41,10 +41,14 @@ use windows_sys::Win32::Security::{ OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, }; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING, + BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + GetFileInformationByHandle, OPEN_EXISTING, }; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::FSCTL_GET_REPARSE_POINT; +use windows_sys::Win32::System::SystemServices::IO_REPARSE_TAG_MOUNT_POINT; use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex, WaitForSingleObject}; use crate::broker_pipe_security::pipe_security_sddl; @@ -429,15 +433,15 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, reject_multi_link_file(Path::new(path))?; } // Only a recursive grant extends into the tree, so only a recursive - // grant requires the tree to be alias-free. Exact roots (e.g. the - // cwd metadata anchor) may legitimately contain junctions deeper in - // the workspace that the sandbox never grants. + // grant needs to inspect its entries. Exact roots (e.g. the cwd + // metadata anchor) may legitimately contain junctions deeper in the + // workspace that the sandbox never grants. let recursive_read = contains_path(&request.read_roots, path) && !contains_path(&request.exact_read_roots, path); let recursive_write = contains_path(&request.write_roots, path) && !contains_path(&request.exact_write_roots, path); if metadata.is_dir() && (recursive_read || recursive_write) { - reject_aliased_entries(Path::new(path))?; + reject_multi_link_files(Path::new(path))?; } roots.push(LedgerRoot { path: path.clone(), @@ -455,30 +459,139 @@ fn contains_path(paths: &[String], path: &str) -> bool { paths.iter().any(|entry| entry.eq_ignore_ascii_case(path)) } -/// Rejects reparse points and multi-link files anywhere in a recursively -/// granted tree. An `(OI)(CI)` grant propagates inherited ACEs onto the -/// existing children at grant time, so a file inside the tree that also has a -/// hard link outside it would carry the grant past the declared root. -fn reject_aliased_entries(path: &Path) -> Result { +fn reject_multi_link_files(path: &Path) -> Result<(), String> { let metadata = fs::symlink_metadata(path) .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return Err(format!( - "ACL root contains a reparse point: {}", - path.display() - )); + return if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 + && nested_reparse_is_ntfs_junction(path)? + { + Ok(()) + } else { + Err(format!( + "ACL tree contains an unsupported reparse point: {}", + path.display() + )) + }; } if metadata.is_dir() { for entry in fs::read_dir(path) .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? { let entry = entry.map_err(|error| format!("scan ACL root failed: {error}"))?; - reject_aliased_entries(&entry.path())?; + reject_multi_link_files(&entry.path())?; } } else { reject_multi_link_file(path)?; } - Ok(metadata) + Ok(()) +} + +fn nested_reparse_is_ntfs_junction(path: &Path) -> Result { + let wide_path = wide(&path.to_string_lossy()); + let handle = unsafe { + CreateFileW( + wide_path.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(last_error(&format!( + "CreateFileW(inspect nested reparse point {})", + path.display() + ))); + } + let mut data = [0_u8; 16 * 1024]; + let mut bytes_returned = 0; + let queried = unsafe { + DeviceIoControl( + handle, + FSCTL_GET_REPARSE_POINT, + std::ptr::null(), + 0, + data.as_mut_ptr().cast(), + data.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { CloseHandle(handle) }; + if queried == 0 { + return Err(last_error(&format!( + "DeviceIoControl(FSCTL_GET_REPARSE_POINT {})", + path.display() + ))); + } + is_ntfs_junction_reparse_data(&data[..bytes_returned as usize]) +} + +pub(crate) fn is_ntfs_junction_reparse_data(data: &[u8]) -> Result { + const REPARSE_DATA_HEADER_LEN: usize = 8; + const MOUNT_POINT_PATH_OFFSET: usize = 16; + + let reparse_tag = u32::from_le_bytes( + data.get(..4) + .ok_or_else(|| "nested reparse point data is missing its tag".to_owned())? + .try_into() + .expect("reparse tag has a fixed width"), + ); + if reparse_tag != IO_REPARSE_TAG_MOUNT_POINT { + return Ok(false); + } + let reparse_data_len = u16::from_le_bytes( + data.get(4..6) + .ok_or_else(|| "nested junction data is missing its length".to_owned())? + .try_into() + .expect("reparse data length has a fixed width"), + ) as usize; + let reparse_data_end = REPARSE_DATA_HEADER_LEN + .checked_add(reparse_data_len) + .ok_or_else(|| "nested junction data length overflows".to_owned())?; + if reparse_data_end < MOUNT_POINT_PATH_OFFSET || reparse_data_end > data.len() { + return Err("nested junction data has an invalid length".to_owned()); + } + let substitute_offset = u16::from_le_bytes( + data.get(8..10) + .ok_or_else(|| "nested junction data is missing its substitute offset".to_owned())? + .try_into() + .expect("substitute offset has a fixed width"), + ) as usize; + let substitute_len = u16::from_le_bytes( + data.get(10..12) + .ok_or_else(|| "nested junction data is missing its substitute length".to_owned())? + .try_into() + .expect("substitute length has a fixed width"), + ) as usize; + if substitute_len % 2 != 0 { + return Err("nested junction substitute name has an odd byte length".to_owned()); + } + let substitute_start = MOUNT_POINT_PATH_OFFSET + .checked_add(substitute_offset) + .ok_or_else(|| "nested junction substitute offset overflows".to_owned())?; + let substitute_end = substitute_start + .checked_add(substitute_len) + .ok_or_else(|| "nested junction substitute length overflows".to_owned())?; + let substitute = data + .get(substitute_start..substitute_end) + .filter(|_| substitute_end <= reparse_data_end) + .ok_or_else(|| "nested junction substitute name is out of range".to_owned())?; + let target = String::from_utf16( + &substitute + .chunks_exact(2) + .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) + .collect::>(), + ) + .map_err(|error| format!("nested junction substitute name is invalid UTF-16: {error}"))?; + let target_path = target.strip_prefix(r"\??\").unwrap_or_default().as_bytes(); + Ok(target_path.len() >= 3 + && target_path[0].is_ascii_alphabetic() + && target_path[1] == b':' + && target_path[2] == b'\\') } /// Fails closed on files whose kernel link count exceeds one. The DACL that a diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index e03b99532a..841a278247 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -27,10 +27,13 @@ mod tests { use sha2::{Digest, Sha256}; use crate::acl_ledger::{ - LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale, - with_acl_grants, write_ledger, + LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, + is_ntfs_junction_reparse_data, recover_stale, with_acl_grants, write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; + use windows_sys::Win32::System::SystemServices::{ + IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK, + }; // Synthetic AppContainer-shaped SIDs so the tests never touch the real // per-app profile. icacls accepts arbitrary `*SID` principals and renders @@ -395,7 +398,7 @@ mod tests { // An exact grant mutates only the directory object itself; entries // below it receive no ACE, so aliases deeper in the tree stay out of - // admission scope exactly like reparse points already do. + // admission scope exactly like validated junctions already do. let outside = fixture .target .parent() @@ -415,6 +418,78 @@ mod tests { assert!(!roots[0].read_recursive); } + #[test] + fn nested_junction_is_excluded_from_recursive_acl_grant() { + let fixture = Fixture::new("nested-junction"); + let target = fixture + .target + .parent() + .expect("fixture base") + .join("junction-target"); + fs::create_dir_all(&target).expect("create junction target"); + fs::write(target.join("outside.txt"), "outside payload").expect("seed junction target"); + let junction = fixture.target.join("child").join("junction"); + let junction_command = format!( + "mklink /J \"{}\" \"{}\"", + junction.display(), + target.display() + ); + let status = Command::new("cmd.exe") + .args(["/C", &junction_command]) + .status() + .expect("create nested junction"); + assert!(status.success(), "create nested junction"); + let request = launch_request( + vec![fixture.target_str()], + Vec::new(), + Vec::new(), + Vec::new(), + ); + + let roots = collect_roots(&request).expect("nested junction admits"); + + assert_eq!(roots.len(), 1); + assert!(roots[0].read_recursive); + with_acl_grants(&request, APP_SID, || { + assert!(sid_listed(&fixture.target, APP_SID)); + assert!(!sid_listed(&target, APP_SID)); + Ok(()) + }) + .expect("nested junction target stays outside the recursive grant"); + } + + fn mount_point_reparse_data(tag: u32, target: &str) -> Vec { + let target: Vec = target.encode_utf16().collect(); + let mut data = vec![0_u8; 16 + target.len() * 2]; + data[..4].copy_from_slice(&tag.to_le_bytes()); + data[4..6].copy_from_slice(&((8 + target.len() * 2) as u16).to_le_bytes()); + data[10..12].copy_from_slice(&((target.len() * 2) as u16).to_le_bytes()); + for (index, unit) in target.iter().enumerate() { + let offset = 16 + index * 2; + data[offset..offset + 2].copy_from_slice(&unit.to_le_bytes()); + } + data + } + + #[test] + fn only_drive_backed_mount_points_are_admitted_as_nested_junctions() { + let junction = mount_point_reparse_data(IO_REPARSE_TAG_MOUNT_POINT, r"\??\C:\target"); + assert!(is_ntfs_junction_reparse_data(&junction).expect("parse directory junction")); + + let volume_mount = + mount_point_reparse_data(IO_REPARSE_TAG_MOUNT_POINT, r"\??\Volume{1234-5678}\"); + assert!( + !is_ntfs_junction_reparse_data(&volume_mount).expect("parse volume mount"), + "volume mount must fail closed" + ); + + let symlink = mount_point_reparse_data(IO_REPARSE_TAG_SYMLINK, r"\??\C:\target"); + assert!( + !is_ntfs_junction_reparse_data(&symlink).expect("parse symbolic link"), + "symbolic link must fail closed" + ); + } + fn shared_ledger_dir() -> PathBuf { std::env::temp_dir().join("maka-sandbox-acl-ledgers") } diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index 1bb6dd2ff1..1152c86f0c 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -19,7 +19,16 @@ import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; -import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { after, before, describe, test } from 'node:test'; @@ -188,6 +197,39 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { ); }); + test('skips a nested junction without granting its target', async () => { + const sourceDirectory = join(workspace, 'glob-source'); + const junctionTarget = join(workspace, 'junction-target'); + const junction = join(sourceDirectory, 'dependencies', 'linked-package'); + await mkdir(junctionTarget, { recursive: true }); + await mkdir(dirname(junction), { recursive: true }); + await writeFile(join(sourceDirectory, 'main.ts'), 'export const main = true;\n'); + await writeFile(join(junctionTarget, 'hidden.ts'), 'export const hidden = true;\n'); + await symlink(junctionTarget, junction, 'junction'); + + const ordinaryFiles = await client.execute({ + operation: { kind: 'glob', path: sourceDirectory, pattern: '**/*.ts' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.equal(ordinaryFiles.kind, 'glob'); + if (ordinaryFiles.kind === 'glob') assert.deepEqual(ordinaryFiles.files, ['main.ts']); + + const junctionFiles = await client.execute({ + operation: { + kind: 'glob', + path: sourceDirectory, + pattern: 'dependencies/linked-package/**/*.ts', + }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.equal(junctionFiles.kind, 'glob'); + if (junctionFiles.kind === 'glob') assert.deepEqual(junctionFiles.files, []); + }); + test('fails closed for unapproved outside paths', async () => { await assert.rejects( client.execute({ diff --git a/packages/runtime/src/filesystem-worker/sandbox-paths.ts b/packages/runtime/src/filesystem-worker/sandbox-paths.ts index 715e80b137..fecad5662e 100644 --- a/packages/runtime/src/filesystem-worker/sandbox-paths.ts +++ b/packages/runtime/src/filesystem-worker/sandbox-paths.ts @@ -37,10 +37,11 @@ import { * native one (GetFinalPathNameByHandle) are denied by the LowBox token. The * Windows variant therefore resolves lexically and REJECTS reparse points * outright instead of following them. That is sound because request paths are - * canonicalised by the client before launch, the broker refuses to grant any - * tree containing a reparse point, and the ACL grants themselves are the - * kernel-side enforcement: a link created after grant time points at an - * ungranted target the worker cannot touch anyway. + * canonicalised by the client before launch, the broker rejects a reparse + * point as a grant root and does not traverse validated nested NTFS junctions while + * granting a tree, and the ACL grants themselves are the kernel-side + * enforcement: a link created after grant time points at an ungranted target + * the worker cannot touch anyway. */ export interface SandboxPathApi { realpath(path: string): Promise;