Skip to content

Commit a5d2e32

Browse files
authored
fix(sandbox): use WRITE_RESTRICTED token when no DenyRead paths are configured (#658)
Removing the WRITE_RESTRICTED flag in #612 made the restricted-SID check apply to reads as well as writes. Default Windows DACLs grant BUILTIN\Users rather than any SID in the token's restricted list (random capability SIDs, logon SID, Everyone), so the sandboxed process could no longer open any executable or non-KnownDlls DLL: every spawned command failed with exit 1 and no output, including cmd builtins' children (gh, git, where.exe). The existing TestWindowsRestrictedTokenNestedPipeCapture smoke test reproduces the regression. Restore WRITE_RESTRICTED, but only when the permission profile has no DenyRead paths. The flag makes the kernel skip restricted-SID deny ACEs for reads, which is exactly the DenyRead bypass #612 fixed, so profiles that configure DenyRead keep the fully restricted token and trade spawn capability for read-deny enforcement. DenyRead is empty by default, so the common case regains a working sandbox while #612's guarantee holds for the profiles that rely on it.
1 parent 5c4815a commit a5d2e32

2 files changed

Lines changed: 29 additions & 5 deletions

File tree

‎internal/sandbox/windows_command_runner_windows.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
6969
// this has no in-token fix; preflight blocking and output hints live in
7070
// internal/tools/shell_runtime.go.
7171
tokenSIDs := windowsRuntimeTokenSIDs(capabilitySIDs, offlineSID, config.PermissionProfile.Network.Mode)
72-
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs)
72+
// A WRITE_RESTRICTED token keeps reads unrestricted so sandboxed commands
73+
// can actually launch executables; it is only unsafe when DenyRead paths
74+
// are configured, because the kernel skips restricted-SID deny ACEs for
75+
// reads under that flag (#612). Profiles with DenyRead keep the fully
76+
// restricted token, trading spawn capability for read-deny enforcement.
77+
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
78+
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
7379
if err != nil {
7480
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
7581
return 1

‎internal/sandbox/windows_token_windows.go‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func (sid windowsLocalSID) close() {
4848
}
4949
}
5050

51-
func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string) (windows.Token, error) {
51+
func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) {
5252
if len(capabilitySIDStrings) == 0 {
5353
return 0, errors.New("windows restricted token requires at least one capability SID")
5454
}
@@ -80,10 +80,10 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string
8080
return 0, fmt.Errorf("open process token: %w", err)
8181
}
8282
defer base.Close()
83-
return createWindowsRestrictedTokenFromBase(base, capabilitySIDs)
83+
return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted)
8484
}
8585

86-
func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID) (windows.Token, error) {
86+
func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted bool) (windows.Token, error) {
8787
logonSID, err := copyWindowsLogonSID(base)
8888
if err != nil {
8989
return 0, err
@@ -102,10 +102,28 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
102102
windows.SIDAndAttributes{Sid: worldSID},
103103
)
104104

105+
// WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses:
106+
// reads use only the normal token identity, so the sandboxed process can
107+
// open executables, DLLs, and per-user config the user can read, while
108+
// writes still require a restricted-SID match (the workspace write-jail).
109+
// Without it the restricted-SID check applies to READS too, and because
110+
// default Windows DACLs grant BUILTIN\Users rather than any SID in the
111+
// restricted list, the token cannot open ANY executable outside the
112+
// ACL-granted write roots — every spawned command dies silently.
113+
//
114+
// The flag also makes the kernel skip restricted-SID deny ACEs for reads,
115+
// which is the DenyRead bypass fixed in #612 — so it is only safe when the
116+
// profile has no DenyRead paths. The caller passes writeRestricted=false
117+
// whenever DenyRead is configured, keeping #612's enforcement for exactly
118+
// the profiles that need it.
119+
flags := uint32(windowsDisableMaxPrivilege | windowsLUAToken)
120+
if writeRestricted {
121+
flags |= windowsWriteRestricted
122+
}
105123
var restricted windows.Token
106124
result, _, callErr := procCreateRestrictedToken.Call(
107125
uintptr(base),
108-
uintptr(windowsDisableMaxPrivilege|windowsLUAToken),
126+
uintptr(flags),
109127
0,
110128
0,
111129
0,

0 commit comments

Comments
 (0)