fix(sandbox): extend the restricted token's default DACL with a write-SID ACE

New objects created without an explicit security descriptor take
their DACL from the token's default DACL, which CreateRestrictedToken
builds from the user's ambient SIDs — none of them a restricting SID.
Confined children therefore failed the write pass-2 check when
creating anonymous pipes (CreatePipe: ERROR_ACCESS_DENIED, surfaced
as Node EPERM), breaking PowerShell pipelines and other CreatePipe
consumers. Merge a full-access write-SID ACE (Everyone under
read-only) into the token default DACL at init via
SetTokenInformation(TokenDefaultDacl).

Named pipes are EXEMPT: their default security descriptor is the
kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone
read-only), which no token change influences, so libuv's piped stdio
capture stays denied for confined grandchildren — the POC-documented
boundary, now pinned by the runner suite (inherit/ignore OK, pipe
DENIED) and documented in the README pair. The NUL paragraph is
corrected to the measured matrix (Everyone has 0x1201BF on the
device: cmd/node writes land; Set-Content fails at the PS layer).
This commit is contained in:
Huanqi Cao
2026-08-09 12:34:48 +08:00
parent bb50783002
commit 01a3b454f6
11 changed files with 120 additions and 11 deletions

View File

@@ -76,6 +76,7 @@ export interface Win32Bindings {
copySid(length: number, destination: NativePtr, source: NativePtr): number
// ---- token information ---------------------------------------------------
getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number
// ---- restricted token ----------------------------------------------------
createRestrictedToken(
existing: NativePtr, flags: number,
@@ -392,6 +393,7 @@ function bindings(): Win32Bindings {
getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]),
copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]),
getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]),
setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']),
createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]),
setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]),
setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]),

View File

@@ -47,7 +47,7 @@ import { Win32Error } from './errors.ts'
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
import type { NativePtr, Win32Bindings } from './ffi.ts'
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts'
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
import * as abi from './win32-abi.ts'
export { quoteArg } from './spawn.ts'
@@ -61,8 +61,9 @@ export interface AclSandboxOptions {
writableDirs: readonly string[]
/**
* Temp directory to also grant; defaults to GetTempPathW() at init time.
* Pass null for read-only confinement: NO temp grant (strict zero write
* allowance — not even the NUL device is writable, see README).
* Pass null for read-only confinement: NO temp grant (strict zero grant on
* the filesystem; the NUL device stays ambient-writable via Everyone — see
* README).
*/
tempDir?: string | null
/**
@@ -232,6 +233,16 @@ export class AclSandbox {
{ world: worldSid },
this.mode,
)
// The restricted token's default DACL still names only the user's
// ambient SIDs — none of the restricting SIDs. Every NEW object the
// confined process creates (anonymous stdio pipes, sync objects) takes
// its DACL from that default, so the write pass-2 check would deny
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
// piped-stdio grandchild spawn. Merge a full-access ACE for a
// restricting SID (the write SID under workspace-write, Everyone under
// read-only): new-object creation stays gated by the parent object's
// DACL, while the new object's own DACL passes pass-2.
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
this.token = restricted
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
this.api = api

View File

@@ -9,6 +9,7 @@
import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts'
import type { NativePtr, Win32Bindings } from './ffi.ts'
import { buildExplicitAccess } from './acl.ts'
import * as abi from './win32-abi.ts'
/**
@@ -92,6 +93,57 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
return sid
}
/**
* Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL
* — the DACL every NEW object the token holder creates (without an explicit
* security descriptor) takes. The restricted token inherits the user's
* default DACL verbatim, which names no restricting SID: a new anonymous pipe
* (child stdio) therefore fails the write pass-2 check at creation
* (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
* piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
* write SID under workspace-write, Everyone under read-only), so each new
* object's own DACL passes pass-2 while object creation itself stays gated by
* the parent container's DACL (files outside the granted trees remain
* uncreatable). Fails closed: any Win32 failure throws before the spawn.
* @param api - the binding table.
* @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT).
* @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
*/
export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void {
const neededSlot = allocUint32()
api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
const needed = decodeUint32(neededSlot)
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query')
const buffer = Buffer.alloc(needed)
if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) {
throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl')
}
const currentDacl = decodePtrAt(buffer, 0)
if (currentDacl === null) {
throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend')
}
const newDaclSlot = allocPtrSlot()
const result = api.setEntriesInAclW(
1,
buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS),
currentDacl,
newDaclSlot,
)
if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge')
const newDacl = decodePtr(newDaclSlot)
if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL')
// TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the
// pointer; SetTokenInformation copies the ACL before returning.
const info = Buffer.alloc(8)
info.writeBigUInt64LE(newDacl, 0)
if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) {
const win32Code = api.getLastError()
api.localFree(newDacl)
throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl')
}
api.localFree(newDacl)
}
/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
function buildRestrictingSids(sids: readonly NativePtr[]): Buffer {
const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length)

View File

@@ -70,6 +70,15 @@ export const FILE_DELETE_CHILD = 0x0040
*/
export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156
/**
* FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE
* | 0x1FF): full file-object access. The mask of the ACE merged into the
* restricted token's DEFAULT DACL — the token holder must keep full access to
* every NEW object it creates (pipes included), and the ACE must name a
* restricting SID so the write pass-2 check passes at creation.
*/
export const FILE_ALL_ACCESS = 0x1F01FF
// CreateRestrictedToken flags (winnt.h lines ~4284)
/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */
export const DISABLE_MAX_PRIVILEGE = 0x1
@@ -85,6 +94,8 @@ export const WinWorldSid = 1
// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2)
/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */
export const TokenGroups = 2
/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */
export const TokenDefaultDacl = 6
// SECURITY_INFORMATION (winnt.h line ~4293)
/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */