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

@@ -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)