-
-
Save jborean93/0952263a902b8008cda506752a2f0a49 to your computer and use it in GitHub Desktop.
# Copyright: (c) 2024, Jordan Borean (@jborean93) <[email protected]> | |
# MIT License (see LICENSE or https://opensource.org/licenses/MIT) | |
Function New-ScheduledTaskSession { | |
<# | |
.SYNOPSIS | |
Creates a PSSession for a process running as a scheduled task. | |
.DESCRIPTION | |
Creates a PSSession that can be used to run code inside a scheduled task | |
context. This context can be used to bypass issues like a network logon | |
not being able to access the Windows Update API, solving the double hop | |
problem or just to run code under SYSTEM. | |
The session can be used alongside the builtin cmdlets like Invoke-Command | |
or Enter-PSSession to use the session to run a command non-interactively | |
or through an interactive session. Once the session is no longer needed it | |
should be cleaned up with Remove-PSSession. | |
By default the task will run as the current user using S4U. This is like | |
running a task with the setting "Run whether user is logged on or not" | |
without storing the password. Use '-UserName' to specify any other well | |
known service accounts like 'SYSTEM', 'LocalService', 'NetworkService', | |
or to specify a gMSA account. To run as another user, or the current user | |
with access to network resources, use the '-Credential' parameter to | |
specify the credentials to run as. It is also possible to run the task in | |
an interactive session from session 0. This switch will create a task that | |
is set to 'Run only when user is logged on' for any user that is logged on | |
the host allowing the code to be run in an actual interactive session. If | |
set but there are no interactive sessions the task will timeout while | |
waiting for the process to start. | |
.PARAMETER PowerShellPath | |
Override the PowerShell executable used, by default will use the current | |
PowerShell executable. | |
.PARAMETER UserName | |
Runs the scheduled task as the user specified. This can be set to well | |
known service accounts like 'SYSTEM', 'LocalService', or 'NetworkService' | |
to run as those service accounts. It can also be set to a gMSA that ends | |
with '$' in the name to run as that gMSA account. Otherwise this will | |
attempt to run using S4U which only works for the current user. | |
If using a gMSA, the gMSA must be configured to allow the current computer | |
account the ability to retrieve its password. | |
.PARAMETER Credential | |
Runs the scheduled task as the user specified by the credentials. The | |
process will be able to access network resources or do other tasks that | |
require credentials like access DPAPI secrets. The user specified must have | |
batch logon rights. | |
.PARAMETER Interactive | |
Runs the scheduled task as an interactive user. This sets the task | |
principal as 'BUILTIN\Users' and set to run only when user is logged on. | |
This is useful for running a process on an interactive desktop but will | |
only work if there is an existing interactive session present. | |
.PARAMETER OpenTimeout | |
The timeout, in seconds, to wait for the PowerShell process to be created | |
by the task scheduler and also to connect to the named pipe it creates. As | |
each operation are separate the total timeout could potentially be double | |
the value specified here. | |
.PARAMETER RunLevel | |
The privilege level to run the scheduled task process as. Set to Highest | |
to run it with the full privileges of the user. Set to Lowest to run as | |
the limited/lowest privileges of the user. If UAC is disabled or the user | |
is not affected by UAC (builtin Administrator account), this value does | |
nothing. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession | |
Invoke-Command $s { whoami /all } | |
$s | Remove-PSSession | |
Runs task as current user and closes the session once done. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession -UserName SYSTEM | |
Invoke-Command $s { whoami } | |
$s | Remove-PSSession | |
Runs task as SYSTEM. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession -UserName myGMSA$ | |
Invoke-Command $s { whoami } | |
$s | Remove-PSSession | |
Runs task as a gMSA account, note the username ends with '$'. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession -Credential user | |
Invoke-Command $s { whoami } | |
$s | Remove-PSSession | |
Runs task as 'user', this will prompt for the password for user. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession | |
Enter-PSSession $s | |
Enters an interactive PSSession for the started scheduled task process. | |
.EXAMPLE | |
$s = New-ScheduledTaskSession -Interactive | |
Invoke-Command $s { | |
Get-Process -Id $pid | Select-Object -Property ProcessName, Id, SessionId | |
} | |
$s | Remove-PSSession | |
Runs task as the interactive logon session. The caller of | |
New-ScheduledTaskSession will be running in session 0 like through ssh, | |
winrm, service, etc but the session will be spawned on the interactive | |
session of an existing logon user. | |
.NOTES | |
This cmdlet requires admin permissions to create the scheduled task. | |
#> | |
[OutputType([System.Management.Automation.Runspaces.PSSession])] | |
[CmdletBinding(DefaultParameterSetName = "UserName")] | |
param ( | |
[Parameter()] | |
[string] | |
$PowerShellPath, | |
[Parameter(ParameterSetName = "UserName")] | |
# [ArgumentCompleter("LocalService", "NetworkService", "SYSTEM")] # Needs pwsh 7+ | |
[string] | |
$UserName, | |
[Parameter(ParameterSetName = "Credential")] | |
[System.Management.Automation.Credential()] | |
[PSCredential] | |
$Credential, | |
[Parameter(ParameterSetName = "Interactive")] | |
[switch] | |
$Interactive, | |
[Parameter()] | |
[int] | |
$OpenTimeout = 30, | |
[Parameter()] | |
[ValidateSet("Highest", "Limited")] | |
[string] | |
$RunLevel = 'Highest' | |
) | |
$ErrorActionPreference = 'Stop' | |
# Use a unique GUID to identify the process uniquely after we start the task. | |
$powershellId = (New-Guid).ToString() | |
$taskName = "New-ScheduledTaskSession-$powershellId" | |
# PowerShell 7.3 created a public way to build a PSSession but WinPS needs | |
# to use reflection to build the PSSession from the Runspace object. | |
$createPSSession = if ([System.Management.Automation.Runspaces.PSSession]::Create) { | |
{ | |
[System.Management.Automation.Runspaces.PSSession]::Create($args[0], $taskName, $null) | |
} | |
} | |
else { | |
$remoteRunspaceType = [PSObject].Assembly.GetType('System.Management.Automation.RemoteRunspace') | |
$pssessionCstr = [System.Management.Automation.Runspaces.PSSession].GetConstructor( | |
'NonPublic, Instance', | |
$null, | |
[type[]]@($remoteRunspaceType), | |
$null) | |
{ $pssessionCstr.Invoke(@($args[0])) } | |
} | |
if (-not $PowerShellPath) { | |
$PowerShellPath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName | |
# wsmprovhost is used in a WSMan PSRemoting target, we need to change | |
# that to the proper executable. | |
$systemRoot = $env:SystemRoot | |
if (-not $systemRoot) { | |
$systemRoot = 'C:\Windows' | |
} | |
if ($PowerShellPath -in @( | |
"$systemRoot\system32\wsmprovhost.exe" | |
"$systemRoot\system32\WindowsPowerShell\v1.0\PowerShell_ISE.exe" | |
)) { | |
$executable = if ($IsCoreCLR) { | |
'pwsh.exe' | |
} | |
else { | |
'powershell.exe' | |
} | |
$PowerShellPath = Join-Path $PSHome $executable | |
} | |
} | |
# Resolve the absolute path for PowerShell for the CIM filter to work. | |
if (Test-Path -LiteralPath $PowerShellPath) { | |
$PowerShellPath = (Get-Item -LiteralPath $PowerShellPath).FullName | |
} | |
elseif ($powershellCommand = Get-Command -Name $PowerShellPath -CommandType Application -ErrorAction SilentlyContinue) { | |
$PowerShellPath = $powershellCommand.Path | |
} | |
else { | |
$exc = [System.ArgumentException]::new("Failed to find PowerShellPath '$PowerShellPath'") | |
$err = [System.Management.Automation.ErrorRecord]::new( | |
$exc, | |
'FailedToFindPowerShell', | |
'InvalidArgument', | |
$PowerShellPath) | |
$PSCmdlet.WriteError($err) | |
return | |
} | |
$powershellArg = "-WindowStyle Hidden -NoExit -Command '$powershellId'" | |
Write-Verbose -Message "Creating scheduled task to run '$PowerShellPath' with the ID $powershellId" | |
$action = New-ScheduledTaskAction -Execute $PowerShellPath -Argument $powershellArg | |
$taskParams = @{ | |
Action = $action | |
Force = $true | |
Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries | |
TaskName = $taskName | |
ErrorAction = 'Stop' | |
} | |
if ($Interactive) { | |
Write-Verbose -Message "Setting task to run with interactive session" | |
$group = [System.Security.Principal.SecurityIdentifier]::new( | |
[System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, | |
$null).Translate([System.Security.Principal.NTAccount]).Value | |
$principal = New-ScheduledTaskPrincipal -GroupId $group -RunLevel $RunLevel | |
$taskParams.Principal = $principal | |
} | |
elseif ($Credential) { | |
Write-Verbose -Message "Setting task to run with credentials for '$($Credential.UserName)'" | |
$taskParams.User = $Credential.UserName | |
$taskParams.Password = $Credential.GetNetworkCredential().Password | |
$taskParams.RunLevel = $RunLevel | |
} | |
else { | |
if ($UserName) { | |
$sid = ([System.Security.Principal.NTAccount]$UserName).Translate( | |
[System.Security.Principal.SecurityIdentifier]) | |
} | |
else { | |
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User | |
} | |
# Normalise the username from the SID. | |
$UserName = $sid.Translate([System.Security.Principal.NTAccount]).Value | |
$logonType = 'S4U' | |
if ($sid.Value -in @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')) { | |
# SYSTEM, LocalService, NetworkService | |
$logonType = 'ServiceAccount' | |
} | |
elseif ($UserName.EndsWith('$')) { | |
# gMSA | |
$logonType = 'Password' | |
} | |
$principal = New-ScheduledTaskPrincipal -UserId $UserName -LogonType $logonType -RunLevel $RunLevel | |
$taskParams.Principal = $principal | |
Write-Verbose -Message "Setting task to run as '$($principal.UserId)' with logon type $($principal.LogonType)" | |
} | |
$task = Register-ScheduledTask @taskParams | |
try { | |
$stopProc = $true | |
$procId = 0 | |
$runspace = $null | |
$task | Start-ScheduledTask | |
# There's no API to get the running PID of a task so we use CIM to | |
# enumerate the processes and find the one that matches our unique | |
# command identifier. | |
$wqlFilter = "ExecutablePath = '$($PowerShellPath -replace '\\', '\\')' AND CommandLine LIKE '% -WindowStyle Hidden -NoExit -Command \'$powershellId\''" | |
$cimParams = @{ | |
ClassName = 'Win32_Process' | |
Filter = $wqlFilter | |
Property = 'ProcessId' | |
} | |
$start = Get-Date | |
while (-not ($proc = Get-CimInstance @cimParams)) { | |
if (((Get-Date) - $start).TotalSeconds -gt $OpenTimeout) { | |
throw "Timeout waiting for PowerShell process to start" | |
} | |
Start-Sleep -Seconds 1 | |
} | |
$procId = [int]$proc.ProcessId | |
Write-Verbose "Found spawned process $procId - attempting to open" | |
$typeTable = [System.Management.Automation.Runspaces.TypeTable]::LoadDefaultTypeFiles() | |
$connInfo = [System.Management.Automation.Runspaces.NamedPipeConnectionInfo]::new($procId) | |
$connInfo.OpenTimeout = $OpenTimeout * 1000 | |
$runspace = [RunspaceFactory]::CreateRunspace($connInfo, $host, $typeTable) | |
$runspace.Open() | |
Write-Verbose "Registering handler to stop the process on closing the PSSession" | |
$null = Register-ObjectEvent -InputObject $runspace -EventName StateChanged -MessageData $procId -Action { | |
if ($EventArgs.RunspaceStateInfo.State -in @('Broken', 'Closed')) { | |
Unregister-Event -SourceIdentifier $EventSubscriber.SourceIdentifier | |
Stop-Process -Id $Event.MessageData -Force | |
} | |
} | |
$stopProc = $false | |
Write-Verbose "Runspace opened, creating PSSession object" | |
& $createPSSession $runspace | |
} | |
catch { | |
if ($stopProc -and $procId) { | |
Stop-Process -Id $procId -Force | |
} | |
if ($runspace) { | |
$runspace.Dispose() | |
} | |
$err = [System.Management.Automation.ErrorRecord]::new( | |
$_.Exception, | |
'FailedToOpenSession', | |
'NotSpecified', | |
$null) | |
$PSCmdlet.WriteError($err) | |
} | |
finally { | |
$task | Unregister-ScheduledTask -Confirm:$false | |
} | |
} |
That is expected, you’ll see the same error if you were to start any other console sub process that tries to be interactive. In a PSRemoting session there is no console handle available thus the sub process has no input/output console handle to attach to. This is a fundamental limitation of PSRemoting/Enter-PSSession and there is unfortunately nothing that can be done about it.
What is the overall goal you are trying to achieve here?
What is the overall goal you are trying to achieve here?
First off, thanks for answering my original question.
My goal/idea was to use New-ScheduledTaskSession
(adapted as New-ManagedPwshProcess
) as the basis for a project-scoped shell similar to direnv, but for PowerShell. The idea was to "enter" into a session where env vars and secrets are managed from a config file. But since a PSSession wouldn't behave like a normal shell (e.g. you can't start an interactive subprocess cleanly), that won't really work in all cases.
What I'm trying now is a function/module that starts a "managed" shell with:
pwsh.exe -CustomPipeName "knownNameBasedOnProjectContext" -EncodedCommand $envSetup -EncodedArgument $envSetupArgs
The new process re-imports the module and runs bootstrap code to set up and refresh its environment variables.
The module would then be able to launch a managed shell in another user context (runas, psexec, runas /netonly etc.) using the same bootstrap approach. In this model, the original shell acts as a parent and the new one as a child. The child still needs to reach back to the parent for secrets that are DPAPI-protected ones. To make that possible, I've prototyped a "PSRP pipe relay" — it grants the child access but forwards traffic back to the parent's private pipe (inspired by your PSRP logger). The child could then import modules from the parent's PSSession via this relay (Import-Module ProjectSecrets -PSSession $parentSession
). Using CustomPipeName
here is important: it gives me a predictable handle to identify the parent's pipe, instead of trying to reconstruct PowerShell's internal naming scheme.
So the goal isn't just remoting for its own sake, but a way to spin up managed shells with controlled env state, while still bridging contexts when/if needed.
I added this to a module for testing (in
scheduledTaskSession.psm1
) and invoked as:In the resulting session I tried to start
pwsh.exe
again with-NoExit
:[localhost]: PS > pwsh.exe -NoExit -NoProfile -Command { Write-Host "example" }
This results in the error:
If I run without
-NoExit
there is no issue. It probably outputs invalid CLIXML I suppose.Was wondering if you migth have an idea as to why and if there might be a workaround for this?
My
$PSVersionTable
is: