Last active
September 21, 2024 10:58
-
-
Save jborean93/0952263a902b8008cda506752a2f0a49 to your computer and use it in GitHub Desktop.
Creates a PSSession that targets a scheduled task process
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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. | |
if ($PowerShellPath -eq 'C:\WINDOWS\system32\wsmprovhost.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 | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment