223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
/**
|
|
* Restricted-token construction: open the current process token, extract its
|
|
* logon SID, build the well-known SIDs, and call CreateRestrictedToken with
|
|
* the POC's restricting-SID allowlist. Every API call is checked; any failure
|
|
* throws with the API name and the exact Win32 code — the original POC ignored
|
|
* all of these and silently ran children with the FULL, unrestricted token.
|
|
* @module @deepseek-ai/dsh-sandbox-windows-acl/token
|
|
*/
|
|
|
|
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'
|
|
|
|
/**
|
|
* Open the current process's access token with the rights
|
|
* CreateRestrictedToken requires (the POC's OpenProcessToken call; the token
|
|
* handle is obtained through a real OpenProcess handle because the
|
|
* GetCurrentProcess() pseudo-handle is not addressable through koffi).
|
|
* @param api - the binding table.
|
|
* @returns the opened token handle.
|
|
*/
|
|
export function openCurrentProcessToken(api: Win32Bindings): NativePtr {
|
|
const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid)
|
|
if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`)
|
|
|
|
const tokenSlot = allocPtrSlot()
|
|
const opened = api.openProcessToken(
|
|
processHandle,
|
|
abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY,
|
|
tokenSlot,
|
|
)
|
|
if (opened === 0) {
|
|
const win32Code = api.getLastError()
|
|
api.closeHandle(processHandle) // best-effort on the error path
|
|
throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`)
|
|
}
|
|
if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle')
|
|
const token = decodePtr(tokenSlot)
|
|
if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle')
|
|
return token
|
|
}
|
|
|
|
/**
|
|
* Find and copy the token's logon session SID (S-1-5-5-x-y, attribute
|
|
* SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and
|
|
* other per-logon objects; the POC extracts it the same way.
|
|
* @param api - the binding table.
|
|
* @param token - the token whose groups are scanned.
|
|
* @returns a copied logon SID (thrown when the token carries none).
|
|
*/
|
|
export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr {
|
|
const neededSlot = allocUint32()
|
|
api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
|
|
const needed = decodeUint32(neededSlot)
|
|
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query')
|
|
if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`)
|
|
|
|
const groups = Buffer.alloc(needed)
|
|
if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) {
|
|
throwLastError(api, 'GetTokenInformation', 'TokenGroups')
|
|
}
|
|
const groupCount = groups.readUInt32LE(0)
|
|
for (let index = 0; index < groupCount; index++) {
|
|
const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE)
|
|
const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8)
|
|
// >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set.
|
|
const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0)
|
|
if (sidPtr === null || !isLogonId) continue
|
|
const sidLength = api.getLengthSid(sidPtr)
|
|
if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`)
|
|
const copy = allocBytes(sidLength)
|
|
if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`)
|
|
return copy
|
|
}
|
|
throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`)
|
|
}
|
|
|
|
/**
|
|
* Create one well-known SID (68-byte buffer) and assert its validity.
|
|
* @param api - the binding table.
|
|
* @param type - the WELL_KNOWN_SID_TYPE to create.
|
|
* @returns the created SID pointer.
|
|
*/
|
|
export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
|
|
const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE)
|
|
const sizeSlot = allocUint32()
|
|
encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE)
|
|
if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) {
|
|
throwLastError(api, 'CreateWellKnownSid', `type ${type}`)
|
|
}
|
|
if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`)
|
|
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)
|
|
sids.forEach((sid, index) => {
|
|
buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index)
|
|
})
|
|
return buffer
|
|
}
|
|
|
|
/** The well-known SID packed into every restricted token's restricting list. */
|
|
export interface RestrictingSidSet {
|
|
world: NativePtr
|
|
}
|
|
|
|
/**
|
|
* Create the write-restricted token with the mode-selected restricting list
|
|
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
|
|
* - read-only: [logon SID, EVERYONE]
|
|
* - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID]
|
|
*
|
|
* 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 write SIDs join ONLY
|
|
* workspace-write — read-only carries no write SID, 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 that
|
|
* workspace grant inert under read-only while the unrevoked ACE keeps the
|
|
* re-upgrade free (the grant's exact-ACE skip — no re-propagation).
|
|
* Everyone's own ambient grants remain the documented partial boundary.
|
|
* 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 writeSids - the distinct write SIDs forming the workspace and
|
|
* optional temp allowlists (workspace-write only; empty under read-only).
|
|
* @param known - the well-known SIDs entering the restricting list.
|
|
* @param mode - selects the restricting list (workspace-write adds the capability SIDs).
|
|
* @returns the restricted token handle.
|
|
*/
|
|
export function createRestrictedToken(
|
|
api: Win32Bindings,
|
|
currentToken: NativePtr,
|
|
logonSid: NativePtr,
|
|
writeSids: readonly NativePtr[],
|
|
known: RestrictingSidSet,
|
|
mode: 'read-only' | 'workspace-write',
|
|
): NativePtr {
|
|
const restrictingSids = buildRestrictingSids(mode === 'read-only'
|
|
? [logonSid, known.world]
|
|
: writeSids.length === 0
|
|
? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })()
|
|
: [logonSid, known.world, ...writeSids])
|
|
const tokenSlot = allocPtrSlot()
|
|
const created = api.createRestrictedToken(
|
|
currentToken,
|
|
abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED,
|
|
0, null, // no SIDs disabled
|
|
0, null, // no privileges deleted
|
|
restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE,
|
|
restrictingSids,
|
|
tokenSlot,
|
|
)
|
|
if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`)
|
|
const token = decodePtr(tokenSlot)
|
|
if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle')
|
|
return token
|
|
}
|