fix(sandbox): drop Authenticated Users from both restricting lists — close the C:\\-root escape, CIM unavailable in every confined mode

workspace-write now runs [logon SID, Everyone, orphan]: the two lists differ only by the orphan, and the keep-alive group (logon SID + Everyone) is the single shared invariant. The WMI namespace security check fails in BOTH modes (0x80041003), so CIM/Get-ComputerInfo are unavailable everywhere — the price of closing the C:\\-root tree-creation escape (AU:(AD) + AU:(OI)(CI)(IO)(M)) in workspace-write too. The unused WinLocalSid/WinInteractiveSid/WinAuthenticatedUserSid constants and their ABI-probe prints are removed; the enforcement 'full' claim now stands on a closed NTFS surface. New regression: a C:\\Users\\Public subdirectory write is denied under BOTH modes (the ambient-writable blind spot the review flagged — INTERACTIVE is absent from both lists). FAT-class (non-ACL) targets outside the granted roots remain writable (no security descriptors to intersect) — documented as a legacy residue, warn-only, not engineered around. Docs/design note/PR body updated in both languages (list I/J terminology gone everywhere).
This commit is contained in:
Huanqi Cao
2026-08-08 21:55:02 +08:00
parent 9d10a1888b
commit 441927c526
14 changed files with 104 additions and 81 deletions

View File

@@ -195,14 +195,9 @@ export class AclSandbox {
this.sidAllocations.push(logonSid)
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
this.sidAllocations.push(worldSid)
const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid)
this.sidAllocations.push(authUserSid)
const restricted = createRestrictedToken(
api, currentToken, logonSid, writeSidPtr,
{
world: worldSid,
authUser: authUserSid,
},
{ world: worldSid },
this.mode,
)
this.token = restricted

View File

@@ -15,9 +15,12 @@
* - workspace-write: the workspace and temp directories carry the orphan-SID
* Write grant; every other write is denied by the token intersection.
* - read-only: STRICT zero grants — no directory is writable, not even the
* NUL device (`> $null` fails with access denied); the token's restricting
* list also drops Authenticated Users (CIM unavailable — documented in
* README).
* NUL device (`> $null` fails with access denied); the restricting list
* carries no orphan SID, so a standing grant ACE from an earlier
* workspace-write period stays inert. BOTH modes drop Authenticated Users
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
* Public tree writes are denied); the two lists share the keep-alive group
* (logon SID, EVERYONE) and differ only by the orphan.
*
* `--write-sid`: the seam's per-session grant contract — the CALLER has
* already materialized the orphan-SID ACEs (once per session, server

View File

@@ -101,44 +101,41 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer {
return buffer
}
/** The well-known SIDs packed into every restricted token's restricting list. */
/** The well-known SID packed into every restricted token's restricting list. */
export interface RestrictingSidSet {
world: NativePtr
authUser: NativePtr
}
/**
* Create the write-restricted token with the mode-selected restricting list
* (dual lists verified on Win11 26200, see the POC-worktree restrict-variant
* harness):
* - list I (read-only): [logon SID, EVERYONE]
* - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan]
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
* - read-only: [logon SID, EVERYONE]
* - workspace-write: [logon SID, EVERYONE, orphan]
*
* The logon SID and EVERYONE are shared: they keep the early startup chain
* (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh
* crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in
* list J ONLY because the CIM path's WMI namespace security check requires it
* (0x80041003 otherwise) — read-only drops it for a zero ambient-write
* surface (it closes the host's C:\-root tree-creation escape, where
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM
* unavailability; documented in README. List I also carries NO orphan: a
* standing grant ACE from an earlier workspace-write period (a
* `/permission` mode downgrade, or a crash-resumed session) must stay INERT
* under read-only — the WRITE_RESTRICTED pass-2 check grants only what the
* restricting list carries, so omitting the orphan keeps read-only strictly
* zero-grant even with stale ACEs standing, while the unrevoked ACE keeps
* the re-upgrade free (the seam's grant map hits it — no re-propagation).
* INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree
* grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1
* (console logon) is intentionally absent: see win32-abi.ts for the
* verified failure modes. FAILS CLOSED: any failure throws — never spawn
* unrestricted.
* The logon SID + EVERYONE keep-alive group is shared by both modes: early
* DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
* pwsh crashes 0xE0434352) fails without them. The orphan SID joins ONLY
* workspace-write — read-only carries no orphan, so a standing grant ACE
* from an earlier workspace-write period (a `/permission` mode downgrade, or
* a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
* pass-2 check grants only what the restricting list carries, keeping
* read-only strictly zero-grant even with stale ACEs standing, while the
* unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no
* re-propagation). Authenticated Users is absent from BOTH lists: the WMI
* namespace security check fails (0x80041003), so CIM is unavailable in
* every confined mode, and the C:\-root tree-creation escape (standing
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
* README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's
* Public tree grants write to INTERACTIVE, so removing it closes that
* escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts
* for the verified failure modes. FAILS CLOSED: any failure throws — never
* spawn unrestricted.
* @param api - the binding table.
* @param currentToken - the process token to restrict.
* @param logonSid - the copied logon session SID.
* @param writeSid - the orphan SID forming the write allowlist (list J only).
* @param writeSid - the orphan SID forming the write allowlist (workspace-write only).
* @param known - the well-known SIDs entering the restricting list.
* @param mode - selects the restricting list (I for read-only, J for workspace-write).
* @param mode - selects the restricting list (workspace-write adds the orphan).
* @returns the restricted token handle.
*/
export function createRestrictedToken(
@@ -151,7 +148,7 @@ export function createRestrictedToken(
): NativePtr {
const restrictingSids = buildRestrictingSids(mode === 'read-only'
? [logonSid, known.world]
: [logonSid, known.world, known.authUser, writeSid])
: [logonSid, known.world, writeSid])
const tokenSlot = allocPtrSlot()
const created = api.createRestrictedToken(
currentToken,

View File

@@ -79,20 +79,8 @@ export const LUA_TOKEN = 0x4
export const WRITE_RESTRICTED = 0x8
// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407)
/** WinWorldSid: S-1-1-0 (Everyone). */
/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */
export const WinWorldSid = 1
/**
* WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init
* (it sits in every restricted token's restricting list). The
* CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this
* module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to
* this type.
*/
export const WinLocalSid = 2
/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */
export const WinInteractiveSid = 11
/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */
export const WinAuthenticatedUserSid = 17
// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2)
/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */