Skip to content

Instantly share code, notes, and snippets.

@PanosGreg
Last active May 27, 2025 07:41
Show Gist options
  • Save PanosGreg/2bd423ecb055df3db36b29c534cbae64 to your computer and use it in GitHub Desktop.
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 1
Write-Output "$p2 2 [O] This is A"
Write-Verbose "$p1 3 [V] Will do b" -Verbose
Start-Sleep 1
Write-Warning "$p1 4 [W] Issue with B"
Write-Output "$p2 5 [O] This is B"
Write-Verbose "$p1 6 [V] Will do c" -Verbose
Start-Sleep 1
Write-Error '7 [E] Error with C'
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).
.NOTES
Author: Panos Grigoriadis
Date: 26-May-2025
Version: 2.0.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 imporve 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()]
[OutputType([object])] # <-- whatever the output of the user's command returns
param (
[Parameter(Mandatory)]
[scriptblock]$Scriptblock,
[object[]]$ArgumentList, # <-- you can pass either unnamed arguments in a specific order
[hashtable]$ParameterList, # <-- or you can pass named parameters in any order
[int]$TimeoutSec = 60, # <-- default execution timeout is 1 minute
[pscredential]$RunAs, # <-- user/pass to run the command as a different user
[switch]$ReturnOnEnd, # <-- do not reutrn 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
)
. ([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 the user's parameters from the Bound Parameters, to use the remaining ones in the state
[void]$PSBoundParameters.Remove('Scriptblock')
[void]$PSBoundParameters.Remove('RunAs')
[void]$PSBoundParameters.Remove('TimeoutSec')
[void]$PSBoundParameters.Remove('ReturnOnEnd')
[void]$PSBoundParameters.Remove('DontRenewErrors')
# 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)
}
# create a new powershell runspace
$Cmd = [PowerShell]::Create($State)
# add the scriptblock & any arguments
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 ($_.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 ($_.pstypenames[0] -like "$SMA.*Record") {. $Hash[$_.pstypenames[0]]} # <-- write verbose/warning/info/error
else {Write-Output $_}
}
}
# clean up
$Cmd.Dispose()
$InOut.Dispose()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment