Last active
June 8, 2026 11:17
-
-
Save PanosGreg/2bd423ecb055df3db36b29c534cbae64 to your computer and use it in GitHub Desktop.
Run a scriptblock in a timer using a runspace
This file contains hidden or 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
| Function Invoke-WithImpersonation { | |
| <# | |
| .SYNOPSIS | |
| Invoke a scriptblock as another user. | |
| .DESCRIPTION | |
| Invoke a scriptblock and run it in the context of another user as supplied by -Credential. | |
| This is the 2nd file in this gist, there is also another file called Start-RunspaceJob.ps1 which calls this function | |
| .PARAMETER ScriptBlock | |
| The PowerShell code to run. It is recommended to use '{}.GetNewClosure()' to ensure the scriptblock has access to | |
| the same values where it was defined. Anything output by this scriptblock will also be outputted by | |
| Invoke-WithImpersonation. | |
| .PARAMETER Credential | |
| The PSCredential that specifies the user to run the scriptblock as. This needs to be a valid local or domain user | |
| except when using '-LogonType NewCredential'. The user specified must have been granted the 'logon as ...' right | |
| for the -LogonType that was requested (except for -LogonType NewCredential). | |
| .PARAMETER LogonType | |
| The logon type to use for the impersonated token. By default it is set to 'Interactive' which is the logon type | |
| used when a user has logged on interactively. Each logon type has their own unique characteristics as specified. | |
| Batch: Replicates running as a scheduled task, will typically have the full rights of the user specified. | |
| Interactive: Replicates running as a normal logged on user, may have limited rights depending on whether UAC | |
| is enabled. | |
| Network: Replicates running from a network logon like WinRM, will not be able to delegate it's credential to | |
| further downstream servers. | |
| NetworkCleartext: Like Network but will have access to its credentials for delegation, similar to using | |
| CredSSP auth for WinRM. | |
| NewCredential: Can be used to specify any credentials and any network auth attempts will use those credentials. | |
| Any local actions are run as the existing users token. | |
| Service: Replicates running as a Windows service. | |
| .EXAMPLE | |
| #Run as an interactive logon | |
| $cred = Get-Credential | |
| Invoke-WithImpersonation -Credential $cred -ScriptBlock { | |
| [System.Security.Principal.WindowsIdentity]::GetCurrent().Name | |
| }.GetNewClosure() | |
| .EXAMPLE | |
| #Access a network path with explicit credentials | |
| $cred = Get-Credential # Can be any username/password, does not have to be a valid local or domain account. | |
| $files = Invoke-WithImpersonation -Credential $cred -LogonType NewCredential -ScriptBlock { | |
| Get-ChildItem -Path \\192.168.1.1\share\folder | |
| }.GetNewClosure() | |
| .NOTES | |
| Starting a new process in the scriptblock will run as the original user and not the user supplied by -Credential. | |
| Use 'Start-Process' with -Credential to create a new process as another user. | |
| I need to thank Jordan Borean for his great work on this function. | |
| The actual source for this is here: https://gist.github.com/jborean93/3c148df03545023c671ddefb2d2b5ffc | |
| His C# mastery is quite remarkable. | |
| #> | |
| [CmdletBinding(DefaultParameterSetName='Block')] | |
| param ( | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='Block')] | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='BlockWithParams')] | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='BlockWithArgs')] | |
| [ScriptBlock]$ScriptBlock, | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='String')] | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='StringWithParams')] | |
| [Parameter(Mandatory=$true,Position=0,ParameterSetName='StringWithArgs')] | |
| [String]$ScriptString, | |
| [Parameter(Mandatory=$true,Position=1)] | |
| [PSCredential]$Credential, | |
| [Parameter(Mandatory=$true,Position=2,ParameterSetName='BlockWithArgs')] | |
| [Parameter(Mandatory=$true,Position=2,ParameterSetName='StringWithArgs')] | |
| [object[]]$ArgumentList, | |
| [Parameter(Mandatory=$true,Position=2,ParameterSetName='BlockWithParams')] | |
| [Parameter(Mandatory=$true,Position=2,ParameterSetName='StringWithParams')] | |
| [hashtable]$ParameterList, | |
| [ValidateSet('Batch', 'Interactive', 'Network', 'NetworkCleartext', 'NewCredential', 'Service')] | |
| [String]$LogonType = 'Interactive' | |
| ) | |
| if ($PSCmdlet.ParameterSetName -match 'String') { | |
| $ScriptBlock = [scriptblock]::Create($ScriptString) | |
| } | |
| $code = @' | |
| [DllImport("Advapi32.dll", EntryPoint = "ImpersonateLoggedOnUser", SetLastError = true)] | |
| private static extern bool NativeImpersonateLoggedOnUser( | |
| SafeHandle hToken); | |
| public static void ImpersonateLoggedOnUser(SafeHandle token) | |
| { | |
| if (!NativeImpersonateLoggedOnUser(token)) | |
| { | |
| throw new System.ComponentModel.Win32Exception(); | |
| } | |
| } | |
| [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] | |
| private static extern bool LogonUserW( | |
| string lpszUsername, | |
| string lpszDomain, | |
| IntPtr lpszPassword, | |
| UInt32 dwLogonType, | |
| UInt32 dwLogonProvider, | |
| out Microsoft.Win32.SafeHandles.SafeWaitHandle phToken); | |
| public static Microsoft.Win32.SafeHandles.SafeWaitHandle LogonUser(string username, string domain, | |
| System.Security.SecureString password, uint logonType, uint logonProvider) | |
| { | |
| IntPtr passPtr = Marshal.SecureStringToGlobalAllocUnicode(password); | |
| try | |
| { | |
| Microsoft.Win32.SafeHandles.SafeWaitHandle token; | |
| if (!LogonUserW(username, domain, passPtr, logonType, logonProvider, out token)) | |
| { | |
| throw new System.ComponentModel.Win32Exception(); | |
| } | |
| return token; | |
| } | |
| finally | |
| { | |
| Marshal.ZeroFreeGlobalAllocUnicode(passPtr); | |
| } | |
| } | |
| [DllImport("Advapi32.dll")] | |
| public static extern bool RevertToSelf(); | |
| '@ | |
| Add-Type -Namespace PInvoke -Name NativeMethods -MemberDefinition $code | |
| $OriginalUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name | |
| if ($OriginalUser.IndexOf('\') -gt 1) {$OriginalUser = $OriginalUser.Split('\')[1]} | |
| $logonTypeInt = switch($LogonType) { | |
| Interactive { 2 } # LOGON32_LOGON_INTERACTIVE | |
| Network { 3 } # LOGON32_LOGON_NETWORK | |
| Batch { 4 } # LOGON32_LOGON_BATCH | |
| Service { 5 } # LOGON32_LOGON_SERVICE | |
| NetworkCleartext { 8 } # LOGON32_LOGON_NETWORK_CLEARTEXT | |
| NewCredential { 9 } # LOGON32_LOGON_NEW_CREDENTIALS | |
| } | |
| $user = $Credential.UserName | |
| $domain = $null | |
| if ($user.Contains('\')) { | |
| $domain, $user = $user -split '\\', 2 | |
| } | |
| try { | |
| $token = [PInvoke.NativeMethods]::LogonUser( | |
| $user, | |
| $domain, | |
| $Credential.Password, | |
| $logonTypeInt, | |
| 0 # LOGON32_PROVIDER_DEFAULT | |
| ) | |
| Write-Verbose "Impersonate the user $user with $LogonType logon" | |
| [PInvoke.NativeMethods]::ImpersonateLoggedOnUser($token) | |
| try { | |
| if ($ArgumentList.Count -gt 0) {$ScriptBlock.Invoke($ArgumentList)} | |
| elseif ($ParameterList.Keys.Count -gt 0) {& $ScriptBlock @ParameterList} | |
| else {& $ScriptBlock} | |
| } | |
| finally { | |
| Write-Verbose "Revert back to the original context of user $OriginalUser" | |
| $null = [PInvoke.NativeMethods]::RevertToSelf() | |
| } | |
| } | |
| catch { | |
| $PSCmdlet.WriteError($_) | |
| } | |
| finally { | |
| if ($token) { $token.Dispose() } | |
| } | |
| } |
This file contains hidden or 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
| function Start-RunspaceJob { | |
| <# | |
| .SYNOPSIS | |
| It runs a command on a timer, so it can abort the command if the timeout expires. | |
| It can also run the command as a different user. | |
| It allows the end-user to pass input parameters to the command. | |
| .EXAMPLE | |
| Start-RunspaceJob -Scriptblock { | |
| $p1 = ' ' * 9 ; $p2 = ' ' * 18 | |
| Write-Verbose "$p1 1 [V] Will do A" -Verbose | |
| Start-Sleep -Milliseconds 500 | |
| Write-Output "$p2 2 [O] This is A" | |
| Start-Sleep -Milliseconds 500 | |
| Write-Output '' | |
| Start-Sleep -Milliseconds 250 | |
| Write-Verbose "$p1 3 [V] Will now do B" -Verbose | |
| Start-Sleep -Milliseconds 500 | |
| Write-Warning "$p1 4 [W] Issue with B" | |
| Start-Sleep -Milliseconds 500 | |
| Write-Output "$p2 5 [O] That was B" | |
| Start-Sleep -Milliseconds 500 | |
| Write-Output '' | |
| Start-Sleep -Milliseconds 250 | |
| Write-Verbose "$p1 6 [V] Finally will do C" -Verbose | |
| Start-Sleep -Milliseconds 500 | |
| Write-Error '7 [E] Error with C' | |
| Start-Sleep -Milliseconds 500 | |
| Write-Output '' | |
| Start-Sleep -Milliseconds 250 | |
| Write-Verbose "$p1 8 [V] Done" -Verbose | |
| } | |
| an example command, that has multiple streams (like verbose and warning) | |
| and also returns output in pieces throughout the execution runtime (due to the 1sec waits) | |
| .EXAMPLE | |
| Start-RunspaceJob -Scriptblock { | |
| Start-Sleep 1 | |
| 'AAA' | |
| Start-Sleep 2 | |
| 'BBB' | |
| Start-Sleep 2 | |
| 'CCC' | |
| } -TimeoutSec 4 | |
| an example command that returns partial output because there's not enough time to finish the entire command. | |
| And shows a warning message for the timeout expiration. | |
| .EXAMPLE | |
| $result = Start-RunspaceJob -Scriptblock { | |
| function Get-MyService {Get-Service 'does-not-exist'} | |
| Get-MyService | |
| } -DontRenewErrors | |
| an example where we use the DontRenewErrors flag to not re-hydrate the error message | |
| but rather pass it directly as it returns from the runspace invocation output. | |
| This way we can better drill in on exactly where the error is coming from (inside the user's scriptblock). | |
| .EXAMPLE | |
| # define some variable and also import some module | |
| $SampleVariable = 'sample' | |
| Import-Module Pester | |
| # then use the above inside the scriptblock | |
| Start-RunspaceJob -Scriptblock { | |
| if ($SampleVariable) {Write-Verbose "The variable from the parent scope: $SampleVariable" -Verbose} | |
| else {Write-Warning 'The variable $SampleVariable was not found'} | |
| if (Get-Module Pester) {Get-Module Pester} | |
| else {Write-Warning 'Module Pester was not found'} | |
| } -ImportLocalState | |
| an example where we import local variables and modules into the runspace, so we can use them inside it. | |
| Try the above command with and without the "ImportLocalState" switch to see the difference | |
| .EXAMPLE | |
| Start-RunspaceJob -Scriptblock {$MyCustomVar = 'aa'} -ExportRunspaceVariables | |
| Write-Output $MyCustomVar | |
| an example where we define a variable inside the runspace and then using the ExportRunspaceVariables switch | |
| we export that variable onto the parent scope | |
| .EXAMPLE | |
| Start-RunspaceJob -Scriptblock {'{0} is {1}' -f $args[0].Name,$args[0].Status} -ArgumentList (Get-Service WinRM) | |
| an example where we pass some data into the scriptblock, using the -ArgumentList | |
| .EXAMPLE | |
| Start-RunspaceJob -Scriptblock {param($Service,$User) Write-Output $User} -ParameterList @{User='test';Service = Get-Service Winrm} | |
| an example where we pass some data into the scriptblock, using the -ParameterList. | |
| Do note that now we dont have to put the parameters in order. Ie in the scriptblock the $user param is 2nd, | |
| although we pass it 1st in the ParameterList hashtable. | |
| .NOTES | |
| Author: Panos Grigoriadis | |
| Date: 08-Jun-2026 | |
| Version: 2.2.0 | |
| Notes: | |
| About the .EndInvoke() method | |
| The .EndInvoke() does actually output $null, which is caught by the caller of the function. | |
| So you have to silence it via out-null or use [void]. | |
| About the .ReadAll() method | |
| The .ReadAll() method does 2 things actually. It returns the items of the collection. | |
| And also it removes them from the collection. | |
| About warning stream redirection | |
| I'm redirecting the Warning stream (#3) to the Normal stream (#1) to show a message that the timeout expired | |
| because the output of the command is doing the same, so i'm keeping the same pattern | |
| so that the end-user can collect the entirety of the output from this function, without missing this warning. | |
| About error messages coming from the user's scriptblock | |
| I need to improve on that, in order to remove any mentions of this function (Start-RunspaceJob) | |
| So that the end-user sees only the error from his scriptblock as-if it was run directly from the console | |
| and not through this function. | |
| TODO: Re-factor the Get-ErrorRecord function for this use-case | |
| #> | |
| [cmdletbinding(DefaultParameterSetName='__AllParameterSets')] | |
| [OutputType([object])] # <-- whatever the output of the user's command returns | |
| param ( | |
| [Parameter(Mandatory)] | |
| [scriptblock]$Scriptblock, | |
| [Parameter(ParameterSetName='WithArgs')] | |
| [object[]]$ArgumentList, # <-- you can pass either unnamed arguments in a specific order | |
| [Parameter(ParameterSetName='WithParams')] | |
| [hashtable]$ParameterList, # <-- or you can pass named parameters in any order, but not both arguments and parameters | |
| [int]$TimeoutSec = 60, # <-- default execution timeout is 1 minute | |
| [pscredential]$RunAs, # <-- user/pass to run the command as a different user | |
| [switch]$ImportLocalState, # <-- import local variables & modules into the runspace | |
| [switch]$ExportRunspaceVariables, # <-- any variables created inside the runspace after its execution | |
| # export them from the runspace into the local scope | |
| [switch]$ReturnOnEnd, # <-- do not return the output as it gets generated, but instead return it all at the end in one go. | |
| [switch]$DontRenewErrors # <-- do not re-hydrate the error stream, instead return errors as-is in StdOut | |
| ) | |
| # get all the local variables | |
| $ScriptVars = Get-Variable -Scope Script -ErrorAction SilentlyContinue | where { | |
| $_.Name -notmatch '^(_|PS|Host|PID|PWD|null|true|false)' -and # <-- Skip automatic and system variables | |
| $_.Options -notmatch 'ReadOnly|Constant' | |
| } | |
| # Note: we'll need this later on, but have to put it here at the top, so it wont include uneeded variables | |
| # make sure we have the privilege to RunAs, before we do anything with runspaces | |
| if ($RunAs) { | |
| $HasPriv = (whoami /priv -fo csv | ConvertFrom-Csv | where 'Privilege Name' -eq SeImpersonatePrivilege) -as [bool] | |
| if (-not $HasPriv) { | |
| $msg = "The current process (PID: $PID) does not have the required security privilege 'SeImpersonatePrivilege'`nThis is required when using the -RunAs switch. Please try running as SYSTEM." | |
| $err = Write-Error -Message $msg -ErrorAction Continue 2>&1 # <-- create an ErrorRecord | |
| $PSCmdlet.ThrowTerminatingError($err) | |
| } | |
| } | |
| . ([scriptblock]::Create('using namespace System.Management.Automation')) # PSDataCollection,PowerShell,Runspaces.* | |
| $State = [Runspaces.InitialSessionState]::CreateDefault() | |
| # add the required context (functions & vars) to run as a different user | |
| if ($RunAs) { | |
| $MyFunc = Get-Item Function:\Invoke-WithImpersonation -ErrorAction Stop | |
| $FunEntry = [Runspaces.SessionStateFunctionEntry]::new($MyFunc.Name,$MyFunc.Definition) | |
| [void]$State.Commands.Add($FunEntry) | |
| $VarEntry1 = [Runspaces.SessionStateVariableEntry]::new('_Creds',$RunAs,$null) | |
| $VarEntry2 = [Runspaces.SessionStateVariableEntry]::new('_UserBlock',$Scriptblock,$null) | |
| [void]$State.Variables.Add($VarEntry1) | |
| [void]$State.Variables.Add($VarEntry2) | |
| # we need to remove any input from the Bound Parameters, that's not the user's args for his scriptblock | |
| $PSBoundParameters.GetEnumerator() | where Key -NotMatch '^(Argument|Parameter)List$' | foreach { | |
| [void]$PSBoundParameters.Remove($_.Key) | |
| } | |
| # so now the function's $PSBoundParameters is either empty or | |
| # it's a hashtable that has a single Key which is either ArgumentList or ParameterList | |
| $VarEntry3 = [Runspaces.SessionStateVariableEntry]::new('_UserArgs',$PSBoundParameters,$null) | |
| [void]$State.Variables.Add($VarEntry3) | |
| } | |
| # import the local variables and modules into the runspace | |
| if ($ImportLocalState) { | |
| # Copy variables to runspace | |
| foreach ($var in $ScriptVars) { | |
| $VarEntry = [Runspaces.SessionStateVariableEntry]::new($var.Name,$var.Value,$null) | |
| [void]$State.Variables.Add($VarEntry) | |
| } | |
| # import any modules from the parent session | |
| $LoadedModules = Get-Module | |
| foreach ($module in $LoadedModules) { | |
| if ($module.Path) { | |
| $ModuleSpec = [Microsoft.PowerShell.Commands.ModuleSpecification]::new($module.Path) | |
| $State.ImportPSModule($ModuleSpec) | |
| } | |
| } | |
| } | |
| $Cmd = [PowerShell]::Create($State) | |
| # Note: the minute we use the .AddScript() method, the runspace will automatically open, hence no need to open it manually | |
| # also by not defining a specific runspace, it creates a powershell instance with a "DefaultRunspace", though with a specific SessionState | |
| # save the default variables of the runspace (we'll need them later on) | |
| [void]$Cmd.AddScript('$_DefaultRunspaceVariableNames = (Get-Variable).Name') | |
| # NOTE: this functionality does not currently work correctly when we use the -RunAs switch | |
| # add the user's scriptblock & any arguments (if he provided any) | |
| if ($RunAs) { | |
| [void]$Cmd.AddScript('Invoke-WithImpersonation -ScriptBlock $_UserBlock -Credential $_Creds @_UserArgs') | |
| } | |
| else { | |
| [void]$Cmd.AddScript($Scriptblock.ToString()) | |
| # add user's parameters (can add args/params only after you add a script first) | |
| if ($ArgumentList.Count -gt 0) { | |
| $ArgumentList | foreach {[void]$Cmd.AddArgument($_)} | |
| } | |
| elseif ($ParameterList.Keys.Count -gt 0) { | |
| $ParameterList.GetEnumerator() | foreach {[void]$Cmd.AddParameter($_.Key,$_.Value)} | |
| } | |
| } | |
| # get all streams as part of the normal output, not separately | |
| $Cmd.Commands.Commands.MergeMyResults('All','Output') | |
| # prepare the setup to re-hydrate the objects to their regular streams | |
| $SMA = 'System.Management.Automation' | |
| $Hash = @{ | |
| "$SMA.VerboseRecord" = {Write-Verbose -Message $_.Message -Verbose} | |
| "$SMA.WarningRecord" = {Write-Warning -Message $_.Message} | |
| "$SMA.InformationRecord" = {Write-Host -Object $_.MessageData} | |
| "$SMA.ErrorRecord" = { | |
| if (-not $DontRenewErrors) {$PSCmdlet.WriteError($_)} | |
| else {Write-Output $_} | |
| } | |
| } | |
| # start the command | |
| $InOut = [PSDataCollection[object]]::new() | |
| $Async = $Cmd.BeginInvoke($InOut,$InOut) | |
| # return the output from the command as it gets generated on the fly | |
| # while keeping a timer to make sure we don't exceed the timeout | |
| $Timer = [System.Diagnostics.Stopwatch]::StartNew() | |
| $IsDone = $false ; $HasExpired = $false | |
| while (-not $IsDone -and -not $HasExpired) { | |
| $IsDone = $Async.IsCompleted | |
| $HasExpired = $Timer.Elapsed.TotalSeconds -gt $TimeoutSec | |
| # this is the output of this function | |
| if (-not $ReturnOnEnd) { | |
| $InOut.ReadAll() | foreach { | |
| if ($null -ne $_ -and $_.pstypenames[0] -like "$SMA.*Record") {. $Hash[$_.pstypenames[0]]} # <-- write verbose/warning/info/error | |
| else {Write-Output $_} | |
| } | |
| } | |
| # if it's done then dont wait, if it's expired then again dont wait | |
| if (-not $IsDone -or -not $HasExpired) { | |
| Start-Sleep -Milliseconds 200 # <-- refresh 5 times per second | |
| } | |
| } | |
| $Timer.Stop() | |
| # stop the command if the timeout has expired | |
| if (-not $Async.IsCompleted) { | |
| Write-Warning "Execution timeout has expired ($TimeoutSec sec) but the script is still running" 3>&1 | |
| Write-Warning 'Will only collect output till this point, if any.' 3>&1 | |
| $Cmd.Stop() | |
| } | |
| try {[void]$Cmd.EndInvoke($Async)} # <-- this method blocks, so it will wait as long as needed for the script to finish | |
| catch {$_.Exception.InnerException.ErrorRecord} | |
| if ($ReturnOnEnd) { | |
| Write-Output $InOut | foreach { | |
| if ($null -ne $_ -and $_.pstypenames[0] -like "$SMA.*Record") {. $Hash[$_.pstypenames[0]]} # <-- write verbose/warning/info/error | |
| else {Write-Output $_} | |
| } | |
| } | |
| # copy variables back from the runspace | |
| if ($ExportRunspaceVariables) { | |
| # NOTE: to do that we create a NEW (temporary) powershell instance | |
| # but we set its runspace to the existing runspace that we already have | |
| # and then we just get all the newly created variables from that (using our existing list that we got earlier on) | |
| $GetVarsCommand = [PowerShell]::Create() | |
| $GetVarsCommand.Runspace = $Cmd.Runspace | |
| [void]$GetVarsCommand.AddScript( | |
| @' | |
| $_VarDiffList = ( Compare-Object $_DefaultRunspaceVariableNames (Get-Variable).Name ).InputObject | |
| if ($null -eq $_VarDiffList) {return} | |
| else {Get-Variable -Name $_VarDiffList} | |
| '@) | |
| $RunspaceVars = $GetVarsCommand.Invoke() | |
| $GetVarsCommand.Dispose() # <-- once we get the variables, we dispose that "temporary" powershell instance | |
| # and then set those variables onto the parent scope | |
| foreach ($var in $RunspaceVars) { | |
| # exclude any variables that are already defined in the parent scope, cause we dont want to overwrite them | |
| if (-not (Get-Variable -Name $var.Name -Scope Script -ErrorAction SilentlyContinue)) { | |
| try {Set-Variable -Name $var.Name -Value $var.Value -Scope Script -Force -ErrorAction Stop} | |
| catch { } | |
| } | |
| } | |
| } #export variables | |
| # clean up | |
| if ($Cmd.Runspace -ne $null -and $Cmd.Runspace.RunspaceStateInfo.State -eq 'Opened') { | |
| $Cmd.Runspace.Close() | |
| $Cmd.Runspace.Dispose() | |
| } | |
| if ($Cmd -ne $null) { | |
| $Cmd.Dispose() | |
| } | |
| if (Test-Path Variable:\InOut) { | |
| $InOut.Dispose() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment