Skip to content

Instantly share code, notes, and snippets.

@PanosGreg
Last active June 8, 2026 11:17
Show Gist options
  • Select an option

  • Save PanosGreg/2bd423ecb055df3db36b29c534cbae64 to your computer and use it in GitHub Desktop.

Select an option

Save PanosGreg/2bd423ecb055df3db36b29c534cbae64 to your computer and use it in GitHub Desktop.
Run a scriptblock in a timer using a runspace
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() }
}
}
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