Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active September 4, 2025 08:50
Show Gist options
  • Save jborean93/0952263a902b8008cda506752a2f0a49 to your computer and use it in GitHub Desktop.
Save jborean93/0952263a902b8008cda506752a2f0a49 to your computer and use it in GitHub Desktop.
Creates a PSSession that targets a scheduled task process
# 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
}
}
@joes
Copy link

joes commented Aug 29, 2025

I added this to a module for testing (in scheduledTaskSession.psm1) and invoked as:

import-module -force .\scheduledTaskSession.psm1
Enter-PSSession (New-ScheduledTaskSession)

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:

pwsh.exe: Cannot process the XML from the 'Output' stream of 'C:\Program Files\PowerShell\7\pwsh.exe': Data at the root level is invalid. Line 1, position 1.
pwsh.exe: Cannot process the XML from the 'Output' stream of 'C:\Program Files\PowerShell\7\pwsh.exe': There are multiple root elements. Line 1, position 122.
example
pwsh.exe: The term '<' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

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:

PS > 
 Name                           Value
----                           -----
WSManStackVersion              3.0
PSVersion                      7.5.2
OS                             Microsoft Windows 10.0.26100
SerializationVersion           1.1.0.1
Platform                       Win32NT
GitCommitId                    7.5.2
PSRemotingProtocolVersion      2.3
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSEdition                      Core

@jborean93
Copy link
Author

jborean93 commented Aug 29, 2025

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?

@joes
Copy link

joes commented Sep 4, 2025

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment