From 59fc0b6520a3dd6ab131d3f817cf848e79393085 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sun, 18 Oct 2026 03:19:14 -0700 Subject: [PATCH] fix(sandbox): strip the token's privileges, refuse a launcher started from a restricted process, and state what Windows bounds The restricted token now drops every privilege but the traverse check, so backup semantics cannot write outside the roots even from an elevated session. A SID is synthetic only if no account resolves it. The launcher refuses to run under a restricted token (a command's own launcher inherits it), grants only session temp folders, hands the child just the standard handles, runs it in a job with UI limits, and the default DACL and the grant mask are narrower. Links are not followed at grant time and a link root is refused. REASONIX.md, the guide, the Settings text and the release note list what is and is not bounded. --- REASONIX.md | 19 +- desktop/frontend-next/src/i18n/en_settings.ts | 4 +- desktop/frontend-next/src/ui/Sandbox.tsx | 2 +- docs/GUIDE.md | 37 +++- docs/GUIDE.zh-CN.md | 22 +- internal/safety/sandbox/win_grants.go | 29 ++- internal/safety/sandbox/win_grants_windows.go | 31 ++- internal/safety/sandbox/win_launch.go | 29 +++ .../win_launch_hardening_windows_test.go | 188 ++++++++++++++++++ internal/safety/sandbox/win_launch_test.go | 72 +++++++ internal/safety/sandbox/win_launch_windows.go | 81 ++++++-- .../safety/sandbox/win_launch_windows_test.go | 15 +- .../safety/sandbox/win_prepare_windows.go | 3 + internal/safety/sandbox/win_sidowner_other.go | 7 + .../safety/sandbox/win_sidowner_windows.go | 21 ++ .../unreleased/13109-windows-sandbox.md | 4 +- 16 files changed, 509 insertions(+), 55 deletions(-) create mode 100644 internal/safety/sandbox/win_launch_hardening_windows_test.go create mode 100644 internal/safety/sandbox/win_sidowner_other.go create mode 100644 internal/safety/sandbox/win_sidowner_windows.go diff --git a/REASONIX.md b/REASONIX.md index 927048c213..6653858168 100644 --- a/REASONIX.md +++ b/REASONIX.md @@ -413,8 +413,13 @@ moving a number here must not move the promise. - Symlinks are recorded by `Lstat`, so a write through one lands outside what the walk compares. Confining the resolved target is the sandbox's job. - `internal/safety/sandbox` confines writes to the workspace, configured extras, temp - and toolchain caches — on macOS and Linux. Windows has no OS-level bash - sandbox, so there nothing the host enforces bounds `Writable`. + and toolchain caches on macOS and Linux. +- On Windows a user-enabled write-restricted token bounds the *creation* of files + (create, append, overwrite, `mkdir`, HKCU writes, global git config, writes through links). +- There, deleting or renaming away an existing file is not bounded, and with the + setting off nothing the host enforces bounds `Writable`. +- The caches a command may write can be poisoned for the user's later unsandboxed + builds, on every platform. - The governed host authorities are a *named* set — ssh-agent, docker, podman. DBus, Wayland, X11, gpg-agent, keyrings and arbitrary sockets stay reachable, so the claim is "these endpoints are governed", never "host IPC is isolated". @@ -460,9 +465,13 @@ stop a connect. Possession is not authority: the key can be unreadable while an agent still signs with it. A protection claim exists only where a backend has a demonstrated enforcement -primitive for that dimension. Unsupported dimensions stay explicitly unsupported -— Windows currently enforces none of the three — because a claim that outruns -its enforcement is worse than an absent one: it is believed. +primitive for that dimension. Unsupported dimensions stay explicitly unsupported, +because a claim that outruns its enforcement is worse than an absent one: it is +believed. + +Windows enforces Integrity on file creation only, when the user turns it on. +Deletion and rename, Confidentiality and Authority (the named pipes of ssh-agent +and docker, COM and RPC services) are not enforced there. **Known unmeasured surface — macOS host-mediated delegation:** Apple Events and Mach-service lookup are not denied by the current allow-default profile. Prior diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index e56ab95f79..4e36873adf 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -385,8 +385,8 @@ export const EN_SETTINGS: Record = { "还有 {n} 个窗格正在运行,它们会在下次重建时采用这个改动。": "{n} panes are still running and will take this change at their next rebuild.", "实际可写": "Actually writable", "命令执行方式": "How commands run", - "在 Windows 上启用后,命令无法在清单之外创建或改写文件、写注册表;但删除或重命名清单之外的文件、读取文件和联网不受限制。需要交互式桌面会话,并会在清单里的文件夹上加一条权限项(可用 reasonix sandbox revoke 收回)。": - "On Windows, once on, commands cannot create or overwrite files outside the list or write the registry; deleting or renaming files outside the list, reading files and the network are not restricted. It needs an interactive desktop session and adds a permission entry on the folders in the list (take it back with reasonix sandbox revoke).", + "在 Windows 上启用后,命令无法在清单之外创建或改写文件、写注册表;但删除或重命名清单之外的文件、读取文件、联网,以及 ssh-agent、docker 管道和任务计划程序等系统服务不受限制,Everyone 本来就能写的位置和被授权的工具链缓存命令也能写。需要交互式桌面会话,并会在清单里的文件夹上加一条权限项(可用 reasonix sandbox revoke 收回)。": + "On Windows, once on, commands cannot create or overwrite files outside the list or write the registry; deleting or renaming files outside the list, reading files, the network, and system services such as the ssh-agent and docker pipes or the task scheduler are not restricted, and neither are places Everyone can already write or the toolchain caches that are granted. It needs an interactive desktop session and adds a permission entry on the folders in the list (take it back with reasonix sandbox revoke).", "启用沙箱后,命令无法写入清单之外的位置。上方的可写目录清单由操作系统实施,不依赖 agent 自觉遵守。": "Inside the sandbox a command cannot write outside the list at all. The writable directories above are enforced by the operating system, not by the agent's cooperation.", "本机没有可用的操作系统沙箱": "This machine has no usable OS sandbox", diff --git a/desktop/frontend-next/src/ui/Sandbox.tsx b/desktop/frontend-next/src/ui/Sandbox.tsx index 7b9f936119..15609224e0 100644 --- a/desktop/frontend-next/src/ui/Sandbox.tsx +++ b/desktop/frontend-next/src/ui/Sandbox.tsx @@ -196,7 +196,7 @@ export function CommandMode({

{t("命令执行方式")}

{box.available && box.platform === "windows" ? (

- {t("在 Windows 上启用后,命令无法在清单之外创建或改写文件、写注册表;但删除或重命名清单之外的文件、读取文件和联网不受限制。需要交互式桌面会话,并会在清单里的文件夹上加一条权限项(可用 reasonix sandbox revoke 收回)。")} + {t("在 Windows 上启用后,命令无法在清单之外创建或改写文件、写注册表;但删除或重命名清单之外的文件、读取文件、联网,以及 ssh-agent、docker 管道和任务计划程序等系统服务不受限制,Everyone 本来就能写的位置和被授权的工具链缓存命令也能写。需要交互式桌面会话,并会在清单里的文件夹上加一条权限项(可用 reasonix sandbox revoke 收回)。")}

) : box.available ? (

diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7c6a381262..f260db032a 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1305,10 +1305,11 @@ inherit the chat session's temporary directory. An approved sandbox-escape command still receives the private temp environment variables, but on Linux its literal `/tmp` is no longer mapped by bubblewrap. -**Windows note:** Reasonix does not ship an OS-level Bash sandbox on Windows. -The effective mode is fixed to `off`; even an older config containing +**Windows note:** the OS-level Bash sandbox on Windows is off unless the user +turns on `[sandbox] windows_integrity = true` (see "On Windows" below). While it +is off the effective mode is `off`; even an older config containing `bash = "enforce"` resolves to `off`, `reasonix doctor` flags the ignored value, -and the desktop selector is read-only. Bash commands therefore run unconfined, +and the desktop selector is read-only. Bash commands then run unconfined, while the dedicated file tools still enforce `workspace_root`, `allow_write`, and `forbid_read` in process. Saved credential variables are still removed from the child environment, but an approved unconfined shell runs as the user and is @@ -1335,22 +1336,36 @@ With `windows_integrity` on, a bash command runs under a write-restricted token. state folder. It gives nobody else access and is inert if the sandbox is turned off. - `reasonix sandbox list` shows what is granted; `reasonix sandbox revoke` takes it back. -What it bounds, measured on Windows 11 in an interactive session: +What it bounds, measured on Windows 11 and for every child process: - Creating, appending to and overwriting files outside the granted folders. -- `mkdir`, registry writes under your user, and global git configuration. -- Writes through a junction that points out of a granted folder. -- The same from every child process. +- `mkdir`, registry writes under your user (HKCU), and global git configuration. +- Writes through a junction or symbolic link that points out of a granted folder. +- Backup-semantics writes (`robocopy /B`): the token carries no privileges. +- Creating a scheduled task, creating a service and writing HKLM from the command + were refused when measured. What it does not bound: -- **Deleting or renaming away** a file outside the granted folders. Windows does - not apply the restriction to those two. The delete guard and the approval - layer remain the control for deletes. -- Reading files, and the network. +- **Deleting or renaming away** an existing file outside the granted folders. + Windows does not apply the restriction to those two. The delete guard and the + approval layer remain the control for deletes. +- **Reading** files, and **the network**. +- **Host authorities**: the Windows named pipes of ssh-agent and docker, and COM + and RPC services such as the task scheduler, are reachable. +- Writes anywhere that Everyone or your logon session can already write: both are + in the restricting set, so a folder open to them is open to the command. +- The toolchain caches that are granted are writable by the command, so a + poisoned cache can reach your later unsandboxed builds. macOS and Linux have the + same exposure for their caches. Other facts: +- The command runs with every privilege except the traverse check removed, and in + a job that cannot reach the other windows, clipboard or display settings of your session. +- Only the three standard handles cross into the command. +- A launcher request is honoured only for the SID in the grant record and for + session temp folders the host made. - It needs an interactive desktop session. In an SSH or service session `enforce` refuses to run commands (`sandbox.win_no_logon_sid`) instead of running them unconfined. - Not measured: standard (non-admin) accounts, other Windows versions, ReFS and diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 5685acf205..2e985f6682 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -978,7 +978,8 @@ $tmpFile = Join-Path $env:TEMP "result.json" MCP 等独立沙盒继续使用自己的隔离规范,不继承父会话临时目录。获得批准后绕过沙盒的 命令仍继承私有临时变量,但在 Linux 上其字面 `/tmp` 不再由 bwrap 映射。 -**Windows 说明:**Reasonix 不在 Windows 上提供 OS 级 Bash 沙箱,生效模式固定为 +**Windows 说明:**Windows 上的 OS 级 Bash 沙箱默认关闭,用户设置 +`[sandbox] windows_integrity = true` 才启用(见下面“在 Windows 上”)。关闭时生效模式为 `off`。旧配置即使写了 `bash = "enforce"` 也会解析为 `off`,`reasonix doctor` 会提示该设置被忽略,桌面设置中的选择器也为只读。Bash 命令会在不受 OS 沙箱限制的 环境中运行;专用文件工具仍会在进程内执行 `workspace_root`、`allow_write` 和 @@ -997,20 +998,27 @@ Windows 上默认关闭,需要设置 `[sandbox] windows_integrity = true`( - 授权是加在这些文件夹上的一条权限项,记录在 Reasonix 状态目录里;它不给其他任何人权限,沙箱以后关闭它也不起作用。 - `reasonix sandbox list` 显示已授权的内容,`reasonix sandbox revoke` 收回。 -它限制什么(在 Windows 11 的交互会话里实测): +它限制什么(在 Windows 11 上实测,对所有子进程): - 在授权文件夹之外创建、追加、覆盖文件。 -- `mkdir`、写你用户下的注册表、改全局 git 配置。 -- 穿过指向授权文件夹之外的目录联接写入。 -- 所有子进程的同样操作。 +- `mkdir`、写你用户下的注册表(HKCU)、改全局 git 配置。 +- 穿过指向授权文件夹之外的目录联接或符号链接写入。 +- 备份语义写入(`robocopy /B`):令牌不带任何特权。 +- 创建计划任务、创建服务、写 HKLM:实测都被拒绝。 它不限制什么: -- **删除或重命名走**授权文件夹之外的文件(Windows 不对这两种操作应用该限制)。删除仍由删除守卫和审批层把关。 -- 读取文件和网络。 +- **删除或重命名走**授权文件夹之外的已有文件(Windows 不对这两种操作应用该限制)。删除仍由删除守卫和审批层把关。 +- **读取**文件和**网络**。 +- **宿主权限端点**:ssh-agent 和 docker 的命名管道,以及任务计划程序之类的 COM、RPC 服务,命令都够得着。 +- Everyone 或你的登录会话本来就能写的位置:两者都在受限 SID 集合里,对它们开放的文件夹对命令也开放。 +- 被授权的工具链缓存命令可写,缓存被投毒会影响你之后不受沙箱的构建。macOS 和 Linux 的缓存有同样的暴露。 其他事实: +- 命令运行时只保留跨目录遍历检查,其余特权全部去掉,并在一个无法触及你会话里其他窗口、剪贴板和显示设置的作业里。 +- 只有三个标准句柄传入命令。 +- 启动器只认授权记录里的 SID,且只给宿主建的会话临时文件夹授权。 - 需要交互式桌面会话。SSH 或服务会话里 `enforce` 会拒绝执行命令(`sandbox.win_no_logon_sid`),而不是无沙箱运行。 - 未测:标准(非管理员)账户、其他 Windows 版本、ReFS 与网络盘。Linux 和 macOS 不变,且限制所有种类的写入。 diff --git a/internal/safety/sandbox/win_grants.go b/internal/safety/sandbox/win_grants.go index 4633239de0..195c01f851 100644 --- a/internal/safety/sandbox/win_grants.go +++ b/internal/safety/sandbox/win_grants.go @@ -34,6 +34,8 @@ const ( const grantBookVersion = 1 +const grantBookFile = "windows-grants.json" + // ErrGrantBookUnreadable is a record that exists and cannot be trusted. The // sandbox treats it as "no grants held" and refuses to run, never as empty. var ErrGrantBookUnreadable = errors.New("sandbox: the Windows grant record is unreadable") @@ -70,15 +72,28 @@ type GrantRepair struct { // sub-authorities: it names no account, and no token carries it unless the // launcher puts it there. func newSyntheticSID() string { - var b [16]byte - _, _ = rand.Read(b[:]) - sub := func(i int) uint32 { return binary.BigEndian.Uint32(b[i*4 : i*4+4]) } - return fmt.Sprintf("S-1-5-21-%d-%d-%d-%d", sub(0), sub(1), sub(2), 10000+sub(3)%50000) + for { + var b [16]byte + _, _ = rand.Read(b[:]) + sub := func(i int) uint32 { return binary.BigEndian.Uint32(b[i*4 : i*4+4]) } + sid := fmt.Sprintf("S-1-5-21-%d-%d-%d-%d", sub(0), sub(1), sub(2), 10000+sub(3)%50000) + if !sidOwned(sid) { + return sid + } + } } -// syntheticSID accepts only a SID this package could have made: a record that -// names a real account would grant, and later revoke, that account's access. +// syntheticSID accepts only a SID this package could have made and that names no +// account: a domain user's SID has the same shape, and a record naming one would +// grant, and later revoke, that account's access. func syntheticSID(s string) bool { + return syntheticShape(s) && !sidOwned(s) +} + +// sidOwned is namesAnAccount; tests replace it to stand in for a domain account. +var sidOwned = namesAnAccount + +func syntheticShape(s string) bool { var a, b, c, rid uint32 var tail string n, _ := fmt.Sscanf(s, "S-1-5-21-%d-%d-%d-%d%s", &a, &b, &c, &rid, &tail) @@ -88,7 +103,7 @@ func syntheticSID(s string) bool { // OpenGrantBook loads the record under dir, or starts one with a fresh SID. A // record that is present and unreadable is an error, not a new book. func OpenGrantBook(dir string, ops grantOps) (*GrantBook, error) { - b := &GrantBook{path: filepath.Join(dir, "windows-grants.json"), ops: ops} + b := &GrantBook{path: filepath.Join(dir, grantBookFile), ops: ops} data, err := os.ReadFile(b.path) switch { case errors.Is(err, os.ErrNotExist): diff --git a/internal/safety/sandbox/win_grants_windows.go b/internal/safety/sandbox/win_grants_windows.go index c6a73fd045..adb1389f9a 100644 --- a/internal/safety/sandbox/win_grants_windows.go +++ b/internal/safety/sandbox/win_grants_windows.go @@ -9,9 +9,10 @@ import ( "golang.org/x/sys/windows" ) -// grantMask is FILE_ALL_ACCESS: the synthetic SID is the write bound itself, so -// it carries everything on the roots it is granted and nothing anywhere else. -const grantMask = windows.ACCESS_MASK(0x1F01FF) +// grantMask is read, write, execute and delete without WRITE_DAC and +// WRITE_OWNER: the synthetic SID is the write bound, and it does not let a command +// rewrite the ACLs of the user's own files in a root. +const grantMask = windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | 0x10000 | 0x40) // WindowsGrantOps edits real DACLs. Granting and revoking propagate to the // children of a root, which costs time proportional to the tree (about 0.7 s @@ -20,9 +21,31 @@ func WindowsGrantOps() grantOps { return daclOps{} } type daclOps struct{} -func (daclOps) Grant(root, sid string) error { return editDACL(root, sid, windows.GRANT_ACCESS) } +func (daclOps) Grant(root, sid string) error { + if err := refuseLink(root); err != nil { + return err + } + return editDACL(root, sid, windows.GRANT_ACCESS) +} func (daclOps) Revoke(root, sid string) error { return editDACL(root, sid, windows.REVOKE_ACCESS) } +// refuseLink: a root that is a junction or symbolic link names a tree somewhere +// else, and granting it would put the SID there. +func refuseLink(root string) error { + p, err := windows.UTF16PtrFromString(root) + if err != nil { + return err + } + attrs, err := windows.GetFileAttributes(p) + if err != nil { + return fmt.Errorf("sandbox: %s: %w", root, err) + } + if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("sandbox: %s is a link, not a folder to grant", root) + } + return nil +} + func editDACL(root, sidText string, mode windows.ACCESS_MODE) error { sid, err := windows.StringToSid(sidText) if err != nil { diff --git a/internal/safety/sandbox/win_launch.go b/internal/safety/sandbox/win_launch.go index 63f218ed8e..258d4566a1 100644 --- a/internal/safety/sandbox/win_launch.go +++ b/internal/safety/sandbox/win_launch.go @@ -3,8 +3,37 @@ package sandbox import ( "encoding/base64" "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" ) +// sessionTempPrefix names the per-lease directories the host makes under the +// temp root (internal/state/sessiontemp); a test holds the two spellings equal. +const sessionTempPrefix = "reasonix-session-tmp-" + +// errLaunchRefused marks a launcher request the host did not make. +var errLaunchRefused = errors.New("launch request refused") + +// checkWinLaunch holds a request to what the host intends: every Grant entry is +// a session temp directory directly under the temp root. Who may ask at all is +// decided by the token the launcher runs under (see launcherMayRun), not by +// anything the request says: argv, environment and files are all the caller's own. +func checkWinLaunch(l winLaunch) error { + base, _ := resolveWithLinks(os.TempDir()) + for _, dir := range l.Grant { + real, _ := resolveWithLinks(dir) + // The launcher may already run with TEMP pointing at the lease itself. + inBase := strings.EqualFold(filepath.Dir(real), base) || strings.EqualFold(real, base) + if !inBase || !strings.HasPrefix(filepath.Base(real), sessionTempPrefix) { + return fmt.Errorf("%w: %s is not a session temp directory", errLaunchRefused, dir) + } + } + return nil +} + // winLaunchFlag marks an argv as a request to run the rest of it under the // write-restricted token. It is an argument of whichever host binary imports // this package, so no helper has to be shipped and found. diff --git a/internal/safety/sandbox/win_launch_hardening_windows_test.go b/internal/safety/sandbox/win_launch_hardening_windows_test.go new file mode 100644 index 0000000000..a22032da6f --- /dev/null +++ b/internal/safety/sandbox/win_launch_hardening_windows_test.go @@ -0,0 +1,188 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +// The token the suite runs under decides what these measure: elevated means the +// backup, restore and take-ownership privileges are present to be stripped. +func TestHardeningSuiteSaysWhichTokenItRanUnder(t *testing.T) { + t.Logf("elevated token: %v", windows.GetCurrentProcessToken().IsElevated()) +} + +// Backup semantics bypass the DACL for a token that holds the backup privilege; +// the launcher removes every privilege, so a /B copy cannot land outside a root. +func TestBackupSemanticsCannotWriteOutsideTheRoot(t *testing.T) { + h := newWinHarness(t) + src := filepath.Join(h.ws, "src") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "payload.txt"), []byte("p"), 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(h.out, "copy") + h.runArgv([]string{"robocopy", src, dst, "payload.txt", "/B", "/R:0", "/W:0"}) + if exists(filepath.Join(dst, "payload.txt")) || exists(dst) { + t.Fatalf("a backup-semantics copy wrote outside the root (elevated: %v)", windows.GetCurrentProcessToken().IsElevated()) + } + // A plain copy inside the root lands, so the refusal above is the bound's and + // not a broken command line. + in := filepath.Join(h.ws, "copy") + h.runArgv([]string{"robocopy", src, in, "payload.txt", "/R:0", "/W:0"}) + if !exists(filepath.Join(in, "payload.txt")) { + t.Error("a copy inside the root did not land") + } +} + +// A request the host did not make: a sandboxed command runs the launcher with a +// Grant naming a folder outside the roots. It must fail, and the folder must not +// gain the SID. +func TestASandboxedCommandCannotGrantItselfAFolderThroughTheLauncher(t *testing.T) { + h := newWinHarness(t) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + forged := winLaunchArgv(self, winLaunch{SID: h.book.SID(), Grant: []string{h.out}}, []string{"cmd", "/c", "echo x> " + filepath.Join(h.out, "forged.txt")}) + h.runArgv(forged) + if ok, _ := WindowsGrantOps().Granted(h.out, h.book.SID()); ok { + t.Error("the forged request granted the SID on a folder outside the roots") + } + if exists(filepath.Join(h.out, "forged.txt")) { + t.Error("the forged request ran a command that wrote outside the roots") + } + // The refusal is the token's: inside the sandbox the launcher says why. + out, code := h.runArgv(forged) + if code != 126 || !strings.Contains(out, "restricted process") { + t.Errorf("the launcher in the sandbox answered %d %q, want a refusal for its restricted token", code, out) + } +} + +// What a child creates without an inherited ACL takes the token's default DACL; +// it has to be openable by the child itself. +func TestAChildCanOpenWhatItCreatedUnderTheNarrowedDefaultDACL(t *testing.T) { + h := newWinHarness(t) + script := `$e = New-Object Threading.EventWaitHandle($false,'ManualReset','Local\rxprobe-` + strconv.Itoa(os.Getpid()) + `'); [Threading.EventWaitHandle]::OpenExisting('Local\rxprobe-` + strconv.Itoa(os.Getpid()) + `') | Out-Null; 'opened'` + out, code := h.runArgv([]string{"powershell", "-NoProfile", "-NonInteractive", "-Command", script}) + if code != 0 || !strings.Contains(out, "opened") { + t.Fatalf("the child could not open its own event (exit %d): %s", code, out) + } +} + +// An inheritable handle in the launcher must not reach the child: an inherited +// handle is not checked against the token. +func TestOnlyTheStandardHandlesReachTheChild(t *testing.T) { + h := newWinHarness(t) + f, err := os.Create(filepath.Join(h.out, "held.txt")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := windows.SetHandleInformation(windows.Handle(f.Fd()), windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT); err != nil { + t.Fatal(err) + } + self, _ := os.Executable() + argv, wrapped := CommandArgs(h.spec, []string{self, "-test.run=TestProbeInheritedHandle"}) + if !wrapped { + t.Fatal("the command was not wrapped") + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = append(os.Environ(), "RX_PROBE_HANDLE="+strconv.FormatUint(uint64(f.Fd()), 10)) + cmd.SysProcAttr = &syscall.SysProcAttr{AdditionalInheritedHandles: []syscall.Handle{syscall.Handle(f.Fd())}} + out, _ := cmd.CombinedOutput() + if !strings.Contains(string(out), "handle-absent") { + t.Fatalf("the extra handle reached the child: %s", out) + } +} + +func TestProbeInheritedHandle(t *testing.T) { + v := os.Getenv("RX_PROBE_HANDLE") + if v == "" { + t.Skip("helper for TestOnlyTheStandardHandlesReachTheChild") + } + n, _ := strconv.ParseUint(v, 10, 64) + if _, err := windows.GetFileType(windows.Handle(n)); err != nil || windows.GetLastError() != nil { + os.Stdout.WriteString("handle-absent\n") + return + } + os.Stdout.WriteString("handle-present\n") +} + +// Granting follows the tree of a root but not a link out of it, and a root that +// is itself a link is refused. +func TestGrantsDoNotFollowLinksOutOfARoot(t *testing.T) { + base := t.TempDir() + ws, out := filepath.Join(base, "ws"), filepath.Join(base, "out") + for _, d := range []string{ws, out} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + victim := filepath.Join(out, "victim.txt") + if err := os.WriteFile(victim, []byte("v"), 0o644); err != nil { + t.Fatal(err) + } + junction := filepath.Join(ws, "jn") + if o, err := exec.Command("cmd", "/c", "mklink", "/J", junction, out).CombinedOutput(); err != nil { + t.Skipf("cannot make a junction here: %v %s", err, o) + } + symlink := filepath.Join(ws, "sl") + hasSymlink := os.Symlink(out, symlink) == nil + book, err := OpenGrantBook(filepath.Join(base, "state"), WindowsGrantOps()) + if err != nil { + t.Fatal(err) + } + if err := book.Ensure(ws); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = book.Revoke(ws) }) + for _, p := range []string{out, victim} { + if ok, _ := WindowsGrantOps().Granted(p, book.SID()); ok { + t.Errorf("the grant reached %s through a link", p) + } + } + if !hasSymlink { + t.Log("no symbolic link could be made here; only the junction was measured") + } + if err := book.Ensure(junction); err == nil { + _ = book.Revoke(junction) + t.Error("a root that is a junction was granted") + } +} + +// Services a restricted command can ask to act for it. A task or a service made +// that way runs later with the user's own token, so each one that succeeds is a +// line in the "not bounded" list. The outcomes are pinned so the text cannot drift. +func TestWhichSystemServicesARestrictedCommandCanAskToActForIt(t *testing.T) { + h := newWinHarness(t) + id := "rxprobe" + strconv.Itoa(os.Getpid()) + t.Cleanup(func() { + _ = exec.Command("schtasks", "/delete", "/tn", id, "/f").Run() + _ = exec.Command("sc", "delete", id).Run() + _ = exec.Command("reg", "delete", `HKLM\Software\`+id, "/f").Run() + }) + h.run(`schtasks /create /tn ` + id + ` /tr "cmd /c exit" /sc once /st 23:59 /f`) + taskMade := exec.Command("schtasks", "/query", "/tn", id).Run() == nil + h.run(`sc create ` + id + ` binPath= "cmd /c exit"`) + serviceMade := exec.Command("sc", "query", id).Run() == nil + h.run(`reg add HKLM\Software\` + id + ` /v a /d b /f`) + hklmWritten := exec.Command("reg", "query", `HKLM\Software\`+id).Run() == nil + t.Logf("elevated=%v scheduledTask=%v service=%v HKLM=%v", windows.GetCurrentProcessToken().IsElevated(), taskMade, serviceMade, hklmWritten) + if serviceMade || hklmWritten { + t.Errorf("a restricted command made a service (%v) or wrote HKLM (%v): the guide says neither", serviceMade, hklmWritten) + } + if taskMade { + t.Log("a restricted command can create a scheduled task, which later runs with the user's full token: the setting's description lists it") + } +} diff --git a/internal/safety/sandbox/win_launch_test.go b/internal/safety/sandbox/win_launch_test.go index 26e9586117..14ddffdeec 100644 --- a/internal/safety/sandbox/win_launch_test.go +++ b/internal/safety/sandbox/win_launch_test.go @@ -1,8 +1,13 @@ package sandbox import ( + "errors" + "os" + "path/filepath" "slices" "testing" + + "reasonix/internal/state/sessiontemp" ) func TestALauncherRequestRoundTripsAndOnlyThatRequestIsOne(t *testing.T) { @@ -35,3 +40,70 @@ func TestALauncherRequestNamingARealAccountIsRefused(t *testing.T) { } } } + +func leaseLaunch(t *testing.T) (winLaunch, string) { + t.Helper() + lease, err := os.MkdirTemp("", sessionTempPrefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(lease) }) + return winLaunch{SID: newSyntheticSID(), Grant: []string{lease}}, lease +} + +func TestTheLauncherGrantsOnlySessionTempFolders(t *testing.T) { + l, _ := leaseLaunch(t) + if err := checkWinLaunch(l); err != nil { + t.Fatalf("the host's own request was refused: %v", err) + } + for name, mutate := range map[string]func(*winLaunch){ + "outside the temp": func(x *winLaunch) { x.Grant = []string{t.TempDir()} }, + "wrong name": func(x *winLaunch) { + d, _ := os.MkdirTemp("", "other-") + t.Cleanup(func() { _ = os.RemoveAll(d) }) + x.Grant = []string{d} + }, + "nested under a lease": func(x *winLaunch) { x.Grant = []string{filepath.Join(x.Grant[0], "sub")} }, + "the temp root": func(x *winLaunch) { x.Grant = []string{os.TempDir()} }, + } { + req := l + req.Grant = slices.Clone(l.Grant) + mutate(&req) + if err := checkWinLaunch(req); !errors.Is(err, errLaunchRefused) { + t.Errorf("%s: %v", name, err) + } + } +} + +// A domain user's SID has the shape of a synthetic one; only the account lookup +// tells them apart. +func TestASIDThatNamesAnAccountIsNotSynthetic(t *testing.T) { + const domainUser = "S-1-5-21-1111-2222-3333-12345" + if !syntheticSID(domainUser) { + t.Fatal("setup: the shape alone should pass") + } + old := sidOwned + sidOwned = func(s string) bool { return s == domainUser } + t.Cleanup(func() { sidOwned = old }) + if syntheticSID(domainUser) { + t.Fatal("a SID naming an account was accepted") + } + state := t.TempDir() + rec := `{"version":1,"sid":"` + domainUser + `"}` + if err := os.WriteFile(filepath.Join(state, grantBookFile), []byte(rec), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenGrantBook(state, newFakeACL()); !errors.Is(err, ErrGrantBookUnreadable) { + t.Errorf("a record naming an account opened: %v", err) + } + argv := winLaunchArgv("reasonix", winLaunch{SID: domainUser}, []string{"cmd"}) + if _, _, ok := parseWinLaunch(argv); ok { + t.Error("a launcher request naming an account parsed") + } +} + +func TestTheLauncherAndTheSessionTempManagerSpellThePrefixAlike(t *testing.T) { + if sessionTempPrefix != sessiontemp.Prefix() { + t.Fatalf("launcher prefix %q, manager prefix %q", sessionTempPrefix, sessiontemp.Prefix()) + } +} diff --git a/internal/safety/sandbox/win_launch_windows.go b/internal/safety/sandbox/win_launch_windows.go index 573ae81e79..31401208f1 100644 --- a/internal/safety/sandbox/win_launch_windows.go +++ b/internal/safety/sandbox/win_launch_windows.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "slices" "unsafe" "golang.org/x/sys/windows" @@ -19,6 +20,14 @@ func init() { if !ok { return } + if err := launcherMayRun(); err != nil { + fmt.Fprintln(os.Stderr, "reasonix sandbox:", err) + os.Exit(126) + } + if err := checkWinLaunch(l); err != nil { + fmt.Fprintln(os.Stderr, "reasonix sandbox:", err) + os.Exit(126) + } code, err := runRestricted(l, argv) if err != nil { fmt.Fprintln(os.Stderr, "reasonix sandbox:", err) @@ -27,12 +36,31 @@ func init() { os.Exit(code) } +// launcherMayRun refuses a launcher that already runs under a restricted token. +// The host's own launcher is the one process that edits DACLs for a command; a +// command that starts the binary itself inherits its restricted token, so the +// token, which a command cannot change, tells the two apart and a forged request +// does nothing. +func launcherMayRun() error { + restricted, err := windows.GetCurrentProcessToken().IsRestricted() + if err != nil { + return fmt.Errorf("%w: cannot read this process's token: %w", errLaunchRefused, err) + } + if restricted { + return fmt.Errorf("%w: a restricted process may not start a launcher", errLaunchRefused) + } + return nil +} + // errNoLogonSID marks the session as one that cannot be restricted. var errNoLogonSID = errors.New("no logon SID") var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken") -const writeRestricted = 0x8 +const ( + disableMaxPrivilege = 0x1 + writeRestricted = 0x8 +) // tokenDefaultDACL is TOKEN_DEFAULT_DACL. type tokenDefaultDACL struct{ defaultDACL *windows.ACL } @@ -42,11 +70,11 @@ type sidAndAttributes struct { attributes uint32 } -// restrictedToken is the process token narrowed to WRITE_RESTRICTED with the -// synthetic SID, the logon SID and Everyone as restricting SIDs. A write needs the -// normal access check and one of those SIDs to pass, and only the roots carry the -// synthetic one. Everyone is there because a child with less cannot start. A -// delete or a rename does not consult the restricting SIDs, so it is not bounded. +// restrictedToken makes the child's token WRITE_RESTRICTED with every privilege +// but the traverse check removed (backup and restore bypass the access check), +// restricted to the synthetic SID, the logon SID and Everyone; only the roots +// carry the synthetic one. A delete or rename does not consult the restricting +// SIDs, so it is not bounded. func restrictedToken(sidText string) (windows.Token, error) { var cur windows.Token if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_ALL_ACCESS, &cur); err != nil { @@ -66,20 +94,20 @@ func restrictedToken(sidText string) (windows.Token, error) { return 0, err } system, _ := windows.CreateWellKnownSid(windows.WinLocalSystemSid) - admins, _ := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) user, err := cur.GetTokenUser() if err != nil { return 0, err } list := []sidAndAttributes{{sid: synthetic}, {sid: logon}, {sid: everyone}} var out windows.Token - r, _, e := procCreateRestrictedToken.Call(uintptr(cur), writeRestricted, 0, 0, 0, 0, uintptr(len(list)), uintptr(unsafe.Pointer(&list[0])), uintptr(unsafe.Pointer(&out))) + r, _, e := procCreateRestrictedToken.Call(uintptr(cur), disableMaxPrivilege|writeRestricted, 0, 0, 0, 0, uintptr(len(list)), uintptr(unsafe.Pointer(&list[0])), uintptr(unsafe.Pointer(&out))) if r == 0 { return 0, fmt.Errorf("create restricted token: %w", e) } - // Objects the child creates take this DACL: without the restricting SIDs in - // it the child could not open what it just made. - if err := setDefaultDACL(out, synthetic, logon, everyone, system, admins, user.User.Sid); err != nil { + // Objects the child creates take this DACL. The synthetic SID passes the + // restricted half of the access check and the user the normal half, so the child + // can open what it made; SYSTEM keeps the service side able to read it. + if err := setDefaultDACL(out, synthetic, system, user.User.Sid); err != nil { out.Close() return 0, err } @@ -143,14 +171,19 @@ func runRestricted(l winLaunch, argv []string) (int, error) { } defer windows.CloseHandle(job) - si := windows.StartupInfo{Flags: windows.STARTF_USESTDHANDLES} + si := windows.StartupInfoEx{} + si.Flags = windows.STARTF_USESTDHANDLES si.Cb = uint32(unsafe.Sizeof(si)) + var inherit []windows.Handle for i, id := range []uint32{windows.STD_INPUT_HANDLE, windows.STD_OUTPUT_HANDLE, windows.STD_ERROR_HANDLE} { h, err := windows.GetStdHandle(id) if err != nil || h == 0 || h == windows.InvalidHandle { continue } _ = windows.SetHandleInformation(h, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT) + if !slices.Contains(inherit, h) { + inherit = append(inherit, h) + } switch i { case 0: si.StdInput = h @@ -171,8 +204,23 @@ func runRestricted(l winLaunch, argv []string) (int, error) { return 0, err } } + // Only the standard handles cross into the child: an inherited handle is not + // checked against the restricted token, so everything else stays behind. + flags := uint32(windows.CREATE_SUSPENDED | windows.CREATE_UNICODE_ENVIRONMENT) + if len(inherit) > 0 { + attrs, err := windows.NewProcThreadAttributeList(1) + if err != nil { + return 0, fmt.Errorf("handle list: %w", err) + } + defer attrs.Delete() + if err := attrs.Update(windows.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&inherit[0]), uintptr(len(inherit))*unsafe.Sizeof(inherit[0])); err != nil { + return 0, fmt.Errorf("handle list: %w", err) + } + si.ProcThreadAttributeList = attrs.List() + flags |= windows.EXTENDED_STARTUPINFO_PRESENT + } var pi windows.ProcessInformation - if err := windows.CreateProcessAsUser(tok, nil, cmdline, nil, nil, true, windows.CREATE_SUSPENDED|windows.CREATE_UNICODE_ENVIRONMENT, nil, nil, &si, &pi); err != nil { + if err := windows.CreateProcessAsUser(tok, nil, cmdline, nil, nil, len(inherit) > 0, flags, nil, nil, &si.StartupInfo, &pi); err != nil { return 0, fmt.Errorf("start %s: %w", argv[0], err) } defer windows.CloseHandle(pi.Process) @@ -205,5 +253,12 @@ func killOnCloseJob() (windows.Handle, error) { windows.CloseHandle(job) return 0, fmt.Errorf("limit job: %w", err) } + // The child cannot reach windows, clipboard, atoms or display settings of the + // user's session that live outside the job. + ui := windows.JOBOBJECT_BASIC_UI_RESTRICTIONS{UIRestrictionsClass: windows.JOB_OBJECT_UILIMIT_HANDLES | windows.JOB_OBJECT_UILIMIT_READCLIPBOARD | windows.JOB_OBJECT_UILIMIT_WRITECLIPBOARD | windows.JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS | windows.JOB_OBJECT_UILIMIT_DISPLAYSETTINGS | windows.JOB_OBJECT_UILIMIT_GLOBALATOMS | windows.JOB_OBJECT_UILIMIT_DESKTOP | windows.JOB_OBJECT_UILIMIT_EXITWINDOWS} + if _, err := windows.SetInformationJobObject(job, windows.JobObjectBasicUIRestrictions, uintptr(unsafe.Pointer(&ui)), uint32(unsafe.Sizeof(ui))); err != nil { + windows.CloseHandle(job) + return 0, fmt.Errorf("limit job UI: %w", err) + } return job, nil } diff --git a/internal/safety/sandbox/win_launch_windows_test.go b/internal/safety/sandbox/win_launch_windows_test.go index 1d6e0777ba..4631585744 100644 --- a/internal/safety/sandbox/win_launch_windows_test.go +++ b/internal/safety/sandbox/win_launch_windows_test.go @@ -17,6 +17,7 @@ type winHarness struct { t *testing.T book *GrantBook ws, out, temp string + state string spec Spec } @@ -27,8 +28,7 @@ func newWinHarness(t *testing.T) *winHarness { t.Helper() base := t.TempDir() h := &winHarness{t: t, ws: filepath.Join(base, "ws"), out: filepath.Join(base, "outside")} - h.temp = filepath.Join(h.ws, "tmp") - for _, d := range []string{h.ws, h.out, h.temp} { + for _, d := range []string{h.ws, h.out} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } @@ -37,7 +37,16 @@ func newWinHarness(t *testing.T) *winHarness { if err := os.WriteFile(filepath.Join(h.ws, "before.txt"), []byte("before"), 0o644); err != nil { t.Fatal(err) } - book, err := OpenGrantBook(filepath.Join(base, "state"), WindowsGrantOps()) + // A session temp directory as the host makes one: new, under the temp root, + // outside every write root, granted by the launcher when it starts. + temp, err := os.MkdirTemp("", sessionTempPrefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(temp) }) + h.temp = temp + h.state = filepath.Join(base, "state") + book, err := OpenGrantBook(h.state, WindowsGrantOps()) if err != nil { t.Fatal(err) } diff --git a/internal/safety/sandbox/win_prepare_windows.go b/internal/safety/sandbox/win_prepare_windows.go index 2b6a31dba7..5896c901f8 100644 --- a/internal/safety/sandbox/win_prepare_windows.go +++ b/internal/safety/sandbox/win_prepare_windows.go @@ -70,6 +70,9 @@ func GrantSpec(spec Spec, stateDir string) Spec { // probeToken makes and drops a restricted token, to learn at session start what // a launch would otherwise learn when it fails. func probeToken(sid string) error { + if err := launcherMayRun(); err != nil { + return fmt.Errorf("%w: %w", ErrWinLauncher, err) + } tok, err := restrictedToken(sid) if err != nil { if errors.Is(err, errNoLogonSID) { diff --git a/internal/safety/sandbox/win_sidowner_other.go b/internal/safety/sandbox/win_sidowner_other.go new file mode 100644 index 0000000000..f5f9064c01 --- /dev/null +++ b/internal/safety/sandbox/win_sidowner_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package sandbox + +// namesAnAccount has no account database to ask off Windows, where no SID is +// ever granted; the shape test alone applies. +func namesAnAccount(string) bool { return false } diff --git a/internal/safety/sandbox/win_sidowner_windows.go b/internal/safety/sandbox/win_sidowner_windows.go new file mode 100644 index 0000000000..81355ad1ba --- /dev/null +++ b/internal/safety/sandbox/win_sidowner_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package sandbox + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// namesAnAccount reports whether the SID resolves to an account on this machine +// or its domain. Only "no mapping" counts as unowned: any other failure leaves +// the question open, and an open question is answered as owned. +func namesAnAccount(sidText string) bool { + sid, err := windows.StringToSid(sidText) + if err != nil { + return true + } + _, _, _, err = sid.LookupAccount("") + return !errors.Is(err, windows.ERROR_NONE_MAPPED) +} diff --git a/release-notes/studio/unreleased/13109-windows-sandbox.md b/release-notes/studio/unreleased/13109-windows-sandbox.md index 3de9c45935..a023cc56d6 100644 --- a/release-notes/studio/unreleased/13109-windows-sandbox.md +++ b/release-notes/studio/unreleased/13109-windows-sandbox.md @@ -1,2 +1,2 @@ -- 新增:Windows 命令沙箱(设置 > 沙箱,默认关):命令不能在授权文件夹外创建或改写文件;删除、重命名、读取不受限。见 #13109。 -- Added: a Windows command sandbox (Settings > Sandbox, off by default): no creating or overwriting files outside granted folders; delete, rename, read are not bounded. See #13109. +- 新增:Windows 命令沙箱(设置 > 沙箱,默认关):不能在授权文件夹外创建或改写文件;删除、重命名、读取、联网、ssh-agent/docker 管道不受限。见 #13109。 +- Added: a Windows command sandbox (Settings > Sandbox, off): no creating or overwriting files outside granted folders; delete, rename, read, network, ssh-agent/docker pipes are not bounded. #13109