Repository navigation
Windows write jail is still bypassable on profiles that set denyRead #869
Description
Activity
Opened #886 for part of this. Leaving the issue open, since it does not close it.
Two things that were achievable now:
The #865 fix had no CI protection. The only test covering the World SID exclusion sits behind
ZERO_SANDBOX_REAL_SMOKE=1, which no workflow sets, so anything restoring the unconditional World SID went green. #640's branch conflicts on that exact hunk, so this was a live risk rather than a theoretical one.CreateRestrictedTokenworks unelevated against the caller's own token, so the invariant is now a plain unit test that reads the restricted-SID list, plus the audit list from this issue (Users,Authenticated Users,INTERACTIVE,BATCHand the rest) pinned on both token shapes.The trade was invisible. Setting
denyReadgives up the write jail, and nothing said so. The plan now warns, scoped to the Windows restricted-token backend and keyed off the same field the runner reads, so the default posture stays quiet.I also confirmed the gap is still real on current
mainrather than taking my own write-up on trust: the non-WRITE_RESTRICTEDtoken does still carryS-1-1-0. There is a test recording that, which skips with a note if it ever stops being true, so whoever closes this gets pointed at it.Still open, unchanged: the read-side grant that is not a universal group. AppContainer or LPAC with a capability SID, or the per-workspace principals from #808. #662 still must not land first.
I did not touch whether
denyReadshould be rejected outright on this tier, since that is the call #640 is making.- added 7 commits that reference this issue
on Aug 20, 2026 - addedissue-approvedReviewed and approved by the core team; community PRs may implement this issue.Reviewed and approved by the core team; community PRs may implement this issue.
on Aug 27, 2026 - added 4 commits that reference this issue
on Aug 29, 2026 Status, since this has been quiet a month and it is assigned to me.
Where it actually stands
The answer this ends up with is refusal, not a fix to the SID list.
I tried the direct route in #1005: take Everyone out of the restricted-SID list, which is what makes the write jail hold. It does not work.
TestWindowsUnelevatedRealSandboxSmokedies at launch with0xc0000022, because a fully restricted token applies its restricted-SID check to reads as well as writes, so the child cannot open its own executable. ServingdenyReadproperly would need the read capability granted on directories Zero does not own, starting at the volume root. That grant is inheritable, so applying it rewrites permissions across unrelated system, application and user trees, and it still would not cover a second volume. That is a worse outcome than the bypass.So #808 refuses instead. A profile that configures
denyReaddoes not run the Windows sandbox at all, and the diagnostic says why and points here:this sandbox profile configures denyRead, which selects a fully restricted token, and that token applies its restricted-SID check to reads as well as writes. [...] denyRead is therefore not available on Windows yet (see #869). Remove denyRead from the sandbox configuration to run on a write-restricted token, which keeps the workspace write jail intact
That closes the hole in the sense that matters: the vulnerable configuration can no longer be served insecurely. It does not deliver
denyReadon Windows, and I would rather say that plainly than call it fixed.What I checked before writing this
The refusal is keyed on the read grant existing at all, not on the volume root. That distinction is load-bearing: the first version checked for a volume root, which happened to cover production only because
permissionProfileReadRootsseeds the bare filesystem root first. AdenyReadprofile with a narrowed read list carried no volume root, sailed through, and elevated setup would still have put an inheritable read ACE onC:\Windows. There is a test pinning exactly that shape now, and a control proving a write-jail-only profile is still accepted, since refusing that would take the Windows sandbox away from everyone.I also walked every production path that applies an ACL plan, because a refusal on one tier and not its sibling is the failure mode I keep hitting. Four appliers:
- the unelevated command runner, refused at
windows_command_runner_windows.go - elevated setup, refused at
windows_setup_windows.go - the principal revoke plan, which carries no read grant and needs no refusal
applyWindowsPrincipalACLs, which does build read grants fromReadRoots
That last one has no refusal of its own, and its internal guard is the weaker volume-root check I replaced elsewhere. It is gated anyway: elevated setup refuses at line 104 and only reaches principal setup at line 119, in the same function, so a
denyReadprofile never gets there. I went looking for that as a hole and it is not one, but it is worth writing down that the protection is positional rather than local.#1005
It is a draft whose approach is disproven, and I will close it rather than leave it looking like work in progress. Nothing in it is worth keeping except the measurement, which is recorded above.
What is left
Leaving this open. It is not resolved until
denyReadeither works on Windows or is deliberately dropped as a supported option there, and that is a design call rather than a bug fix. The refusal is the honest interim state, not the destination.- the unelevated command runner, refused at
- added 6 commits that reference this issue
on Sep 7, 2026 Vasanthdev2004 commented
on Sep 12, 2026 CollaboratorAuthorMore actionsClosing this. #1006 landed on main at 0207f2b and settles it the way my last status here said it would end: refusal, not a fix to the SID list.
On main, any non-empty DenyRead on a Windows profile is now rejected before setup or token creation. windowsDenyReadRestrictedTokenUnsupportedProfile is called from the setup path (windows_setup.go and windows_setup_windows.go), the runner (windows_runner.go) and the command runner, so both restricted-token tiers refuse it and no path can provision or launch with it. The user-facing error names the reason and says to remove DenyRead from the configuration.
I ran the two tests #1006 added for this on current main (6937a30) rather than take the description for it, and both pass here on Windows:
TestSandboxManagerRejectsWindowsDenyReadOnBothRestrictedTokenTiers TestRunWindowsSandboxSetupRejectsDenyReadUpfrontSo the profiles this issue is about can no longer run under the Windows sandbox at all, which removes the bypass by removing the configuration. The warning that #886 carries for this case (windowsDenyReadWarnings) is dead code now and I will drop it from that branch.
#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).