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:
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user