Skip to content

Windows write jail is still bypassable on profiles that set denyRead #869

Description

@Vasanthdev2004

#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).

Activity

  1. Vasanthdev2004 commented on Aug 9, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    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. CreateRestrictedToken works 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, BATCH and the rest) pinned on both token shapes.

    The trade was invisible. Setting denyRead gives 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 main rather than taking my own write-up on trust: the non-WRITE_RESTRICTED token does still carry S-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 denyRead should be rejected outright on this tier, since that is the call #640 is making.

  2. added
    issue-approvedReviewed and approved by the core team; community PRs may implement this issue.
    on Aug 27, 2026
  3. Vasanthdev2004 commented on Sep 5, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    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. TestWindowsUnelevatedRealSandboxSmoke dies at launch with 0xc0000022, because a fully restricted token applies its restricted-SID check to reads as well as writes, so the child cannot open its own executable. Serving denyRead properly 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 denyRead does 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 denyRead on 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 permissionProfileReadRoots seeds the bare filesystem root first. A denyRead profile with a narrowed read list carried no volume root, sailed through, and elevated setup would still have put an inheritable read ACE on C:\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 from ReadRoots

    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 denyRead profile 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 denyRead either 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.

  4. Vasanthdev2004 commented on Sep 12, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    Closing 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
    TestRunWindowsSandboxSetupRejectsDenyReadUpfront
    

    So 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingissue-approvedReviewed and approved by the core team; community PRs may implement this issue.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions