#865 removed the World SID (S-1-1-0, Everyone) from the WRITE_RESTRICTED token, which is the shape every Windows user gets by default. It could not remove it from the other shape, and that one is still bypassable.
A profile with a non-empty denyRead builds its restricted token without WRITE_RESTRICTED, and Everyone stays on the restricted-SID list. A restricted SID is a key to every object whose DACL names it, and every principal carries Everyone, so the restricted-SID check passes for free on any path whose DACL grants Everyone write. The workspace write jail then falls back to the ordinary user's own permissions, which is the exact boundary this token exists to be stricter than.
No privilege, no symlink and no race is needed. An Everyone-writable directory is enough, and share roots opened with Everyone:F plus loose installer ACLs supply them.
Site: createWindowsRestrictedTokenFromBase in internal/sandbox/windows_token_windows.go.
Who is affected. Only Windows users who set denyRead themselves. Zero never populates it on Windows: credentialDenyReadPaths returns nil there (internal/sandbox/profile.go:166), so the default posture runs the token #865 fixed.
Why #865 could not cover this shape. Without WRITE_RESTRICTED the restricted-SID check applies to reads as well as writes, and default Windows DACLs grant BUILTIN\Users rather than anything on this list. A token without Everyone cannot open cmd.exe: the process dies at launch with STATUS_ACCESS_DENIED (0xC0000022) before it runs anything. That was tried first and it breaks the unelevated sandbox outright. So #865 fixed the shape where nothing depends on Everyone and left this one documented in the code.
What closing it needs. A read-side grant that is not a universal group. AppContainer or LPAC with an explicit capability SID is the obvious candidate. A narrower interim: keep Everyone off the restricted-SID list and grant read through the per-sandbox capability SID on the executable search paths, trading machine setup for enforcement.
Same class, worth auditing at the same time. Authenticated Users, Users, INTERACTIVE and BATCH each produce this bypass if they reach that list. #640 adds Users and Authenticated Users behind a broadenReadSIDs parameter that its own doc comment says callers must always pass false, plus a guard refusing to combine it with writeRestricted. That is the right posture, and it needs to stay that way.
Do not close it by widening the default. #662 asks for the credential deny-read defaults to apply on Windows. That makes denyRead non-empty for every Windows user and moves all of them onto this token shape, turning a config almost nobody hits into the default one. #662 needs this fixed first, not the other way round.
Related: #865 (fix for the default shape), #612 (why DenyRead uses the non-WRITE_RESTRICTED token), #640 (proposes rejecting denyRead on these tiers outright, which is a different trade for the same problem).
#865 removed the World SID (
S-1-1-0, Everyone) from theWRITE_RESTRICTEDtoken, which is the shape every Windows user gets by default. It could not remove it from the other shape, and that one is still bypassable.A profile with a non-empty
denyReadbuilds its restricted token withoutWRITE_RESTRICTED, and Everyone stays on the restricted-SID list. A restricted SID is a key to every object whose DACL names it, and every principal carries Everyone, so the restricted-SID check passes for free on any path whose DACL grants Everyone write. The workspace write jail then falls back to the ordinary user's own permissions, which is the exact boundary this token exists to be stricter than.No privilege, no symlink and no race is needed. An Everyone-writable directory is enough, and share roots opened with
Everyone:Fplus loose installer ACLs supply them.Site:
createWindowsRestrictedTokenFromBaseininternal/sandbox/windows_token_windows.go.Who is affected. Only Windows users who set
denyReadthemselves. Zero never populates it on Windows:credentialDenyReadPathsreturns nil there (internal/sandbox/profile.go:166), so the default posture runs the token #865 fixed.Why #865 could not cover this shape. Without
WRITE_RESTRICTEDthe restricted-SID check applies to reads as well as writes, and default Windows DACLs grantBUILTIN\Usersrather than anything on this list. A token without Everyone cannot open cmd.exe: the process dies at launch withSTATUS_ACCESS_DENIED(0xC0000022) before it runs anything. That was tried first and it breaks the unelevated sandbox outright. So #865 fixed the shape where nothing depends on Everyone and left this one documented in the code.What closing it needs. A read-side grant that is not a universal group. AppContainer or LPAC with an explicit capability SID is the obvious candidate. A narrower interim: keep Everyone off the restricted-SID list and grant read through the per-sandbox capability SID on the executable search paths, trading machine setup for enforcement.
Same class, worth auditing at the same time.
Authenticated Users,Users,INTERACTIVEandBATCHeach produce this bypass if they reach that list. #640 addsUsersandAuthenticated Usersbehind abroadenReadSIDsparameter that its own doc comment says callers must always pass false, plus a guard refusing to combine it withwriteRestricted. That is the right posture, and it needs to stay that way.Do not close it by widening the default. #662 asks for the credential deny-read defaults to apply on Windows. That makes
denyReadnon-empty for every Windows user and moves all of them onto this token shape, turning a config almost nobody hits into the default one. #662 needs this fixed first, not the other way round.Related: #865 (fix for the default shape), #612 (why DenyRead uses the non-
WRITE_RESTRICTEDtoken), #640 (proposes rejectingdenyReadon these tiers outright, which is a different trade for the same problem).